Formatting Objects without XML
Custom formatting in PowerShell has always seemed like one of the most under utilized features of PowerShell to me. And I understand why. It feels kind of bizarre to spend all this time writing PowerShell, creating a cool custom object and then jumping into…XML.
The bad news is, what I’m about to tell you is still going to involve XML. The good news, you don’t have to write it.
PSControl Classes
One of the things I love the most about PowerShell is the pure discoverability it provides. You can usually figure out what you need to do with just about any command, object, etc. That all gets a little abstracted when you’re working through XML.
The PSControl object (and more importantly it’s child classes) give that discoverability back. Let’s
pipe TableControl
to Get-Member
so you can see what I mean.
PS C:\> [System.Management.Automation.TableControl] | Get-Member -Static
TypeName: System.Management.Automation.TableControl
Name MemberType Definition
---- ---------- ----------
Create Method static System.Management.Automation.Table...
Equals Method static bool Equals(System.Object objA, Sy...
new Method System.Management.Automation.TableControl...
ReferenceEquals Method static bool ReferenceEquals(System.Object...
Let’s take a closer look at the Create
static method.
PS C:\> [System.Management.Automation.TableControl]::Create
OverloadDefinitions
-------------------
static System.Management.Automation.TableControlBuilder Create(bool outOfBand, bool autoSize, bool hideTableHeaders)
Now, I know I just went on about discoverability, but what this is leaving out is all of these parameters are
optional. So all we actually need to do is run Create()
. Also, you may have noticed that returns a
different type, so let’s see what options it has.
PS C:\> [System.Management.Automation.TableControl]::Create() | Get-Member
TypeName: System.Management.Automation.TableControlBuilder
Name MemberType Definition
---- ---------- ----------
AddHeader Method System.Management.Automation.TableCont...
EndTable Method System.Management.Automation.TableCont...
GroupByProperty Method System.Management.Automation.TableCont...
GroupByScriptBlock Method System.Management.Automation.TableCont...
StartRowDefinition Method System.Management.Automation.TableRowD...
So if you were to expand the definitions you would see a few of the methods return a copy of themselves,
meaning they are meant to be chained together. We also have a new builder, TableRowDefinitionBuilder
.
I’m sure you get the idea by now, so I’m going to skip ahead a little and show you an example I made for an existing type.
# For System.Reflection.RuntimeParameterInfo
[System.Management.Automation.TableControl]::Create().
GroupByProperty('Member', $null, 'Definition').
AddHeader('Left', 3, '#').
AddHeader('Left', 30, 'Type').
AddHeader('Left', 20, 'Name').
AddHeader('Center', 2, 'In').
AddHeader('Center', 3, 'Out').
AddHeader('Center', 3, 'Opt').
StartRowDefinition($false).
AddPropertyColumn('Position').
AddScriptBlockColumn('$_.ParameterType.Name').
AddPropertyColumn('Name').
AddScriptBlockColumn('if ($_.IsIn) { ''X'' }').
AddScriptBlockColumn('if ($_.IsOut) { ''X'' }').
AddScriptBlockColumn('if ($_.IsOptional) { ''X'' }').
EndRowDefinition().
EndTable()
If you’ve ever dug into reflection you know most of it is completely unformatted. For example, here
is what the TableControl.Create()
method looks like.
# Before (this is just *one* parameter)
PS C:\> [System.Management.Automation.TableControl].GetMethod('Create').GetParameters()
ParameterType : System.Boolean
Name : outOfBand
HasDefaultValue : True
DefaultValue : False
RawDefaultValue : False
MetadataToken : 134234830
Position : 0
Attributes : Optional, HasDefault
Member : System.Management.Automation.TableControlBuilder Create(Boolean, Boolean, Boolean)
IsIn : False
IsOut : False
IsLcid : False
IsRetval : False
IsOptional : True
CustomAttributes : {[System.Runtime.InteropServices.OptionalAttribute()]}
# After
PS C:\> [System.Management.Automation.TableControl].GetMethod('Create').GetParameters()
Definition: System.Management.Automation.TableControlBuilder Create(Boolean, Boolean, Boolean)
# Type Name In Out Opt
- ---- ---- -- --- ---
0 Boolean outOfBand X
1 Boolean autoSize X
2 Boolean hideTableHeaders X
Actually loading it
So you ran my example and all it did was return an object. Now what?
Well, we need to wrap it in an object that tells the formatter what types to target and what to name
our view. Combine this with the Export-FormatData
and Update-FormatData
cmdlets to load it into
the session.
using namespace System.Management.Automation
[ExtendedTypeDefinition]::new(
'System.Reflection.ParameterInfo',
[FormatViewDefinition]::new(
'MyParameterView',
[TableControl]::Create().
GroupByProperty('Member', $null, 'Definition').
AddHeader('Left', 3, '#').
AddHeader('Left', 30, 'Type').
AddHeader('Left', 20, 'Name').
AddHeader('Center', 2, 'In').
AddHeader('Center', 3, 'Out').
AddHeader('Center', 3, 'Opt').
StartRowDefinition($false).
AddPropertyColumn('Position').
AddScriptBlockColumn('$_.ParameterType.Name').
AddPropertyColumn('Name').
AddScriptBlockColumn('if ($_.IsIn) { ''X'' }').
AddScriptBlockColumn('if ($_.IsOut) { ''X'' }').
AddScriptBlockColumn('if ($_.IsOptional) { ''X'' }').
EndRowDefinition().
EndTable()
) -as [List[FormatViewDefinition]]
) | ForEach-Object {
Export-FormatData -Path ".\$($PSItem.TypeName).ps1xml" `
-InputObject $PSItem `
-IncludeScriptBlock `
-Force
# Use -PrependPath for existing types, -AppendPath for custom ones.
Update-FormatData -PrependPath ".\$($PSItem.TypeName).ps1xml"
}
I highly recommend exploring the objects with Get-Member
or diving into the MSDN documentation for each class.
Getting Complex
So that was a small example of some pretty basic formatting. There are classes for all of the formatting types:
ListControl
, WideControl
, CustomControl
and of course TableControl
. The one you’ll probably
use the most for general formatting is TableControl
But if you need really precise control over your output, you want CustomControl
.
For example, I’ve been looking for a way to build flexible string expressions for generating code in editor commands. I’ve been playing with the idea of using formatting for this because it’s really easy to build dynamic statements, and you can easily customize it by adding your own view.
Here is a really early draft of some controls that take a [type]
object and “implements” any abstract
or interface methods the type has.
using namespace System.Management.Automation
using namespace System.Collections.Generic
$parameterControl = [CustomControl]::
Create().
StartEntry().
AddScriptBlockExpressionBinding('", "', 0, 0, '$_.Position -ne 0').
AddText('[').
AddPropertyExpressionBinding('ParameterType').
AddText('] $').
AddPropertyExpressionBinding('Name').
EndEntry().
EndControl()
$methodControl = [CustomControl]::
Create().
StartEntry().
AddNewline().
AddText('[').
AddPropertyExpressionBinding('ReturnType').
AddText('] ').
AddPropertyExpressionBinding('Name').
AddText(' (').
AddScriptBlockExpressionBinding(
<# scriptBlock: #> '$_.GetParameters()',
<# enumerateCollection: #> 1,
<# selectedByType: #> 0,
<# selectedByScript: #> '$_.GetParameters().Count',
<# customControl: #> $parameterControl
).
AddText(') {').
AddNewline().
StartFrame(4).
AddText('throw [NotImplementedException]::new()').
AddNewline().
EndFrame().
AddText('}').
AddNewline().
EndEntry().
EndControl()
$classControl = [CustomControl]::
Create().
StartEntry().
AddText('class MyClass : ').
AddScriptBlockExpressionBinding('$_').
AddText(' {').
AddNewline().
StartFrame(4).
AddScriptBlockExpressionBinding(
<# scriptBlock: #> '
if ($_.IsAbstract) {
$return = $_.DeclaredMethods.
Where{ $_.IsAbstract }
} elseif ($_.IsInterface) {
$return = $_.DeclaredMethods
}
$return
',
<# enumerateCollection: #> $true,
<# selectedByType: #> $null,
<# selectedByScript: #> '
$_.IsInterface -or
($_.IsAbstract -and
$_.DeclaredMethods.Where{ $_.IsAbstract })
',
<# customControl: #> $methodControl
).
EndFrame().
AddText('}').
EndEntry().
EndControl()
$formats = @(
[ExtendedTypeDefinition]::new(
'System.Reflection.RuntimeParameterInfo',
[FormatViewDefinition]::new(
'ParameterView',
$parameterControl
) -as [List[FormatViewDefinition]]
)
[ExtendedTypeDefinition]::new(
'System.Reflection.RuntimeMethodInfo',
[FormatViewDefinition]::new(
'MethodView',
$methodControl
) -as [List[FormatViewDefinition]]
)
[ExtendedTypeDefinition]::new(
'System.RuntimeType',
[FormatViewDefinition]::new(
'TypeView',
$classControl
) -as [List[FormatViewDefinition]]
)
)
And here it is in action.
PS C:\> [System.Collections.IDictionary]
# This won't actually load because it missed a method, but you get the idea.
class MyClass : System.Collections.IDictionary {
[System.Object] get_Item ([System.Object] $key) {
throw [NotImplementedException]::new()
}
[System.Void] set_Item ([System.Object] $key, [System.Object]
$value) {
throw [NotImplementedException]::new()
}
[System.Collections.ICollection] get_Keys () {
throw [NotImplementedException]::new()
}
[System.Collections.ICollection] get_Values () {
throw [NotImplementedException]::new()
}
[System.Boolean] Contains ([System.Object] $key) {
throw [NotImplementedException]::new()
}
[System.Void] Add ([System.Object] $key, [System.Object] $value) {
throw [NotImplementedException]::new()
}
[System.Void] Clear () {
throw [NotImplementedException]::new()
}
[System.Boolean] get_IsReadOnly () {
throw [NotImplementedException]::new()
}
[System.Boolean] get_IsFixedSize () {
throw [NotImplementedException]::new()
}
[System.Collections.IDictionaryEnumerator] GetEnumerator () {
throw [NotImplementedException]::new()
}
[System.Void] Remove ([System.Object] $key) {
throw [NotImplementedException]::new()
}
}
Final thoughts
There is a way to load this directly without writing it to XML, but it requires a huge amount of reflection and isn’t really consistant. If anyone is looking for a project, a domain specific language that does all this for you would be really cool.
Also, if you’re looking for more examples, check out the DefaultFormatters folder in the PowerShell repo. It’s all in C#, but it should be pretty easy to translate.