Modules/businessdev.ALbuild.Apps/Private/Get-BcAlExecutableLines.ps1
|
function Get-BcAlExecutableLines { <# .SYNOPSIS Identifies the executable statement lines of an AL source file -- the honest denominator for code coverage. .DESCRIPTION Business Central's coverage export only ever lists Covered/PartiallyCovered lines (verified against the platform: "AL Code Coverage Mgt.".SaveCoverageResults filters to those statuses), so the raw CSV alone always reads ~100%. To compute an honest line-coverage percentage ALbuild derives the *total* executable lines from the AL source instead. BC counts a source line as a "Code" line only when it carries an executable statement. This parser mirrors that: it walks each procedure/trigger body (the `begin`..matching `end` after a `procedure`/`trigger` header) and returns the 1-based source line numbers of statement lines, excluding object/property declarations, procedure & trigger signatures, `var` sections and their variable declarations, lone `begin`/`end`/`else`/`do`/`then` block keywords, comments and blank lines. Calibrated against a controlled BC28.2 container (a codeunit whose only statements sit on specific lines reported exactly by BC). A line the parser counts but BC can never report is pure loss: Convert-BcCodeCoverage unions the source lines with the covered ones, so a phantom line only ever raises the denominator. Four rules exist for exactly that reason: * String literals are MASKED before anything else is examined. AL XPath expressions routinely contain '/*' and never '*/', so scanning the raw line made a single `SelectSingleNode('//*[...]')` open a block comment that never closed - silently hiding the rest of the file from the measurement. * Preprocessor directives (`#region`, `#pragma`, `#if`, ...) are not statements and are not counted. * A statement spanning several lines counts ONCE, on its first line: BC attributes the statement to where it starts, so the argument lines can never be reported as covered. * With -Symbols, lines inside a non-compiled `#if` branch are skipped - the compiler never emitted them, so they are dead code in this build. One AL object per file is assumed (BC/AL convention), matching Get-BcAlObjectMap. .PARAMETER Path The .al file to analyse. .PARAMETER Symbols The preprocessor symbols the file was compiled with -- the same set that reaches alc.exe as `/define:`, normally read from the app's app.json (see Resolve-BcAlAppContext). Understood forms: `#if SYMBOL`, `#if not SYMBOL`, `#else`, `#endif`. An expression this does not understand is treated as ACTIVE, so an unusual condition can only over-count (today's behaviour) and never hide real code. Omitted (the default) means no branch is skipped at all -- behaviour identical to before this parameter existed, so existing callers are unaffected. .OUTPUTS int[] -- sorted, distinct 1-based source line numbers that carry an executable statement. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Returns a set of executable lines; the plural noun is intentional and clearer.')] [CmdletBinding()] [OutputType([int[]])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Path, [string[]] $Symbols = @() ) $lines = @(Get-Content -LiteralPath $Path -ErrorAction SilentlyContinue) if ($lines.Count -eq 0) { return @() } # Blank out the CONTENT of every string literal, keeping the quotes. AL escapes a single quote by # doubling it. Everything downstream - comment detection, keyword tests, bracket depth - runs on the # masked text, so punctuation inside a literal can never be mistaken for code. $maskLiterals = { param([string] $text) [regex]::Replace($text, "'(?:[^']|'')*'", "''") } $useSymbols = @($Symbols).Count -gt 0 # One frame per open #if. Active = lines here are compiled; Taken = some branch of this #if already # matched, so a following #else must not activate. $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 # not understood -> caller keeps the branch active } $exec = [System.Collections.Generic.List[int]]::new() $inBody = $false # inside a procedure/trigger body (after its opening begin) $depth = 0 # begin/end nesting within the current body (0 = not in a body) $pendingHeader = $false # saw a procedure/trigger header, waiting for its opening begin $inBlockComment = $false $parenDepth = 0 # open '(' carried across lines -> continuation lines of one statement for ($i = 0; $i -lt $lines.Count; $i++) { # Mask literals FIRST - before comment detection, which is what defect D1 got wrong. $scan = (& $maskLiterals $lines[$i]).Trim() # Strip block comments /* ... */ (whole-line handling is enough for AL statement detection). if ($inBlockComment) { if ($scan -match '\*/') { $inBlockComment = $false; $scan = ($scan -replace '^.*?\*/', '').Trim() } else { continue } } if ($scan -match '/\*' -and $scan -notmatch '/\*.*\*/') { $inBlockComment = $true; $scan = ($scan -replace '/\*.*$', '').Trim() } if ($scan -eq '') { continue } if ($scan.StartsWith('//')) { continue } # Strip trailing line comment for keyword tests. $code = ($scan -replace '//.*$', '').Trim() if ($code -eq '') { continue } $lower = $code.ToLowerInvariant() # Preprocessor directives: never statements (they emit no code), but they steer branch tracking. 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 } # unknown condition -> keep counting $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 } # Only when symbols are known: skip what this build does not compile. if ($useSymbols -and -not (& $branchActive)) { continue } # A procedure/trigger header opens a (possibly var-prefixed) body. if ($lower -match '^(local\s+|internal\s+|protected\s+)*(procedure|trigger)\b') { $pendingHeader = $true $parenDepth = 0 # a signature's parentheses never continue into the body continue } # The opening `begin` of a procedure/trigger body. if (-not $inBody -and $pendingHeader -and $lower -match '^begin\b') { $inBody = $true; $depth = 1; $pendingHeader = $false continue } if (-not $inBody) { # Outside any body: var sections, declarations, object/property lines -- never executable. $parenDepth = 0 continue } # Inside a procedure/trigger body. Track begin/end nesting and skip pure block keywords. # Count begins/ends on the line to maintain depth (a line may open and close blocks). $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 # A statement that started on an earlier line continues here; BC reports it at its first line. $startsStatement = ($parenDepth -eq 0) $isPureKeyword = $lower -match '^(begin|end|end;|else|do|then|var)$' -or $lower -match '^(end\s+else\b.*)$' # A statement line: inside the body, not a lone block keyword. `end else begin`-style lines are control flow. if ($startsStatement -and -not $isPureKeyword) { # Lines that are only opening/closing braces or block punctuation aren't statements. if ($lower -notmatch '^[{}();]+$') { $exec.Add($i + 1) | Out-Null } } $parenDepth += ([regex]::Matches($code, '\(')).Count - ([regex]::Matches($code, '\)')).Count if ($parenDepth -lt 0) { $parenDepth = 0 } $depth += $opens - $closes if ($depth -le 0) { $inBody = $false; $depth = 0; $parenDepth = 0 } } return @($exec | Sort-Object -Unique) } |