Powershell 5 has a nice declarative "using module" statement that can be provided at the top of a file to declare the dependencies of the script. Presumably one should be able to use this programmatically to determine what the dependencies of a given powershell script or module are. But I can't find anything on how to consume that - does only powershell use that internally? Is there no developer-API to read the requirements-list of a .ps1 file?
To complement your own effective solution with some background information:
Preface:
The following finds using
statements in a given script file (*.ps1
), which not only comprises using module
statements, but also using assembly
and using namespace
statements; using namespace
statements latter do not constitute a dependency per se, as their only purpose is to allow to refer to types by simple name only.
using
statements are not the only way to import modules and load assemblies (e.g, the former can also be imported with Import-Module
, and the latter with Add-Type
). Additionally, there are potentially implicit module dependencies that rely on module auto-loading.
In short: Static analysis via using
statements isn't guaranteed to find all dependencies.
Ultimately, it is PowerShell's language parser, [System.Management.Automation.Language.Parser]
that provides the AST (Abstract Syntax Tree) that your solution relies on.
It has a static ::ParseFile()
method that directly accepts script file paths.
The .UsingStatement
property of the [System.Management.Automation.Language.ScriptBlockAst]
instance returned by ::ParseFile()
(and as contained in a [scriptblock]
's .Ast
property, as shown in your answer) contains [System.Management.Automation.Language.UsingStatementAst]
instances, if any, describing the using
statements.
Indeed, their .Name
property is filled in for using module
statements with simple module names and paths as well as for the assembly names and paths used in using assembly
statements.
.Name
isn't a string, but an instance of [System.Management.Automation.Language.StringConstantExpressionAst]
. While such an instance by definition has only verbatim content - variables cannot be used in using
statements - it may contain incidental quoting or escaping, because using module Foo
may also be expressed as using module 'Foo'
or using module "Foo"
.
Removing the incidental quoting can be as simple as .Name.ToString.Trim("'`"")
, though, at least hypothetically, this isn't fully robust, because it would fail with something like using module 'Foo''Bar'
. Even an unquoted form that uses `
-escaping could fail, e.g. using module Foo`'Bar
. A pragmatic solution is to use Invoke-Expression
on .Name.ToString()
passed to Write-Output
- while Invoke-Expression
is generally to be avoided, its use is safe here. Note that passing an argument that isn't a string to Invoke-Expression
implicitly stringifies it.
Only using module
statements that use a FQMN (Fully Qualified Module Name, e.g.
using module @{ ModuleName = 'Foo'; ModuleVersion = '2.0.1' }
)
have the .ModuleSpecification
property filled in instead, in the form of a [System.Management.Automation.Language.HashtableAst]
instance.
[hashtable]
from this instance is non-trivial, but, given that its .ToString()
representation is the original hash-table literal source code (also composed of literal values only), the simplest approach is the simplest approach is again to pass its string representation to Invoke-Expression
.The following puts it all together:
It extracts all using module
and using assembly
statements from a given script file (*.ps1
) ...
... and outputs a [pscustomobject]
instance for each with three properties:
.Kind
is either Module
or Assembly
(passing the .UsingStatementKind
property value through)
.NameOrSpec
is:
either: the module or assembly name or path, with incidental quoting and escaping removed
or: a [hashtable]
instance representing the FQMN in the originating using module
statement.
.SourceCode
is the original statement as text ([string]
).
$scriptPath = './test.ps1'
[System.Management.Automation.Language.Parser]::ParseFile(
(Convert-Path $scriptPath),
[ref] $null, # `out` parameter that receives the array of tokens; not used here
[ref] $null # `out` parameter that receives an array of errors, if any; not used here.
).UsingStatements |
Where-Object UsingStatementKind -ne Namespace | # Filter out `using namespace` statements
ForEach-Object {
[pscustomobject] @{
Kind = $_.UsingStatementKind
NameOrSpec = if ($_.ModuleSpecification) {
Invoke-Expression $_.ModuleSpecification
} else {
Invoke-Expression ('Write-Output ' + $_.Name)
}
SourceCode = $_.Extent
}
}
If you fill test.ps1
with the following content...
using module PSReadLine
# Variations with quoting
using module 'PSReadLine'
using module "PSReadLine"
# Module with escaped embedded '
using module Foo`'Bar
# FQMN module spec
using module @{ ModuleName = 'Foo'; ModuleVersion = '2.0.0' }
# Reference to built-in assembly.
# Note: Broken in PowerShell (Core) as of v7.3.6 - see https://github.com/PowerShell/PowerShell/issues/11856
using assembly System.Windows.Forms
# Variation with quoting
using assembly 'System.Windows.Forms'
# Reference to assembly relative to the script's location.
using assembly ./path/to/some/assembly.dll
# Variation with quoting
using assembly './path/to/some/assembly.dll'
# ...
... then running the code above yields the following:
Kind NameOrSpec SourceCode
---- ---------- ----------
Module PSReadLine using module PSReadLine
Module PSReadLine using module 'PSReadLine'
Module PSReadLine using module "PSReadLine"
Module Foo'Bar using module Foo`'Bar
Module {[ModuleName, Foo], [ModuleVersion, 2.0.0]} using module @{ ModuleName = 'Foo'; ModuleVersion = '2.0.0' }
Assembly System.Windows.Forms using assembly System.Windows.Forms
Assembly System.Windows.Forms using assembly 'System.Windows.Forms'
Assembly ./path/to/some/assembly.dll using assembly ./path/to/some/assembly.dll
Assembly ./path/to/some/assembly.dll using assembly './path/to/some/assembly.dll'