1

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?

Pxtl
  • 880
  • 8
  • 18

2 Answers2

1

Thanks to some help on Mastodon from @nyanhp, I have the answer - the "ScriptBlock" class.

$ScriptBlock = [System.Management.Automation.ScriptBlock]::Create((Get-Content $scriptPath -Raw))
$ScriptBlock.Ast.UsingStatements | 
    Select-Object Alias, Extent, ModuleSpecification, Name, UsingStatementKind

yields

Alias               :
Extent              : using module  ActiveDirectory
ModuleSpecification :
Name                : ActiveDirectory
UsingStatementKind  : Module

which is a good place to start for getting more details. I assume that if a full module spec is provided instead of a simple name, it will appear in the ModuleSpecification member instead of the Name member.

Pxtl
  • 880
  • 8
  • 18
1

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.

    • However, as with any .NET method call, a full path must be passed, given that .NET's working directory usually differs from PowerShell's.
  • 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.

      • Reconstructing a [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

        • Note: Any relative path is relative to the script's location.
      • 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'
mklement0
  • 382,024
  • 64
  • 607
  • 775