Skip to content

Commit 5ea6dea

Browse files
Combine files at build time (#24)
This change adds a build step that combines all functions into a single .psm1 file. This will reduce the initial load time of the module, which will be more important as more functions are added to the module.
1 parent f1b0e75 commit 5ea6dea

File tree

3 files changed

+260
-24
lines changed

3 files changed

+260
-24
lines changed

EditorServicesCommandSuite.build.ps1

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@
33
param()
44

55
$moduleName = 'EditorServicesCommandSuite'
6-
$manifest = Test-ModuleManifest -Path $PSScriptRoot\module\$moduleName.psd1 `
7-
-ErrorAction Ignore `
8-
-WarningAction Ignore
6+
$manifest = Test-ModuleManifest -Path $PSScriptRoot\module\$moduleName.psd1 -ErrorAction Ignore -WarningAction Ignore
97

108
$script:Settings = @{
119
Name = $moduleName
@@ -29,47 +27,46 @@ $script:Discovery = @{
2927
}
3028

3129
task Clean {
32-
if (Test-Path $script:Folders.Release) {
33-
Remove-Item $script:Folders.Release -Recurse
30+
if (Test-Path $PSScriptRoot\Release) {
31+
Remove-Item $PSScriptRoot\Release -Recurse
3432
}
35-
$null = New-Item $script:Folders.Release -ItemType Directory
33+
$null = New-Item $Folders.Release -ItemType Directory
3634
}
3735

38-
task BuildDocs -If { $script:Discovery.HasDocs } {
39-
$null = New-ExternalHelp -Path $PSScriptRoot\docs\$PSCulture `
40-
-OutputPath ('{0}\{1}' -f $script:Folders.Release, $PSCulture)
36+
task BuildDocs -If { $Discovery.HasDocs } {
37+
$output = '{0}\{1}' -f $Folders.Release, $PSCulture
38+
$null = New-ExternalHelp -Path $PSScriptRoot\docs\$PSCulture -OutputPath $output
4139
}
4240

4341
task CopyToRelease {
44-
Copy-Item -Path ('{0}\*' -f $script:Folders.PowerShell) `
45-
-Destination $script:Folders.Release `
46-
-Recurse `
47-
-Force
42+
$moduleName = $Settings.Name
43+
& "$PSScriptRoot\tools\BuildMonolith.ps1" -OutputPath $Folders.Release -ModuleName $Settings.Name
44+
45+
"$moduleName.psd1",
46+
'en-US' | ForEach-Object {
47+
Join-Path $Folders.PowerShell -ChildPath $PSItem |
48+
Copy-Item -Destination $Folders.Release -Recurse
49+
}
4850
}
4951

50-
task Analyze -If { $script:Settings.ShouldAnalyze } {
51-
Invoke-ScriptAnalyzer -Path $script:Folders.Release `
52-
-Settings $PSScriptRoot\ScriptAnalyzerSettings.psd1 `
53-
-Recurse
52+
task Analyze -If { $Settings.ShouldAnalyze } {
53+
Invoke-ScriptAnalyzer -Path $Folders.Release -Settings $PSScriptRoot\ScriptAnalyzerSettings.psd1 -Recurse
5454
}
5555

56-
task Test -If { $script:Discovery.HasTests -and $script:Settings.ShouldTest } {
56+
task Test -If { $Discovery.HasTests -and $Settings.ShouldTest } {
5757
Invoke-Pester -PesterOption @{ IncludeVSCodeMarker = $true }
5858
}
5959

6060
task DoInstall {
6161
$installBase = $Home
6262
if ($profile) { $installBase = $profile | Split-Path }
63-
$installPath = '{0}\Modules\{1}\{2}' -f $installBase, $script:Settings.Name, $script:Settings.Version
63+
$installPath = '{0}\Modules\{1}\{2}' -f $installBase, $Settings.Name, $Settings.Version
6464

6565
if (-not (Test-Path $installPath)) {
6666
$null = New-Item $installPath -ItemType Directory
6767
}
6868

69-
Copy-Item -Path ('{0}\*' -f $script:Folders.Release) `
70-
-Destination $installPath `
71-
-Force `
72-
-Recurse
69+
Copy-Item -Path ('{0}\*' -f $Folders.Release) -Destination $installPath -Force -Recurse
7370
}
7471

7572
task DoPublish {
@@ -78,7 +75,7 @@ task DoPublish {
7875
}
7976

8077
$apiKey = (Import-Clixml $env:USERPROFILE\.PSGallery\apikey.xml).GetNetworkCredential().Password
81-
Publish-Module -Name $script:Folders.Release -NuGetApiKey $apiKey -Confirm
78+
Publish-Module -Name $Folders.Release -NuGetApiKey $apiKey -Confirm
8279
}
8380

8481
task Build -Jobs Clean, CopyToRelease, BuildDocs

module/EditorServicesCommandSuite.psm1

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,13 @@ if (-not ('Antlr4.StringTemplate.StringRenderer' -as [type])) {
1919
Add-Type -Path $psstPath\Antlr4.StringTemplate.dll
2020
}
2121

22+
# ~MONOLITH_INJECT_START~
23+
# This section will be replaced with the contents of the files it calls during the build process.
24+
# See tools/BuildMonolith.ps1 for more details.
2225
"$PSScriptRoot\Classes", "$PSScriptRoot\Public", "$PSScriptRoot\Private" |
2326
Get-ChildItem -Filter '*.ps1' |
2427
ForEach-Object { . $PSItem.FullName }
28+
# ~MONOLITH_INJECT_END~
2529

2630
# Export only the functions using PowerShell standard verb-noun naming.
2731
# Be sure to list each exported functions in the FunctionsToExport field of the module manifest file.

tools/BuildMonolith.ps1

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
#requires -Version 5.1
2+
3+
<#
4+
.SYNOPSIS
5+
Combine module files at build time to improve load time.
6+
7+
.DESCRIPTION
8+
The BuildMonolith script parses all function and class files in the source directory and combines
9+
then into a single .psm1 file, or a .psm1 and a Classes.ps1 file to avoid exporting classes.
10+
11+
This process improves the load time of the module at the cost of increased complexity during the
12+
build process. I do not personally recommended replicating this process outside of this module
13+
unless you have a similar number of files.
14+
15+
.PARAMETER OutputPath
16+
Specifies the path to the release directory.
17+
18+
.PARAMETER ModuleName
19+
Specifies the name of the module being built. This will be used when naming the generated files.
20+
21+
.PARAMETER SourcePath
22+
Specifies the path to the source directory. If not specified, the source directory will be set to
23+
a directory named "module" in the directory above this script. i.e. $PSScriptRoot\..\module
24+
25+
.PARAMETER ClassFolderName
26+
Specifies the name(s) of folder(s) within the source directory that contain classes. If not specified,
27+
this script will look for a directory named "Classes".
28+
29+
.PARAMETER FunctionFolderName
30+
Specifies the name(s) of folder(s) within the source directory that contain functions. If not specified,
31+
this script will look for directories named "Public" and/or "Private".
32+
33+
.PARAMETER ExportClasses
34+
If specified, classes will be included in the main .psm1 file. By default, classes are outputted
35+
into a different file that is loaded by the .psm1 which disables importing classes.
36+
37+
.EXAMPLE
38+
PS C:\> .\BuildMonolith.ps1 -OutputDirectory .\Release\EditorServicesCommandSuite\1.0.0 -ModuleName EditorServicesCommandSuite
39+
40+
Builds the monolith module file for this module.
41+
42+
.NOTES
43+
- Only classes and functions in the root ScriptBlockAst will be extracted
44+
45+
- Because namespaces are combined, it's possible this process could cause type resolution conflicts
46+
#>
47+
48+
using namespace System.Collections.Generic
49+
using namespace System.IO
50+
using namespace System.Management.Automation
51+
using namespace System.Management.Automation.Language
52+
using namespace Microsoft.PowerShell.Commands
53+
54+
[CmdletBinding()]
55+
param(
56+
[Parameter(Mandatory)]
57+
[ValidateNotNullOrEmpty()]
58+
[string] $OutputPath,
59+
60+
[Parameter(Mandatory)]
61+
[ValidateNotNullOrEmpty()]
62+
[string] $ModuleName,
63+
64+
[ValidateNotNullOrEmpty()]
65+
[string] $SourcePath,
66+
67+
[ValidateNotNullOrEmpty()]
68+
[string[]] $ClassFolderName = 'Classes',
69+
70+
[ValidateNotNullOrEmpty()]
71+
[string[]] $FunctionFolderName = ('Public', 'Private'),
72+
73+
[switch] $ExportClasses
74+
)
75+
begin {
76+
# Sort using statements by type (namespace/module/assembly) then by System/Other then alphabetically.
77+
function GetUsingStatementText {
78+
param([String[]] $Statements)
79+
end {
80+
$regex = 'using (namespace|assembly|module) '
81+
82+
$groupedByType = $Statements | Group-Object {
83+
$PSItem |
84+
Select-String -Pattern $regex -AllMatches |
85+
ForEach-Object { $PSItem.Matches[0].Groups[1].Value }
86+
}
87+
88+
$groupedText = $groupedByType |
89+
Sort-Object Name |
90+
ForEach-Object {
91+
$PSItem.Group |
92+
Group-Object { ($PSItem -replace $regex).StartsWith('System') } |
93+
Sort-Object Name -Descending |
94+
ForEach-Object { ($PSItem.Group | Sort-Object) } |
95+
Out-String
96+
}
97+
98+
return $groupedText | Out-String
99+
}
100+
}
101+
102+
# Resolve paths from parameters and handle errors.
103+
function ResolvePath {
104+
param([string] $Path, [string] $VariableName, [switch] $Directory)
105+
end {
106+
try {
107+
$resolved = Resolve-Path $Path -ErrorAction Stop
108+
} catch {
109+
$exception = [ItemNotFoundException]::new([UtilityResources]::PathDoesNotExist -f $Path)
110+
throw [ErrorRecord]::new(
111+
<# exception: #> $exception,
112+
<# errorId: #> 'PathNotFound',
113+
<# errorCategory: #> 'ObjectNotFound',
114+
<# targetObject: #> $Path)
115+
}
116+
117+
if ($Directory.IsPresent -and -not [Directory]::Exists($resolved)) {
118+
$exception = [PSArgumentException]::new(
119+
'The value specified for the parameter "{0}" must be a directory.' -f $VariableName)
120+
throw [ErrorRecord]::new(
121+
<# exception: #> $exception,
122+
<# errorId: #> 'DirectoryNotFound',
123+
<# errorCategory: #> 'InvalidArgument',
124+
<# targetObject: #> $resolved)
125+
}
126+
127+
return $resolved
128+
}
129+
}
130+
}
131+
end {
132+
if (-not $SourcePath) {
133+
$SourcePath = "$PSScriptRoot\..\module"
134+
}
135+
136+
$SourcePath = ResolvePath $SourcePath -VariableName SourcePath -Directory
137+
$OutputPath = ResolvePath $OutputPath -VariableName OutputPath -Directory
138+
139+
$header = @'
140+
<#
141+
This file was generated during the build process to decrease module load
142+
time. You can see the original source, organized into separate files at
143+
the GitHub repository for this project.
144+
#>
145+
146+
147+
'@
148+
149+
$usings = @{
150+
Class = [HashSet[string]]::new()
151+
Function = [HashSet[string]]::new()
152+
}
153+
154+
$commands = @{
155+
Class = [List[TypeDefinitionAst]]::new()
156+
Function = [List[FunctionDefinitionAst]]::new()
157+
}
158+
159+
$folders = @{
160+
Class = $ClassFolderName
161+
Function = $FunctionFolderName
162+
}
163+
164+
foreach ($commandType in 'function', 'class') {
165+
foreach ($commandFolder in $folders.$commandType) {
166+
try {
167+
$commandFolder = Join-Path $SourcePath -ChildPath $commandFolder -Resolve -ErrorAction Stop
168+
} catch {
169+
$PSCmdlet.WriteDebug("Could not resolve $commandType directory '$commandFolder', skipping.")
170+
continue
171+
}
172+
173+
$files = Get-ChildItem $commandFolder\*.ps1
174+
if (-not $files) {
175+
$PSCmdlet.WriteDebug("No ps1 files found in class directory '$commandFolder', skipping.")
176+
continue
177+
}
178+
179+
foreach ($file in $files) {
180+
$ast = [Parser]::ParseFile(
181+
<# fileName: #> $file.FullName,
182+
<# tokens: #> [ref]$null,
183+
<# errors: #> [ref]$null)
184+
185+
$astType = $commands.$commandType.GetType().GetGenericArguments()[0]
186+
$validAsts = $ast.FindAll({ param($a) $a -is $astType }, $false)
187+
if ($validAsts) {
188+
$commands.$commandType.AddRange($validAsts.ToArray().ForEach($astType))
189+
}
190+
191+
$usingAsts = $ast.FindAll({ param($a) $a -is [UsingStatementAst] }, $true)
192+
foreach ($usingAst in $usingAsts) {
193+
$null = $usings.$commandType.Add($usingAst.ToString())
194+
}
195+
}
196+
}
197+
}
198+
199+
if ($ExportClasses.IsPresent) {
200+
$usings.Function.UnionWith($usings.Class)
201+
$allCommands = [List[StatementAst]]::new($commands.Class)
202+
$allCommands.AddRange($commands.Function)
203+
$commands.Function = $allCommands
204+
} else {
205+
$classFileName = '{0}.Classes.ps1' -f $ModuleName
206+
$content = $header
207+
$content += GetUsingStatementText -Statements $usings.Class
208+
$content += $commands.Class.Extent.Text -join ([Environment]::NewLine + [Environment]::NewLine)
209+
210+
$classOutputFile = Join-Path $OutputPath -ChildPath $classFileName
211+
$content | Out-File -Encoding default -FilePath $classOutputFile
212+
}
213+
214+
$moduleOutputFile = (Join-Path $OutputPath -ChildPath $ModuleName) + '.psm1'
215+
$content = $header
216+
$content += GetUsingStatementText -Statements $usings.Function
217+
218+
$injection = [string]::Empty
219+
if (-not $ExportClasses.IsPresent) {
220+
$injection += ('. "$PSScriptRoot\{0}"' -f $classFileName) +
221+
[Environment]::NewLine +
222+
[Environment]::NewLine
223+
}
224+
225+
$injection += $commands.Function.Extent.Text -join ([Environment]::NewLine + [Environment]::NewLine)
226+
227+
# Use a match evaluator instead of plain text so we don't have to worry about escaping capture groups.
228+
$content += [regex]::Replace(
229+
<# input: #> (Get-Content -Raw $SourcePath\$ModuleName.psm1),
230+
<# pattern: #> '# ~MONOLITH_INJECT_START~.+?# ~MONOLITH_INJECT_END~',
231+
<# evaluator: #> { $injection },
232+
<# options: #> 'SingleLine')
233+
234+
$content | Out-File -Encoding default -FilePath $moduleOutputFile -NoNewline
235+
}

0 commit comments

Comments
 (0)