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