Last active
June 11, 2018 07:11
-
-
Save Jaykul/176c4aacc477a69b3d0fa86b4229503b to your computer and use it in GitHub Desktop.
How we "compile" modules from source .ps1 files
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# This is just an example of a Build.psd1 | |
# The idea is simple: you can set values for any of the parameters of Optimize-Module in this hashtable: | |
@{ | |
# If I make a build.psd1, I always specify the path to my module's psd1 | |
Path = "YourModule.psd1" | |
# Copy assemblies you keep in a \lib sub-folder | |
CopyDirectories = "lib" | |
# Make sure we export aliases from this module | |
ExportModuleMember = "Export-ModuleMember -Function *-* -Alias *" | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
function Convert-CodeCoverage { | |
<# | |
.SYNOPSIS | |
Convert the file name and line numbers from Pester code coverage of "optimized" modules to the source | |
.EXAMPLE | |
Invoke-Pester .\Tests -CodeCoverage (Get-ChildItem .\Output -Filter *.psm1).FullName -PassThru | | |
Convert-CodeCoverage -SourceRoot .\Source -Relative | |
Runs pester tests from a "Tests" subfolder against an optimized module in the "Output" folder, | |
piping the results through Convert-CodeCoverage to render the code coverage misses with the source paths. | |
#> | |
param( | |
# The root of the source folder (for resolving source code paths) | |
[Parameter(Mandatory)] | |
[string]$SourceRoot, | |
# The output of `Invoke-Pester -Pasthru` | |
# Note: Pester doesn't apply a custom type name | |
[Parameter(ValueFromPipeline)] | |
[PSObject]$InputObject, | |
# Output paths as short paths, relative to the SourceRoot | |
[switch]$Relative | |
) | |
begin { | |
$filemap = @{} | |
# Conditionally define the Resolve function as either Convert-Path or Resolve-Path | |
${function:Resolve} = if($Relative) { | |
{ process { $_ | Resolve-Path -Relative } } | |
} else { | |
{ process { $_ | Convert-Path } } | |
} | |
} | |
process { | |
Push-Location $SourceRoot | |
try { | |
foreach ($miss in $InputObject.CodeCoverage.MissedCommands ) { | |
if (!$filemap.ContainsKey($miss.File)) { | |
$matches = Select-String "# BEGIN (?<path>.*)" -Path $miss.file | |
$filemap[$miss.File] = @($matches.ForEach( { | |
[PSCustomObject]@{ | |
Line = $_.LineNumber | |
Path = $_.Matches[0].Groups["path"].Value | Resolve | |
} | |
})) | |
} | |
$hit = $filemap[$miss.file] | |
# These are all negative, indicating they are the match *after* the line we're searching for | |
# We need the match *before* the line we're searching for | |
# And we need it as a zero-based index: | |
$index = -2 - [Array]::BinarySearch($filemap[$miss.file].Line, $miss.Line) | |
$Source = $filemap[$miss.file][$index] | |
$miss.File = $Source.Path | |
$miss.Line = $miss.Line - $Source.Line | |
$miss | |
} | |
} finally { | |
Pop-Location | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#Requires -Module Configuration | |
function Optimize-Module { | |
<# | |
.Synopsis | |
Compile a module from ps1 files to a single psm1 | |
.Description | |
Compiles modules from source according to conventions: | |
1. A single ModuleName.psd1 manifest file with metadata | |
2. Source subfolders in the same directory as the manifest: | |
Classes, Private, Public contain ps1 files | |
3. Optionally, a build.psd1 file containing settings for this function | |
The optimization process: | |
1. The OutputDirectory is created | |
2. All psd1/psm1/ps1xml files in the root will be copied to the output | |
3. If specified, $CopyDirectories will be copied to the output | |
4. The ModuleName.psm1 will be generated (overwritten completely) by concatenating all .ps1 files in subdirectories (that aren't specified in CopySubdirectories) | |
5. The ModuleName.psd1 will be updated (based on existing data) | |
#> | |
param( | |
# The path to the module folder, manifest or build.psd1 | |
[Parameter(Position=0, ValueFromPipelineByPropertyName)] | |
[ValidateScript( { | |
if (($IsPath = Test-Path $_ )) { | |
$true | |
} else { | |
throw "Source must point to a valid module" | |
} | |
} )] | |
[Alias("ModuleManifest")] | |
[string]$Path, | |
# Where to build the module. | |
# Defaults to a version number folder, adjacent to the module folder | |
[Alias("Destination")] | |
[string]$OutputDirectory, | |
[version]$ModuleVersion, | |
# Folders which should be copied intact to the module output | |
# Can be relative to the module folder | |
[AllowEmptyCollection()] | |
[string[]]$CopyDirectories = @(), | |
# A Filter (relative to the module folder) for public functions | |
# If non-empty, ExportedFunctions will be set with the file BaseNames of matching files | |
# Defaults to Public\*.ps1 | |
[AllowEmptyString()] | |
[string[]]$PublicFilter = "Public\*.ps1", | |
# File encoding for output RootModule (defaults to UTF8) | |
[Microsoft.PowerShell.Commands.FileSystemCmdletProviderEncoding] | |
$Encoding = "UTF8", | |
# A line which will be added at the bottom of the psm1. The intention is to allow you to add an export like: | |
# Export-ModuleMember -Alias *-QM* -Functions * -Variables QMConstant_* | |
# | |
# The default is nothing | |
$ExportModuleMember, | |
# Controls whether or not there is a build or cleanup performed | |
[ValidateSet("Clean", "Build", "CleanBuild")] | |
[string]$Target = "CleanBuild", | |
# Output the ModuleInfo of the "built" module | |
[switch]$Passthru | |
) | |
process { | |
# If a path is $passed, use that | |
if($Path) { | |
$ModuleBase = Split-Path $Path -Parent | |
# Do not use GetFileNameWithoutExtension, some module names have dots in them | |
$ModuleName = (Split-Path $Path -Leaf) -replace ".psd1$" | |
# Support passing the path to a module folder | |
if (Test-Path $Path -PathType Container) { | |
if ( (Test-Path (Join-Path $Path build.psd1)) -or | |
(Test-Path (Join-Path $Path "$ModuleName.psd1")) | |
) { | |
$ModuleBase = $Path | |
$Path = Join-Path $Path "$ModuleName.psd1" | |
if(Test-Path $Path) { | |
$PSBoundParameters["Path"] = $Path | |
} else { | |
$null = $PSBoundParameters.Remove("Path") | |
} | |
} else { | |
throw "Module not found in $Path. Try passing the full path to the manifest file." | |
} | |
} | |
# Add support for passing the path to a build.psd1 | |
if( (Test-Path $Path -PathType Leaf) -and ($ModuleName -eq "build") ) { | |
$null = $PSBoundParameters.Remove("Path") | |
} | |
Push-Location $ModuleBase | |
# Otherwise, look for a local build.psd1 | |
} elseif(Test-Path Build.psd1) { | |
Push-Location | |
} else { | |
throw "Build.psd1 not found in PWD. You must specify the $Path to the build" | |
} | |
# Read build.psd1 for defaults | |
if(Test-Path Build.psd1) { | |
$BuildInfo = Import-LocalizedData -BaseDirectory $Pwd.Path -FileName Build | |
} else { | |
$BuildInfo = @{} | |
} | |
# Overwrite with parameter values | |
foreach($property in $PSBoundParameters.Keys) { | |
$BuildInfo.$property = $PSBoundParameters.$property | |
} | |
# Read Module Manifest for details | |
$ModuleInfo = Test-ModuleManifest $BuildInfo.Path -WarningAction SilentlyContinue -ErrorAction SilentlyContinue -ErrorVariable Problems | |
if($Problems) { | |
$Problems = $Problems.Where{ $_.FullyQualifiedErrorId -notmatch "^Modules_InvalidRequiredModulesinModuleManifest"} | |
if($Problems) { | |
foreach($problem in $Problems) { | |
Write-Error $problem | |
} | |
throw "Unresolvable problems in module manifest" | |
} | |
} | |
foreach($property in $BuildInfo.Keys) { | |
# Note:we can't overwrite the Path from the Build.psd1 | |
Add-Member -Input $ModuleInfo -Type NoteProperty -Name $property -Value $BuildInfo.$property -ErrorAction SilentlyContinue | |
} | |
# Copy in default parameters | |
if(!(Get-Member -InputObject $ModuleInfo -Name PublicFilter)){ | |
Add-Member -Input $ModuleInfo -Type NoteProperty -Name PublicFilter -Value $PublicFilter | |
} | |
if(!(Get-Member -InputObject $ModuleInfo -Name Encoding)){ | |
Add-Member -Input $ModuleInfo -Type NoteProperty -Name Encoding -Value $Encoding | |
} | |
# TODO: Increment version? | |
# Ensure OutputDirectory | |
if(!$ModuleInfo.OutputDirectory) { | |
$OutputDirectory = Join-Path (Split-Path $ModuleInfo.ModuleBase -Parent) $ModuleInfo.Version | |
Add-Member -Input $ModuleInfo -Type NoteProperty -Name OutputDirectory -Value $OutputDirectory -Force | |
} | |
$OutputDirectory = $ModuleInfo.OutputDirectory | |
Write-Progress "Building $($ModuleInfo.ModuleBase)" -Status "Use -Verbose for more information" | |
Write-Verbose "Building $($ModuleInfo.ModuleBase)" | |
Write-Verbose " Output to: $OutputDirectory" | |
if ($Target -match "Clean") { | |
Write-Verbose "Cleaning $OutputDirectory" | |
if (Test-Path $OutputDirectory) { | |
Remove-Item $OutputDirectory -Recurse -Force -ErrorAction Stop | |
} | |
if($Target -notmatch "Build") { | |
return # No build, just cleaning | |
} | |
} else { | |
# If we're not cleaning, skip the build if it's up to date already | |
Write-Verbose "Target $Target" | |
$NewestBuild = Get-ChildItem $OutputDirectory -Recurse | | |
Sort-Object LastWriteTime -Descending | | |
Select-Object -First 1 -ExpandProperty LastWriteTime | |
$IsNew = Get-ChildItem $ModuleInfo.ModuleBase -Recurse | | |
Where-Object LastWriteTime -gt $NewestBuild | | |
Select-Object -First 1 -ExpandProperty LastWriteTime | |
if($null -eq $IsNew) { | |
return # Skip the build | |
} | |
} | |
$null = mkdir $OutputDirectory -Force | |
Write-Verbose "Copy files to $OutputDirectory" | |
# Copy the files and folders which won't be processed | |
Copy-Item *.psm1, *.psd1, *.ps1xml -Exclude "build.psd1" -Destination $OutputDirectory -Force | |
if($ModuleInfo.CopyDirectories) { | |
Write-Verbose "Copy Entire Directories: $($ModuleInfo.CopyDirectories)" | |
Copy-Item -Path $ModuleInfo.CopyDirectories -Recurse -Destination $OutputDirectory -Force | |
} | |
# Output psm1 | |
$RootModule = Join-Path $OutputDirectory "$($ModuleInfo.Name).psm1" | |
$OutputManifest = Join-Path $OutputDirectory "$($ModuleInfo.Name).psd1" | |
Write-Verbose "Combine scripts to $RootModule" | |
# Prefer pipeline to speed for the sake of memory and file IO | |
# SilentlyContinue because there don't *HAVE* to be functions at all | |
$AllScripts = Get-ChildItem -Path $ModuleInfo.ModuleBase -Exclude $ModuleInfo.CopyDirectories -Directory -ErrorAction SilentlyContinue | | |
Get-ChildItem -Filter *.ps1 -Recurse -ErrorAction SilentlyContinue | |
if($AllScripts) { | |
$AllScripts | ForEach-Object { | |
$SourceName = Resolve-Path $_.FullName -Relative | |
Write-Verbose "Adding $SourceName" | |
"# BEGIN $SourceName" | |
Get-Content $SourceName | |
"# END $SourceName" | |
} | Set-Content -Path $RootModule -Encoding $ModuleInfo.Encoding | |
if($ModuleInfo.ExportModuleMember) { | |
Add-Content -Path $RootModule -Value $ModuleInfo.ExportModuleMember -Encoding $ModuleInfo.Encoding | |
} | |
# If there is a PublicFilter, update ExportedFunctions | |
if($ModuleInfo.PublicFilter) { | |
# SilentlyContinue because there don't *HAVE* to be public functions | |
if($PublicFunctions = Get-ChildItem $ModuleInfo.PublicFilter -Recurse -ErrorAction SilentlyContinue | Select-Object -ExpandProperty BaseName) { | |
# TODO: Remove the _Public hack | |
Update-Metadata -Path $OutputManifest -PropertyName FunctionsToExport -Value ($PublicFunctions -replace "_Public$") | |
} | |
} | |
} | |
Write-Verbose "Update Manifest to $OutputManifest" | |
Update-Metadata -Path $OutputManifest -PropertyName Copyright -Value ($ModuleInfo.Copyright -replace "20\d\d",(Get-Date).Year) | |
if($ModuleVersion) { | |
Update-Metadata -Path $OutputManifest -PropertyName ModuleVersion -Value $ModuleVersion | |
} | |
# This is mostly for testing ... | |
if($Passthru) { | |
Test-ModuleManifest $OutputManifest | |
} | |
Pop-Location | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment