Skip to main content

In C# convert to file-scoped namepaces

·1048 words·5 mins
Thorsten Lenzen
Author
Thorsten Lenzen
Coding, Devops and Homelab maniac…

This is script, that converts block-scoped namespaces in C# (*.cs) files to file-scoped namepaces. Use it as you see fit, without any garantee, that it will not totally destroy your code. 🚀

No, I have tested it myself. It works! 😄

You can download the the script here.

<#
.SYNOPSIS
    Converts block-scoped C# namespaces to file-scoped namespaces.

.DESCRIPTION
    Scans one or more C# files and converts any block-scoped namespace declarations
    (with curly braces) to the file-scoped syntax (namespace Foo.Bar;).
    Files that already use file-scoped namespaces are left untouched.
    The namespace body is un-indented by one level (one tab or 4 spaces).

.PARAMETER Path
    One or more .cs files or directories to process. Directories are searched
    recursively for *.cs files. Defaults to the current directory.

.PARAMETER WhatIf
    Shows what would be changed without modifying any files.

.EXAMPLE
    .\Convert-ToFileScopedNamespace.ps1
    Converts all *.cs files under the current directory.

.EXAMPLE
    .\Convert-ToFileScopedNamespace.ps1 -Path src\MyProject -WhatIf
    Previews changes without touching files.

.EXAMPLE
    .\Convert-ToFileScopedNamespace.ps1 -Path MyClass.cs
    Converts a single file.
#>

[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
    [string[]] $Path = @('.')

)

begin {
    # ---------------------------------------------------------------------------
    # Helpers
    # ---------------------------------------------------------------------------

    function Get-CsFiles ([string[]]$Paths) {
        foreach ($p in $Paths) {
            if (Test-Path $p -PathType Container) {
                Get-ChildItem -Path $p -Filter '*.cs' -Recurse -File
            } elseif (Test-Path $p -PathType Leaf) {
                Get-Item $p
            } else {
                Write-Warning "Path not found: $p"
            }
        }
    }

    # Returns $true when the file already contains a file-scoped namespace
    # (i.e. a line matching:  namespace Some.Name;  possibly with leading whitespace)
    function Has-FileScopedNamespace ([string[]]$Lines) {
        foreach ($line in $Lines) {
            if ($line -match '^\s*namespace\s+[\w.]+\s*;') {
                return $true
            }
        }
        return $false
    }

    # Locate the FIRST block-scoped namespace in the file.
    # Returns a hashtable with:
    #   NamespaceLine  – 0-based index of the "namespace Foo {" line
    #   OpenBraceLine  – 0-based index of the opening '{'  (may equal NamespaceLine)
    #   CloseBraceLine – 0-based index of the matching closing '}'
    #   NamespaceDecl  – the bare "namespace Foo.Bar" text (no brace)
    # Returns $null if no block-scoped namespace is found.
    function Find-BlockNamespace ([string[]]$Lines) {
        $nsIndex = -1
        $nsDecl  = ''

        for ($i = 0; $i -lt $Lines.Count; $i++) {
            $trimmed = $Lines[$i].TrimEnd()

            # Match:  namespace Foo.Bar   OR   namespace Foo.Bar {
            if ($trimmed -match '^\s*(namespace\s+[\w.]+)\s*(\{)?') {
                $nsDecl  = $Matches[1].Trim()   # e.g. "namespace Foo.Bar"
                $nsIndex = $i
                break
            }
        }

        if ($nsIndex -eq -1) { return $null }

        # Find the opening brace (may be on the same line or the next)
        $openIndex = -1
        for ($i = $nsIndex; $i -lt [Math]::Min($Lines.Count, $nsIndex + 5); $i++) {
            if ($Lines[$i] -match '\{') {
                $openIndex = $i
                break
            }
        }
        if ($openIndex -eq -1) { return $null }   # malformed – skip

        # Find the matching closing brace by counting depth
        $depth      = 0
        $closeIndex = -1
        for ($i = $openIndex; $i -lt $Lines.Count; $i++) {
            $depth += ([regex]::Matches($Lines[$i], '\{')).Count
            $depth -= ([regex]::Matches($Lines[$i], '\}')).Count
            if ($depth -eq 0) {
                $closeIndex = $i
                break
            }
        }
        if ($closeIndex -eq -1) { return $null }   # unbalanced – skip

        return @{
            NamespaceLine  = $nsIndex
            OpenBraceLine  = $openIndex
            CloseBraceLine = $closeIndex
            NamespaceDecl  = $nsDecl
        }
    }

    # Remove exactly one level of leading indentation from a single line.
    # Prefers tab removal; falls back to removing up to 4 spaces.
    function Remove-OneIndent ([string]$Line) {
        if ($Line.StartsWith("`t"))  { return $Line.Substring(1) }
        if ($Line.StartsWith('    ')) { return $Line.Substring(4) }
        if ($Line.StartsWith('   '))  { return $Line.Substring(3) }
        if ($Line.StartsWith('  '))   { return $Line.Substring(2) }
        if ($Line.StartsWith(' '))    { return $Line.Substring(1) }
        return $Line
    }

    # Core transformation: takes the original lines array and the info from
    # Find-BlockNamespace and returns the new lines array.
    function Convert-Namespace ([string[]]$Lines, [hashtable]$Info) {
        $ns    = $Info.NamespaceDecl
        $nsIdx = $Info.NamespaceLine
        $oIdx  = $Info.OpenBraceLine
        $cIdx  = $Info.CloseBraceLine

        $result = [System.Collections.Generic.List[string]]::new()

        # 1. Everything before the namespace declaration – verbatim
        for ($i = 0; $i -lt $nsIdx; $i++) {
            $result.Add($Lines[$i])
        }

        # 2. The new file-scoped namespace declaration
        $result.Add("$ns;")

        # 3. If the opening brace was on its own line (or a line that had only
        #    the namespace keyword + brace), we skip all lines up to and
        #    including $oIdx.  Any content on the namespace line before the
        #    brace has already been captured in $ns.
        # Lines between nsIdx+1 and oIdx (exclusive) would be blank or comments –
        # preserve them as-is minus one indent.
        for ($i = $nsIdx + 1; $i -lt $oIdx; $i++) {
            $result.Add((Remove-OneIndent $Lines[$i]))
        }
        # Skip the open-brace line itself (line $oIdx).

        # 4. Body: lines after the open brace up to (but not including) the
        #    closing brace – un-indented by one level.
        for ($i = $oIdx + 1; $i -lt $cIdx; $i++) {
            # Preserve genuinely blank lines without adding trailing spaces
            if ($Lines[$i].Trim() -eq '') {
                $result.Add('')
            } else {
                $result.Add((Remove-OneIndent $Lines[$i]))
            }
        }
        # Skip the closing brace line itself (line $cIdx).

        # 5. Everything after the closing brace – verbatim
        #    (drop a trailing blank line that was sitting just before the '}' if desired)
        for ($i = $cIdx + 1; $i -lt $Lines.Count; $i++) {
            $result.Add($Lines[$i])
        }

        return $result.ToArray()
    }

    # ---------------------------------------------------------------------------
    # Counters for summary
    # ---------------------------------------------------------------------------
    $stats = @{ Converted = 0; AlreadyFileScoped = 0; NoNamespace = 0; Skipped = 0 }
}

