Skip to content

Commit 73081a2

Browse files
Make class and enum loader dependency aware
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 317bcb1 commit 73081a2

6 files changed

Lines changed: 312 additions & 210 deletions

File tree

.github/actions/Build-PSModule/src/helpers/Build/Add-ContentFromItem.ps1

Lines changed: 123 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,103 @@
1-
function Add-ContentFromItem {
1+
function Get-DependencyOrderedScriptFiles {
2+
[OutputType([System.IO.FileInfo[]])]
3+
[CmdletBinding()]
4+
param(
5+
[Parameter(Mandatory)]
6+
[System.IO.FileInfo[]] $Files
7+
)
8+
9+
$sortedFiles = $Files | Sort-Object -Property FullName
10+
if ($sortedFiles.Count -le 1) {
11+
return $sortedFiles
12+
}
13+
14+
$metadataByPath = @{}
15+
$typeToPath = @{}
16+
$duplicateTypes = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
17+
18+
foreach ($file in $sortedFiles) {
19+
$content = Get-Content -Path $file.FullName -Raw
20+
$declaredTypes = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
21+
[Regex]::Matches($content, '(?im)^\s*(class|enum)\s+([^\s:{]+)') | ForEach-Object {
22+
[void] $declaredTypes.Add($_.Groups[2].Value)
23+
}
24+
25+
$metadataByPath[$file.FullName] = [pscustomobject]@{
26+
File = $file
27+
Content = $content
28+
DeclaredTypes = $declaredTypes
29+
}
30+
31+
foreach ($typeName in $declaredTypes) {
32+
if ($typeToPath.ContainsKey($typeName)) {
33+
[void]$duplicateTypes.Add($typeName)
34+
continue
35+
}
36+
37+
$typeToPath[$typeName] = $file.FullName
38+
}
39+
}
40+
41+
$dependenciesByPath = @{}
42+
$dependentsByPath = @{}
43+
foreach ($file in $sortedFiles) {
44+
$dependenciesByPath[$file.FullName] = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
45+
$dependentsByPath[$file.FullName] = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
46+
}
47+
48+
foreach ($metadata in $metadataByPath.Values) {
49+
foreach ($typeName in $typeToPath.Keys) {
50+
if ($duplicateTypes.Contains($typeName)) {
51+
continue
52+
}
53+
54+
if ($metadata.DeclaredTypes.Contains($typeName)) {
55+
continue
56+
}
57+
58+
$typePattern = "(?<![\w\.])\[$([Regex]::Escape($typeName))\](?![\w\.])"
59+
if ([Regex]::IsMatch($metadata.Content, $typePattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)) {
60+
$dependencyPath = $typeToPath[$typeName]
61+
[void]$dependenciesByPath[$metadata.File.FullName].Add($dependencyPath)
62+
[void]$dependentsByPath[$dependencyPath].Add($metadata.File.FullName)
63+
}
64+
}
65+
}
66+
67+
$remainingPaths = @($sortedFiles.FullName)
68+
$ready = @(
69+
$remainingPaths |
70+
Where-Object { $dependenciesByPath[$_].Count -eq 0 } |
71+
Sort-Object
72+
)
73+
$orderedPaths = [System.Collections.Generic.List[string]]::new()
74+
75+
while ($ready.Count -gt 0) {
76+
$currentPath = $ready[0]
77+
$ready = @($ready | Select-Object -Skip 1)
78+
$orderedPaths.Add($currentPath)
79+
$remainingPaths = @($remainingPaths | Where-Object { $_ -ne $currentPath })
80+
81+
$dependents = @($dependentsByPath[$currentPath] | Sort-Object)
82+
foreach ($dependentPath in $dependents) {
83+
$null = $dependenciesByPath[$dependentPath].Remove($currentPath)
84+
if ($dependenciesByPath[$dependentPath].Count -eq 0) {
85+
$ready = @($ready + $dependentPath | Sort-Object -Unique)
86+
}
87+
}
88+
}
89+
90+
if ($orderedPaths.Count -lt $sortedFiles.Count) {
91+
Write-Warning "Detected cyclical or unresolved class/enum dependencies. Falling back to lexical order for remaining files in [$($sortedFiles[0].DirectoryName)]."
92+
$remainingPaths | Sort-Object | ForEach-Object {
93+
$orderedPaths.Add($_)
94+
}
95+
}
96+
97+
return @($orderedPaths | ForEach-Object { $metadataByPath[$_].File })
98+
}
99+
100+
function Add-ContentFromItem {
2101
<#
3102
.SYNOPSIS
4103
Add the content of a folder or file to the root module file.
@@ -20,28 +119,34 @@
20119

21120
# The root path of the module.
22121
[Parameter(Mandatory)]
23-
[string] $RootPath
122+
[string] $RootPath,
123+
124+
# Whether the folder should be loaded using dependency ordering (class/enum aware).
125+
[Parameter()]
126+
[switch] $DependencyAware
24127
)
25-
# Get the path separator for the current OS
26-
$pathSeparator = [System.IO.Path]::DirectorySeparatorChar
128+
$separators = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
27129

28-
$relativeFolderPath = $Path -replace $RootPath, ''
29-
$relativeFolderPath = $relativeFolderPath -replace $file.Extension, ''
30-
$relativeFolderPath = $relativeFolderPath.TrimStart($pathSeparator)
31-
$relativeFolderPath = $relativeFolderPath -split $pathSeparator | ForEach-Object { "[$_]" }
130+
$relativeFolderPath = [System.IO.Path]::GetRelativePath($RootPath, $Path)
131+
$relativeFolderPath = $relativeFolderPath.Split($separators, [System.StringSplitOptions]::RemoveEmptyEntries) | ForEach-Object { "[$_]" }
32132
$relativeFolderPath = $relativeFolderPath -join ' - '
33133

34134
Add-Content -Path $RootModuleFilePath -Force -Value @"
35135
#region $relativeFolderPath
36136
Write-Debug "[`$scriptName] - $relativeFolderPath - Processing folder"
37137
"@
38138

39-
$files = $Path | Get-ChildItem -File -Force -Filter '*.ps1' | Sort-Object -Property FullName
139+
if ($DependencyAware) {
140+
$files = $Path | Get-ChildItem -Recurse -File -Force -Filter '*.ps1' | Sort-Object -Property FullName
141+
$files = Get-DependencyOrderedScriptFiles -Files $files
142+
} else {
143+
$files = $Path | Get-ChildItem -File -Force -Filter '*.ps1' | Sort-Object -Property FullName
144+
}
145+
40146
foreach ($file in $files) {
41-
$relativeFilePath = $file.FullName -replace $RootPath, ''
42-
$relativeFilePath = $relativeFilePath -replace $file.Extension, ''
43-
$relativeFilePath = $relativeFilePath.TrimStart($pathSeparator)
44-
$relativeFilePath = $relativeFilePath -split $pathSeparator | ForEach-Object { "[$_]" }
147+
$relativeFilePath = [System.IO.Path]::GetRelativePath($RootPath, $file.FullName)
148+
$relativeFilePath = [System.IO.Path]::ChangeExtension($relativeFilePath, $null).TrimEnd('.')
149+
$relativeFilePath = $relativeFilePath.Split($separators, [System.StringSplitOptions]::RemoveEmptyEntries) | ForEach-Object { "[$_]" }
45150
$relativeFilePath = $relativeFilePath -join ' - '
46151

47152
Add-Content -Path $RootModuleFilePath -Force -Value @"
@@ -55,9 +160,11 @@ Write-Debug "[`$scriptName] - $relativeFilePath - Done"
55160
"@
56161
}
57162

58-
$subFolders = $Path | Get-ChildItem -Directory -Force | Sort-Object -Property Name
59-
foreach ($subFolder in $subFolders) {
60-
Add-ContentFromItem -Path $subFolder.FullName -RootModuleFilePath $RootModuleFilePath -RootPath $RootPath
163+
if (-not $DependencyAware) {
164+
$subFolders = $Path | Get-ChildItem -Directory -Force | Sort-Object -Property Name
165+
foreach ($subFolder in $subFolders) {
166+
Add-ContentFromItem -Path $subFolder.FullName -RootModuleFilePath $RootModuleFilePath -RootPath $RootPath
167+
}
61168
}
62169
Add-Content -Path $RootModuleFilePath -Force -Value @"
63170
Write-Debug "[`$scriptName] - $relativeFolderPath - Done"

.github/actions/Build-PSModule/src/helpers/Build/Build-PSModuleRootModule.ps1

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,14 @@
1010
1111
1. Module header from header.ps1 file. Usually to suppress code analysis warnings/errors and to add [CmdletBinding()] to the module.
1212
2. Data loader is added if data files are available.
13-
3. Combines *.ps1 files from the following folders in alphabetical order from each folder:
13+
3. Combines *.ps1 files from the following folders:
1414
1. init
15-
2. classes/private
16-
3. classes/public
17-
4. functions/private
18-
5. functions/public
19-
6. variables/private
20-
7. variables/public
21-
8. Any remaining *.ps1 on module root.
15+
2. classes (dependency-aware ordering for class/enum references)
16+
3. functions/private
17+
4. functions/public
18+
5. variables/private
19+
6. variables/public
20+
7. Any remaining *.ps1 on module root.
2221
4. Adds a class loader for classes found in the classes/public folder.
2322
5. Export-ModuleMember by using the functions, cmdlets, variables and aliases found in the source files.
2423
- `Functions` will only contain functions that are from the `functions/public` folder.
@@ -48,8 +47,7 @@
4847
[System.IO.DirectoryInfo] $ModuleOutputFolder
4948
)
5049

51-
# Get the path separator for the current OS
52-
$pathSeparator = [System.IO.Path]::DirectorySeparatorChar
50+
$separators = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
5351

5452
Set-GitHubLogGroup 'Build root module' {
5553
$rootModuleFile = New-Item -Path $ModuleOutputFolder -Name "$ModuleName.psm1" -Force
@@ -176,31 +174,30 @@ Write-Debug "[$scriptName] - [data] - Done"
176174
#region - Add content from subfolders
177175
$scriptFoldersToProcess = @(
178176
'init',
179-
'classes/private',
180-
'classes/public',
177+
'classes',
181178
'functions/private',
182179
'functions/public',
183180
'variables/private',
184181
'variables/public'
185182
)
186183

187184
foreach ($scriptFolder in $scriptFoldersToProcess) {
188-
$scriptFolder = Join-Path -Path $ModuleOutputFolder -ChildPath $scriptFolder
189-
if (-not (Test-Path -Path $scriptFolder)) {
185+
$dependencyAware = $scriptFolder -eq 'classes'
186+
$scriptFolderPath = Join-Path -Path $ModuleOutputFolder -ChildPath $scriptFolder
187+
if (-not (Test-Path -Path $scriptFolderPath)) {
190188
continue
191189
}
192-
Add-ContentFromItem -Path $scriptFolder -RootModuleFilePath $rootModuleFile -RootPath $ModuleOutputFolder
193-
Remove-Item -Path $scriptFolder -Force -Recurse
190+
Add-ContentFromItem -Path $scriptFolderPath -RootModuleFilePath $rootModuleFile -RootPath $ModuleOutputFolder -DependencyAware:$dependencyAware
191+
Remove-Item -Path $scriptFolderPath -Force -Recurse
194192
}
195193
#endregion - Add content from subfolders
196194

197195
#region - Add content from *.ps1 files on module root
198196
$files = $ModuleOutputFolder | Get-ChildItem -File -Force -Filter '*.ps1' | Sort-Object -Property FullName
199197
foreach ($file in $files) {
200-
$relativePath = $file.FullName -replace $ModuleOutputFolder, ''
201-
$relativePath = $relativePath -replace $file.Extension, ''
202-
$relativePath = $relativePath.TrimStart($pathSeparator)
203-
$relativePath = $relativePath -split $pathSeparator | ForEach-Object { "[$_]" }
198+
$relativePath = [System.IO.Path]::GetRelativePath($ModuleOutputFolder, $file.FullName)
199+
$relativePath = [System.IO.Path]::ChangeExtension($relativePath, $null).TrimEnd('.')
200+
$relativePath = $relativePath.Split($separators, [System.StringSplitOptions]::RemoveEmptyEntries) | ForEach-Object { "[$_]" }
204201
$relativePath = $relativePath -join ' - '
205202

206203
Add-Content -Path $rootModuleFile -Force -Value @"
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
class BookList {
2+
# Static property to hold the list of books
3+
static [System.Collections.Generic.List[Book]] $Books
4+
# Static method to initialize the list of books. Called in the other
5+
# static methods to avoid needing to explicit initialize the value.
6+
static [void] Initialize() { [BookList]::Initialize($false) }
7+
static [bool] Initialize([bool]$force) {
8+
if ([BookList]::Books.Count -gt 0 -and -not $force) {
9+
return $false
10+
}
11+
12+
[BookList]::Books = [System.Collections.Generic.List[Book]]::new()
13+
14+
return $true
15+
}
16+
# Ensure a book is valid for the list.
17+
static [void] Validate([book]$Book) {
18+
$Prefix = @(
19+
'Book validation failed: Book must be defined with the Title,'
20+
'Author, and PublishDate properties, but'
21+
) -join ' '
22+
if ($null -eq $Book) { throw "$Prefix was null" }
23+
if ([string]::IsNullOrEmpty($Book.Title)) {
24+
throw "$Prefix Title wasn't defined"
25+
}
26+
if ([string]::IsNullOrEmpty($Book.Author)) {
27+
throw "$Prefix Author wasn't defined"
28+
}
29+
if ([datetime]::MinValue -eq $Book.PublishDate) {
30+
throw "$Prefix PublishDate wasn't defined"
31+
}
32+
}
33+
# Static methods to manage the list of books.
34+
# Add a book if it's not already in the list.
35+
static [void] Add([Book]$Book) {
36+
[BookList]::Initialize()
37+
[BookList]::Validate($Book)
38+
if ([BookList]::Books.Contains($Book)) {
39+
throw "Book '$Book' already in list"
40+
}
41+
42+
$FindPredicate = {
43+
param([Book]$b)
44+
45+
$b.Title -eq $Book.Title -and
46+
$b.Author -eq $Book.Author -and
47+
$b.PublishDate -eq $Book.PublishDate
48+
}.GetNewClosure()
49+
if ([BookList]::Books.Find($FindPredicate)) {
50+
throw "Book '$Book' already in list"
51+
}
52+
53+
[BookList]::Books.Add($Book)
54+
}
55+
# Clear the list of books.
56+
static [void] Clear() {
57+
[BookList]::Initialize()
58+
[BookList]::Books.Clear()
59+
}
60+
# Find a specific book using a filtering scriptblock.
61+
static [Book] Find([scriptblock]$Predicate) {
62+
[BookList]::Initialize()
63+
return [BookList]::Books.Find($Predicate)
64+
}
65+
# Find every book matching the filtering scriptblock.
66+
static [Book[]] FindAll([scriptblock]$Predicate) {
67+
[BookList]::Initialize()
68+
return [BookList]::Books.FindAll($Predicate)
69+
}
70+
# Remove a specific book.
71+
static [void] Remove([Book]$Book) {
72+
[BookList]::Initialize()
73+
[BookList]::Books.Remove($Book)
74+
}
75+
# Remove a book by property value.
76+
static [void] RemoveBy([string]$Property, [string]$Value) {
77+
[BookList]::Initialize()
78+
$Index = [BookList]::Books.FindIndex({
79+
param($b)
80+
$b.$Property -eq $Value
81+
}.GetNewClosure())
82+
if ($Index -ge 0) {
83+
[BookList]::Books.RemoveAt($Index)
84+
}
85+
}
86+
}

0 commit comments

Comments
 (0)