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)
}