process {
    $files = Get-CsFiles $Path

    foreach ($file in $files) {
        $filePath = $file.FullName

        # Read preserving line endings; split on any newline variant
        $rawContent = [System.IO.File]::ReadAllText($filePath)
        # Detect original line ending
        $crlf = $rawContent.Contains("`r`n")
        $lines = $rawContent -split "`r?`n"

        # --- Check for file-scoped namespace already present ---
        if (Has-FileScopedNamespace $lines) {
            Write-Verbose "SKIP (already file-scoped): $filePath"
            $stats.AlreadyFileScoped++
            continue
        }

        # --- Find block-scoped namespace ---
        $info = Find-BlockNamespace $lines
        if ($null -eq $info) {
            Write-Verbose "SKIP (no namespace found): $filePath"
            $stats.NoNamespace++
            continue
        }

        # --- Perform conversion ---
        $newLines = Convert-Namespace $lines $info

        $sep        = if ($crlf) { "`r`n" } else { "`n" }
        $newContent = $newLines -join $sep

        if ($PSCmdlet.ShouldProcess($filePath, 'Convert to file-scoped namespace')) {
            [System.IO.File]::WriteAllText($filePath, $newContent, [System.Text.UTF8Encoding]::new($false))
            Write-Host "CONVERTED: $filePath" -ForegroundColor Green
            $stats.Converted++
        } else {
            # -WhatIf path
            Write-Host "WOULD CONVERT: $filePath  ($($info.NamespaceDecl);)" -ForegroundColor Cyan
            $stats.Skipped++
        }
    }
}

end {
    Write-Host ''
    Write-Host '--- Summary ---' -ForegroundColor Yellow
    Write-Host "  Converted          : $($stats.Converted)"
    Write-Host "  Already file-scoped: $($stats.AlreadyFileScoped)"
    Write-Host "  No namespace found : $($stats.NoNamespace)"
    if ($stats.Skipped -gt 0) {
        Write-Host "  Would convert (-WhatIf): $($stats.Skipped)"
    }
}