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*$') # Blanks out everything the AL compiler reads as data rather than as code, so the begin/end count # below cannot fire inside it. String literals first, then quoted identifiers: once the literals are # gone every remaining double quote delimits an identifier, whereas the other order would mis-read a # double quote that merely sat inside a string. # # Identifiers are what makes this necessary. A field named "bdev.BNK End-to-End Reference" carries # the token `end` TWICE, each time bounded by a space or a hyphen, so the lookarounds accept it. The # line then closed two blocks that were never opened, the body was judged to have ended at its own # arrangement, and every statement after it was dropped - which for a test means its assertions were # dropped and the assertion gate reported a test that does assert. # # Only the keyword counting reads the masked form. The statements kept below are the original text, # because that is what the assertion patterns are matched against. $maskLiterals = { param([string] $text) $masked = [regex]::Replace($text, "'(?:[^']|'')*'", "''") [regex]::Replace($masked, '"(?:[^"]|"")*"', '""') } $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 = (& $maskLiterals $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) } |