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. So # does an enum member: "Deliveries of goods to end customers made from within Germany", from # bdev.ERiC OSS Record Type, cost build 27642 three tests. The line closed 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 gate reported a test that # does assert. # # The CONTENT is blanked but the line's LENGTH is kept, so an index found in the masked text points # at the same character in the original. That is what lets the comment scan below cut both strings at # the same place, and it is why the statements handed to the assertion patterns are the original text. $blank = [System.Text.RegularExpressions.MatchEvaluator] { param($m) [string]$m.Value[0] + (' ' * ($m.Value.Length - 2)) + [string]$m.Value[$m.Value.Length - 1] } $maskData = { param([string] $text) $masked = [regex]::Replace($text, "'(?:[^']|'')*'", $blank) [regex]::Replace($masked, '"(?:[^"]|"")*"', $blank) } # Removes what the compiler discards, reading the MASKED text so a '//' or '/*' sitting inside a # literal is not mistaken for a comment, and cutting the original at the same offsets. Returns the # surviving original text; $inBlockComment carries an unterminated /* into the next line. $stripComments = { param([string] $raw, [string] $masked, [ref] $inBlockComment) while ($true) { if ($inBlockComment.Value) { $close = $masked.IndexOf('*/') if ($close -lt 0) { return '' } $raw = $raw.Substring($close + 2); $masked = $masked.Substring($close + 2) $inBlockComment.Value = $false continue } $line = $masked.IndexOf('//'); $block = $masked.IndexOf('/*') if ($line -ge 0 -and ($block -lt 0 -or $line -lt $block)) { return $raw.Substring(0, $line) } if ($block -lt 0) { return $raw } $close = $masked.IndexOf('*/', $block + 2) if ($close -lt 0) { $inBlockComment.Value = $true; return $raw.Substring(0, $block) } $length = $close + 2 - $block $raw = $raw.Remove($block, $length); $masked = $masked.Remove($block, $length) } } $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 $inBlockComment = $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) } $maskedLine = & $maskData $lines[$j] $code = (& $stripComments $lines[$j] $maskedLine ([ref]$inBlockComment)).Trim() if ($code -eq '') { continue } # Mask again rather than carry the offsets: the cut is over, and the keyword tests only # need a form of THIS text in which no literal and no identifier can answer them. $lower = (& $maskData $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 # Only `begin` opens the BODY - a var section stands between the header and it, and # nothing in a declaration may start counting. if (-not $started) { if ($opens -gt 0) { $started = $true; $depth = 0 } else { continue } } # Inside the body `case` opens a block too, and it is closed by `end` just like `begin` # is. Counting only `begin` let the `end;` of a case block cancel the procedure's own # `begin`: the body was judged to have finished there and every statement after it - the # assertions included - was dropped. The lookbehind rejects a member access ('.Case'), # which is a name, not the keyword. AL has no `case` without a closing `end`, so this # cannot over-count even when the `of` sits on a following line. $opens += ([regex]::Matches($lower, '(?<![A-Za-z0-9_."])case(?![A-Za-z0-9_])')).Count $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) } |