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).
 
        One AL object per file is assumed (BC/AL convention), matching Get-BcAlObjectMap.
 
    .PARAMETER Path
        The .al file to analyse.
 
    .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
    )

    $lines = @(Get-Content -LiteralPath $Path -ErrorAction SilentlyContinue)
    if ($lines.Count -eq 0) { return @() }

    $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

    for ($i = 0; $i -lt $lines.Count; $i++) {
        $raw = $lines[$i]
        $line = $raw.Trim()

        # Strip block comments /* ... */ (whole-line handling is enough for AL statement detection).
        if ($inBlockComment) {
            if ($line -match '\*/') { $inBlockComment = $false; $line = ($line -replace '^.*?\*/', '').Trim() } else { continue }
        }
        if ($line -match '/\*' -and $line -notmatch '/\*.*\*/') { $inBlockComment = $true; $line = ($line -replace '/\*.*$', '').Trim() }

        if ($line -eq '') { continue }
        if ($line.StartsWith('//')) { continue }
        # Strip trailing line comment for keyword tests.
        $code = ($line -replace '//.*$', '').Trim()
        if ($code -eq '') { continue }
        $lower = $code.ToLowerInvariant()

        # A procedure/trigger header opens a (possibly var-prefixed) body.
        if ($lower -match '^(local\s+|internal\s+|protected\s+)*(procedure|trigger)\b') {
            $pendingHeader = $true
            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.
            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

        $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 (-not $isPureKeyword) {
            # Lines that are only opening/closing braces or block punctuation aren't statements.
            if ($lower -notmatch '^[{}();]+$') { $exec.Add($i + 1) | Out-Null }
        }

        $depth += $opens - $closes
        if ($depth -le 0) { $inBody = $false; $depth = 0 }
    }

    return @($exec | Sort-Object -Unique)
}