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