Modules/businessdev.ALbuild.Apps/Private/Get-BcAlProcedure.ps1
|
function Get-BcAlProcedure { <# .SYNOPSIS Enumerates the procedures in AL sources and returns their statement lines. .DESCRIPTION Shared AL scan behind Get-BcAlTestProcedure (and through it Get-BcTestQuality and Test-BcTestAssertion), so everything that reasons about AL bodies sees the same set of procedures and the same statements and can only differ in the verdict it draws. For every procedure it returns the declaring object, the procedure name, whether it carries a [Test] attribute, its location, the ALbuild directives that apply to it, and the executable statement lines of its body (comments stripped, block keywords and the surrounding begin/end removed). The body runs from the first 'begin' after the procedure header to its matching 'end', so nested begin/end blocks stay inside the procedure and the next one is not swallowed. Non-test procedures are returned as well, because a test may delegate its assertions to a helper (a matrix/factory suite calls one entry point per case); Test-BcTestAssertion resolves those calls against this index instead of reporting the test as assertion-free. .PARAMETER WorkspaceRoot AL source root to scan; tooling folders (.alpackages, output, .git, node_modules, ...) are skipped. .PARAMETER Path A single .al file to scan instead of a whole workspace. .PARAMETER TestsOnly Return only procedures carrying a [Test] attribute. The whole file is still parsed (a test is found wherever it stands), the other bodies are simply not kept - for callers that never resolve helpers, so a large repository is not held in memory twice. .OUTPUTS PSCustomObject per procedure: Codeunit, Name, TestName, IsTest, FilePath, Line, Statements[], Directives[]. #> [CmdletBinding(DefaultParameterSetName = 'Workspace')] [OutputType([PSCustomObject])] param( [Parameter(Mandatory, ParameterSetName = 'Workspace')] [ValidateNotNullOrEmpty()] [string] $WorkspaceRoot, [Parameter(Mandatory, ParameterSetName = 'File')] [ValidateNotNullOrEmpty()] [string] $Path, [switch] $TestsOnly ) $files = if ($PSCmdlet.ParameterSetName -eq 'File') { , (Get-Item -LiteralPath $Path) } else { $root = (Resolve-Path -LiteralPath $WorkspaceRoot).Path Get-ChildItem -LiteralPath $root -Filter '*.al' -File -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.FullName.Substring($root.Length) -notmatch '[\\/](\.alpackages|\.altemplates|\.snapshots|\.output|output|\.git|node_modules)[\\/]' } } # An ALbuild directive is a comment, so it survives the AL compiler: '// albuild:<name> <argument>'. $directiveRx = [regex]::new('(?i)^//\s*albuild:\s*(?<d>\S.*?)\s*$') $result = [System.Collections.Generic.List[object]]::new() foreach ($file in $files) { $lines = @(Get-Content -LiteralPath $file.FullName -ErrorAction SilentlyContinue) if ($lines.Count -eq 0) { continue } $cuName = '' $m = [regex]::Match(($lines -join "`n"), '(?im)^\s*codeunit\s+\d+\s+("(?<q>[^"]+)"|(?<b>[A-Za-z0-9_]+))') if ($m.Success) { $cuName = if ($m.Groups['q'].Success) { $m.Groups['q'].Value } else { $m.Groups['b'].Value } } # File-scope directives apply to every procedure in the file - one .al file declares one object, # so this is the object-level scope. The scope ends at the first attribute or procedure header: a # directive written with the FIRST test belongs to that test, not to the whole file. $fileDirectives = [System.Collections.Generic.List[string]]::new() $pending = [System.Collections.Generic.List[string]]::new() $inFileScope = $true $isTestAttr = $false for ($i = 0; $i -lt $lines.Count; $i++) { $t = $lines[$i].Trim() $dm = $directiveRx.Match($t) if ($dm.Success) { if ($inFileScope) { [void]$fileDirectives.Add($dm.Groups['d'].Value) } [void]$pending.Add($dm.Groups['d'].Value) continue } if ($t -match '^\[Test\b') { $isTestAttr = $true; $inFileScope = $false; continue } $pm = [regex]::Match($t, '(?i)^(local\s+|internal\s+|protected\s+)*procedure\s+(?<n>[A-Za-z0-9_]+)') # Any other attribute ([HandlerFunctions], [TransactionModel], ...) keeps the pending [Test]. if (-not $pm.Success) { if ($t -ne '' -and $t -notmatch '^\[') { $isTestAttr = $false # A plain comment keeps the pending directives: a directive is normally written above # the test together with the sentence explaining it. if ($t -notmatch '^//') { $pending.Clear() } } continue } $isTest = $isTestAttr $isTestAttr = $false $bodyDirectives = [System.Collections.Generic.List[string]]::new() $statements = [System.Collections.Generic.List[string]]::new() $depth = 0; $started = $false for ($j = $i + 1; $j -lt $lines.Count; $j++) { $bd = $directiveRx.Match($lines[$j].Trim()) if ($bd.Success) { [void]$bodyDirectives.Add($bd.Groups['d'].Value) } $code = ($lines[$j] -replace '//.*$', '').Trim() if ($code -eq '') { continue } $lower = $code.ToLowerInvariant() $opens = ([regex]::Matches($lower, '(?<![A-Za-z0-9_])begin(?![A-Za-z0-9_])')).Count $closes = ([regex]::Matches($lower, '(?<![A-Za-z0-9_])end(?![A-Za-z0-9_])')).Count if (-not $started) { if ($opens -gt 0) { $started = $true; $depth = 0 } else { continue } } $isKeyword = $lower -match '^(begin|end|end;|else|do|then|var)$' if (-not $isKeyword -and $lower -notmatch '^[{}();]+$' -and $lower -notmatch '^(begin|end)\b') { [void]$statements.Add($code) } $depth += $opens - $closes if ($depth -le 0) { break } } $inFileScope = $false $directives = @(@($fileDirectives) + @($pending) + @($bodyDirectives) | Select-Object -Unique) $pending.Clear() if ($TestsOnly -and -not $isTest) { continue } $result.Add([PSCustomObject]@{ Codeunit = $cuName Name = $pm.Groups['n'].Value TestName = $pm.Groups['n'].Value IsTest = $isTest FilePath = $file.FullName Line = $i + 1 Statements = @($statements) Directives = $directives }) } } return @($result) } |