Modules/businessdev.ALbuild.Apps/Private/Get-BcAlSourceModel.ps1
|
function Get-BcAlSourceModel { <# .SYNOPSIS Parses an AL source file into the statements, procedures and unmeasurable bodies code coverage needs. .DESCRIPTION Business Central reports a statement once, on the line where it starts, and only for methods the debugger can see. This parser mirrors that, so the denominator holds exactly the lines BC can report. The rules are calibrated against a real BC 28.5 run of test/fixtures/coverage-patterns, in which every statement is executed: the parser must return exactly the lines BC reported. * A statement runs from its first line until a line ends in ';' or a block keyword (then, do, of, else, begin, repeat, end) outside any parenthesis. Only its first line counts; the other lines are continuation lines and map to it (StatementOf). * A case label list ends in a single ':' and may span lines ('a',<newline>'b':). It is not a statement. A statement on the label's last line ('y': exit(3);) counts on that line. * Lines made only of block keywords (begin, end, else, then, do, of, repeat - also 'else begin', 'end else') are not statements. 'end else if X then' is: BC reports the condition. * Bodies of [NonDebuggable] methods run but are never reported. Their lines are returned separately (UnmeasuredLines) and never in ExecutableLines. * With -Symbols, lines in a non-compiled #if branch are skipped. An #if expression this does not understand keeps its branch active. One AL object per file is assumed, matching Get-BcAlObjectMap. .PARAMETER Path The .al file to analyse. .PARAMETER Symbols The preprocessor symbols the file was compiled with. Omitted means no branch is skipped. .OUTPUTS PSCustomObject: ExecutableLines (int[]), UnmeasuredLines (int[]), StatementOf (hashtable continuation line -> statement line), Procedures (Name, Kind, Line, EndLine, NonDebuggable, Lines). #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Path, [string[]] $Symbols = @() ) $exec = [System.Collections.Generic.SortedSet[int]]::new() $unmeasured = [System.Collections.Generic.SortedSet[int]]::new() $statementOf = @{} $procedures = [System.Collections.Generic.List[object]]::new() $model = { [PSCustomObject]@{ ExecutableLines = [int[]]@($exec); UnmeasuredLines = [int[]]@($unmeasured); StatementOf = $statementOf; Procedures = @($procedures) } } $lines = @(Get-Content -LiteralPath $Path -ErrorAction SilentlyContinue) if ($lines.Count -eq 0) { return (& $model) } # String literals, then quoted identifiers, are blanked so no keyword or bracket inside them is read. $maskLiterals = { param([string] $text) $masked = [regex]::Replace($text, "'(?:[^']|'')*'", "''") [regex]::Replace($masked, '"(?:[^"]|"")*"', '""') } $useSymbols = @($Symbols).Count -gt 0 $branches = [System.Collections.Generic.List[hashtable]]::new() $branchActive = { if ($branches.Count -eq 0) { $true } else { [bool] $branches[$branches.Count - 1].Active } } $evalCondition = { param([string] $expression) $e = "$expression".Trim() if ($e -match '^not\s+([A-Za-z_][A-Za-z0-9_]*)$') { return -not ($Symbols -contains $Matches[1]) } if ($e -match '^([A-Za-z_][A-Za-z0-9_]*)$') { return ($Symbols -contains $Matches[1]) } return $null } $leadingKeywordsRx = [regex]::new('^(?:(?:begin|end|else|then|do|of|repeat)\b;?\s*)+') $terminatorRx = [regex]::new('(?:;|(?<![A-Za-z0-9_."])(?:then|do|of|else|begin|repeat|end))$') $labelColonRx = [regex]::new('(?<!:):(?![:=])') $controlRx = [regex]::new('(?i)^(?:field|action|part|systempart|usercontrol|dataitem|column|value|systemaction|fileuploadaction|customaction)\s*\(\s*(?:\d+\s*;\s*)?(?<name>"(?:[^"]|"")*"|[A-Za-z0-9_]+)') $headerRx = [regex]::new('(?i)^(?:local\s+|internal\s+|protected\s+)*(?<kind>procedure|trigger)\s+(?<name>"(?:[^"]|"")*"|[A-Za-z0-9_]+)') $inBody = $false $depth = 0 $pendingHeader = $null $inBlockComment = $false $parenDepth = 0 $pendingNonDebuggable = $false $current = $null # procedure whose body is being read $stmtStart = 0 # first line of the statement still open, 0 = none $braceDepth = 0 $controls = [System.Collections.Generic.List[hashtable]]::new() $addLine = { param([int] $n) if ($current.NonDebuggable) { [void]$unmeasured.Add($n) } else { [void]$exec.Add($n) } [void]$current.LineSet.Add($n) } $removeLine = { param([int] $n) [void]$unmeasured.Remove($n); [void]$exec.Remove($n); [void]$current.LineSet.Remove($n) foreach ($k in @($statementOf.Keys)) { if ($statementOf[$k] -eq $n) { $statementOf.Remove($k) } } } for ($i = 0; $i -lt $lines.Count; $i++) { $lineNo = $i + 1 $raw = $lines[$i] $scan = (& $maskLiterals $raw).Trim() if ($inBlockComment) { if ($scan -match '\*/') { $inBlockComment = $false; $scan = ($scan -replace '^.*?\*/', '').Trim() } else { continue } } $scan = ($scan -replace '/\*.*?\*/', ' ').Trim() if ($scan -match '/\*') { $inBlockComment = $true; $scan = ($scan -replace '/\*.*$', '').Trim() } if ($scan -eq '' -or $scan.StartsWith('//')) { continue } $code = ($scan -replace '//.*$', '').Trim() if ($code -eq '') { continue } $lower = $code.ToLowerInvariant() if ($lower.StartsWith('#')) { if ($lower -match '^#\s*if\b') { $parentActive = & $branchActive $condition = if ($code -match '^#\s*if\s+(.+)$') { $Matches[1] } else { '' } $value = & $evalCondition $condition $isActive = if ($null -eq $value) { $true } else { [bool] $value } $branches.Add(@{ Active = ($parentActive -and $isActive); Taken = ($parentActive -and $isActive); ParentActive = $parentActive }) } elseif ($lower -match '^#\s*else\b') { if ($branches.Count -gt 0) { $frame = $branches[$branches.Count - 1] $frame.Active = ($frame.ParentActive -and -not $frame.Taken) $frame.Taken = $true } } elseif ($lower -match '^#\s*endif\b') { if ($branches.Count -gt 0) { $branches.RemoveAt($branches.Count - 1) } } continue } if ($useSymbols -and -not (& $branchActive)) { continue } if (-not $inBody) { # Structure outside bodies: remember the enclosing control so a trigger can be named after it. $cm = $controlRx.Match((($raw -replace '//.*$', '').Trim())) if ($cm.Success) { $controls.Add(@{ Name = $cm.Groups['name'].Value.Trim('"'); Depth = $braceDepth; Entered = $false }) } $braceDepth += ([regex]::Matches($code, '\{')).Count - ([regex]::Matches($code, '\}')).Count while ($controls.Count -gt 0) { $top = $controls[$controls.Count - 1] if ($braceDepth -gt $top.Depth) { $top.Entered = $true; break } if ($top.Entered) { $controls.RemoveAt($controls.Count - 1) } else { break } } # Only an attribute outside a header and a body applies to a method (a variable may carry it too). if (-not $pendingHeader -and $lower -match '^\[nondebuggable(\(\s*\))?\]') { $pendingNonDebuggable = $true; continue } $hm = $headerRx.Match($code) if ($hm.Success) { $name = $hm.Groups['name'].Value.Trim('"') $kind = $hm.Groups['kind'].Value.ToLowerInvariant() if ($kind -eq 'trigger' -and $controls.Count -gt 0) { $name = '{0} - {1}' -f $controls[$controls.Count - 1].Name, $name } $pendingHeader = @{ Name = $name; Kind = $kind; Line = $lineNo; NonDebuggable = $pendingNonDebuggable } $pendingNonDebuggable = $false $parenDepth = 0 continue } if ($pendingHeader -and $lower -match '^begin\b') { $inBody = $true; $depth = 1; $stmtStart = 0; $parenDepth = 0 $current = @{ Name = $pendingHeader.Name; Kind = $pendingHeader.Kind; Line = $pendingHeader.Line; NonDebuggable = $pendingHeader.NonDebuggable; LineSet = [System.Collections.Generic.SortedSet[int]]::new() } $pendingHeader = $null } continue } $opens = ([regex]::Matches($lower, '(?<![A-Za-z0-9_])begin(?![A-Za-z0-9_])')).Count # A case block is closed by 'end' as well; '.Case' and quoted names are not the keyword. $opens += ([regex]::Matches($lower, '(?<![A-Za-z0-9_."])case(?![A-Za-z0-9_])')).Count $closes = ([regex]::Matches($lower, '(?<![A-Za-z0-9_])end(?![A-Za-z0-9_])')).Count $lead = $leadingKeywordsRx.Match($lower) $rest = if ($lead.Success) { $lower.Substring($lead.Length).Trim() } else { $lower } # A closing or branching keyword ends whatever statement was open (the last one may omit its ';'). if ($lead.Success -or $rest -match '^until\b') { $stmtStart = 0 } if ($rest -ne '' -and $parenDepth -eq 0 -and $rest -notmatch '^[{}();]+$') { if ($stmtStart -eq 0) { $stmtStart = $lineNo; & $addLine $lineNo } elseif ($stmtStart -ne $lineNo) { $statementOf[$lineNo] = $stmtStart } $label = $labelColonRx.Match($rest) if ($label.Success) { # Everything so far was a case label. A statement after the colon starts on this line. & $removeLine $stmtStart if ($statementOf.ContainsKey($lineNo)) { $statementOf.Remove($lineNo) } $tail = $rest.Substring($label.Index + 1).Trim() $tailRest = $tail.Substring($leadingKeywordsRx.Match($tail).Length).Trim() if ($tailRest -ne '') { $stmtStart = $lineNo; & $addLine $lineNo } else { $stmtStart = 0 } } } elseif ($rest -ne '' -and $stmtStart -ne 0 -and $stmtStart -ne $lineNo) { $statementOf[$lineNo] = $stmtStart } $parenDepth += ([regex]::Matches($code, '\(')).Count - ([regex]::Matches($code, '\)')).Count if ($parenDepth -lt 0) { $parenDepth = 0 } if ($parenDepth -eq 0 -and ($rest -eq '' -or $terminatorRx.IsMatch($lower))) { $stmtStart = 0 } $depth += $opens - $closes if ($depth -le 0) { $procedures.Add([PSCustomObject]@{ Name = $current.Name Kind = $current.Kind Line = $current.Line EndLine = $lineNo NonDebuggable = [bool]$current.NonDebuggable Lines = [int[]]@($current.LineSet) }) $inBody = $false; $depth = 0; $parenDepth = 0; $stmtStart = 0; $current = $null } } return (& $model) } |