From f5d1c72deb24f3c4cb581fdbf8fe203e3bd132dc Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 26 Jul 2026 09:47:11 +0200 Subject: [PATCH 1/3] Add Format-Toml with nested-table indentation support --- src/functions/private/Add-TomlIndentation.ps1 | 75 ++++++++++++ src/functions/public/Format-Toml.ps1 | 108 ++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 src/functions/private/Add-TomlIndentation.ps1 create mode 100644 src/functions/public/Format-Toml.ps1 diff --git a/src/functions/private/Add-TomlIndentation.ps1 b/src/functions/private/Add-TomlIndentation.ps1 new file mode 100644 index 0000000..d017456 --- /dev/null +++ b/src/functions/private/Add-TomlIndentation.ps1 @@ -0,0 +1,75 @@ +function Add-TomlIndentation { + <# + .SYNOPSIS + Indents nested TOML table sections by nesting depth. + + .DESCRIPTION + Re-indents already-serialized TOML text so that table headers and their + key/value lines are prefixed with spaces proportional to the table's nesting + depth. Depth is derived from the dotted path in each `[path]` / `[[path]]` + header — a root table has depth 1, `[a.b]` has depth 2, and so on. Header + lines are indented one level shallower than the keys they contain, so the + keys visually nest under their header. Root-level keys (before any header) + are never indented. TOML is whitespace-insensitive around keys and headers, + so this is purely a display convention and does not change the parsed value. + + .EXAMPLE + Add-TomlIndentation -Toml "[a]`nx = 1`n[a.b]`ny = 2" -Indent 2 + # Returns: + # [a] + # x = 1 + # [a.b] + # y = 2 + + Indents nested table `[a.b]` and its key two spaces per nesting level. + + .INPUTS + None. Parameters only. + + .OUTPUTS + [string] + #> + [OutputType([string])] + [CmdletBinding()] + param( + # The canonical, non-indented TOML text produced by ConvertTo-Toml. + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Toml, + + # Number of spaces to indent per nesting level. 0 disables indentation. + [Parameter(Mandatory)] + [ValidateRange(0, 100)] + [int] $Indent + ) + + if ($Indent -eq 0 -or [string]::IsNullOrEmpty($Toml)) { + return $Toml + } + + $lines = $Toml -split "`n" + $result = [System.Text.StringBuilder]::new() + $depth = 0 + + foreach ($rawLine in $lines) { + $line = $rawLine.TrimEnd("`r") + + if ($line -match '^\[\[(.+)\]\]$' -or $line -match '^\[(.+)\]$') { + $path = $Matches[1] + $depth = (Split-TomlDottedKey -KeyPath $path).Count + $headerIndent = ' ' * ([Math]::Max(0, $depth - 1) * $Indent) + $null = $result.AppendLine("$headerIndent$line") + continue + } + + if ([string]::IsNullOrWhiteSpace($line)) { + $null = $result.AppendLine() + continue + } + + $lineIndent = ' ' * ($depth * $Indent) + $null = $result.AppendLine("$lineIndent$line") + } + + return $result.ToString().TrimEnd() +} diff --git a/src/functions/public/Format-Toml.ps1 b/src/functions/public/Format-Toml.ps1 new file mode 100644 index 0000000..0065576 --- /dev/null +++ b/src/functions/public/Format-Toml.ps1 @@ -0,0 +1,108 @@ +function Format-Toml { + <# + .SYNOPSIS + Normalizes TOML text to a canonical form. + + .DESCRIPTION + Parses TOML text and re-serializes it, producing consistent key quoting, + canonical scalar formatting, and stable table ordering — semantically + equivalent to `ConvertFrom-Toml | ConvertTo-Toml` expressed as a single + ergonomic pipeline step. The result is idempotent: formatting already + canonical text returns the same text unchanged. + + TOML itself is a flat, whitespace-insensitive format — nested tables are + represented as `[a.b]` headers rather than indented blocks, so there is no + spec-defined meaning for indentation. The `-Indent` parameter is offered as + a display convention only: it prefixes each table header and its key/value + lines with spaces proportional to the table's nesting depth (as some TOML + formatters, such as Taplo, do). This never changes the parsed value of the + document. Set `-Indent 0` to disable it and keep the flat, unindented form + that `ConvertTo-Toml` produces. + + .EXAMPLE + Format-Toml -InputObject 'name="value" [ a ] x=1' + + Reformats an inconsistently spaced TOML string into canonical form. + + .EXAMPLE + Get-Content 'Cargo.toml' -Raw | Format-Toml + + Reads a file's content and normalizes it via the pipeline. + + .EXAMPLE + Format-Toml -Path 'Cargo.toml' -Indent 4 + + Reads a TOML file and returns its canonical form with nested tables + indented four spaces per level. + + .EXAMPLE + Format-Toml -LiteralPath 'C:\configs\[env].toml' + + Reads a file whose name contains wildcard-like characters, bypassing + wildcard expansion. + + .INPUTS + [string] — pipeline input supported for the InputObject parameter. + + .OUTPUTS + [string] + + .NOTES + Throws when the input is not valid TOML 1.0.0, when a file path does not + resolve to an existing file, or when a file cannot be read. This function + does not validate — it formats. Use a try/catch (or `Test-Toml`, if + available) to check validity without raising a terminating error. + + .LINK + https://psmodule.io/Toml/Functions/Format-Toml + #> + [OutputType([string])] + [CmdletBinding(DefaultParameterSetName = 'InputObject')] + param( + # TOML text to normalize. + [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'InputObject')] + [ValidateNotNullOrEmpty()] + [string] $InputObject, + + # Path to a TOML file to read and normalize. Accepts relative paths and wildcards. + [Parameter(Mandatory, ParameterSetName = 'Path')] + [ValidateNotNullOrEmpty()] + [string] $Path, + + # Literal path to a TOML file to read and normalize. No wildcard expansion is performed. + [Parameter(Mandatory, ParameterSetName = 'LiteralPath')] + [ValidateNotNullOrEmpty()] + [string] $LiteralPath, + + # Spaces used to indent nested table headers and keys per nesting level. + # This is a display convention only — TOML has no indentation semantics. + # Set to 0 to keep the flat, unindented canonical form. + [Parameter()] + [ValidateRange(0, 100)] + [int] $Indent = 2 + ) + + process { + $tomlText = switch ($PSCmdlet.ParameterSetName) { + 'Path' { + $resolvedPath = Resolve-Path -Path $Path -ErrorAction Stop + Write-Verbose "Reading TOML file: $($resolvedPath.ProviderPath)" + [System.IO.File]::ReadAllText($resolvedPath.ProviderPath) + } + 'LiteralPath' { + $resolvedPath = Resolve-Path -LiteralPath $LiteralPath -ErrorAction Stop + Write-Verbose "Reading TOML file: $($resolvedPath.ProviderPath)" + [System.IO.File]::ReadAllText($resolvedPath.ProviderPath) + } + default { + $InputObject + } + } + + Write-Verbose "Normalizing TOML text ($($tomlText.Length) character(s)), Indent=$Indent." + $doc = ConvertFrom-Toml -InputObject $tomlText + $canonical = ConvertTo-Toml -InputObject $doc + + return Add-TomlIndentation -Toml $canonical -Indent $Indent + } +} From 90fe0e6a2f03712fb72ad6e1443d583310754eae Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 26 Jul 2026 09:47:15 +0200 Subject: [PATCH 2/3] Add Pester tests for Format-Toml normalization, indentation, and idempotency --- tests/Toml.Tests.ps1 | 110 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/tests/Toml.Tests.ps1 b/tests/Toml.Tests.ps1 index 1cbbcc3..45a4a36 100644 --- a/tests/Toml.Tests.ps1 +++ b/tests/Toml.Tests.ps1 @@ -15,6 +15,7 @@ Describe 'Toml' { $commands | Should -Contain 'ConvertTo-Toml' $commands | Should -Contain 'Import-Toml' $commands | Should -Contain 'Export-Toml' + $commands | Should -Contain 'Format-Toml' } } @@ -801,4 +802,113 @@ Describe 'Toml' { } } } + + Describe 'Format-Toml' { + + Context 'Return type' { + It 'returns a string' { + $result = Format-Toml -InputObject 'key = "value"' + $result | Should -BeOfType [string] + } + } + + Context 'Normalization' { + It 'normalizes inconsistent key quoting' { + $result = Format-Toml -InputObject @' +"quoted" = 1 +bare = 2 +'@ + $result | Should -Match 'quoted = 1' + $result | Should -Match 'bare = 2' + } + + It 'is semantically equivalent to ConvertFrom-Toml | ConvertTo-Toml' { + $source = @' +title = "My App" +[server] +port = 8080 +'@ + $expected = ConvertTo-Toml -InputObject (ConvertFrom-Toml -InputObject $source) + (Format-Toml -InputObject $source -Indent 0) | Should -Be $expected + } + + It 'indents nested tables by nesting depth' { + $result = Format-Toml -InputObject "[a]`nx = 1`n[a.b]`ny = 2" -Indent 2 + $result | Should -Match "(?m)^ x = 1\r?$" + $result | Should -Match "(?m)^ \[a\.b\]\r?$" + $result | Should -Match "(?m)^ y = 2\r?$" + } + + It 'produces flat, unindented output when Indent is 0' { + $result = Format-Toml -InputObject "[a]`nx = 1`n[a.b]`ny = 2" -Indent 0 + $result | Should -Match "(?m)^x = 1\r?$" + $result | Should -Match "(?m)^y = 2\r?$" + } + } + + Context 'Idempotency' { + It 'formatting already-canonical text returns the same text' { + $source = @' +title = "My App" +[server] +host = "localhost" +port = 8080 +[server.nested] +key = "value" +'@ + $once = Format-Toml -InputObject $source + $twice = Format-Toml -InputObject $once + $twice | Should -Be $once + } + } + + Context 'Round-trip validity' { + It 'output remains parseable by ConvertFrom-Toml' { + $source = @' +[database] +host = "db.example.com" +ports = [8001, 8002] +'@ + $formatted = Format-Toml -InputObject $source + $parsed = ConvertFrom-Toml -InputObject $formatted + $parsed.Data['database']['host'] | Should -Be 'db.example.com' + $parsed.Data['database']['ports'] | Should -HaveCount 2 + } + } + + Context 'Pipeline' { + It 'accepts InputObject from the pipeline' { + $result = 'key = "value"' | Format-Toml + $result | Should -Match 'key = "value"' + } + } + + Context 'Path parameter' { + It 'reads a file and returns normalized TOML text' { + $path = Join-Path $TestDrive 'input.toml' + Set-Content -Path $path -Value '"quoted"=1' -NoNewline + $result = Format-Toml -Path $path + $result | Should -Match 'quoted = 1' + } + } + + Context 'LiteralPath parameter' { + It 'reads a file and returns normalized TOML text' { + $path = Join-Path $TestDrive 'literal.toml' + Set-Content -Path $path -Value '"quoted"=1' -NoNewline + $result = Format-Toml -LiteralPath $path + $result | Should -Match 'quoted = 1' + } + } + + Context 'Error handling' { + It 'throws on invalid TOML input' { + { Format-Toml -InputObject 'invalid = = "bad"' } | Should -Throw + } + + It 'throws when the file does not exist' { + { Format-Toml -Path 'nonexistent-file.toml' } | Should -Throw + } + } + } } From e86501bc900971fb22ee1dcd49ee6a9af181c21b Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 26 Jul 2026 09:47:15 +0200 Subject: [PATCH 3/3] Document Format-Toml in README and General usage example --- README.md | 15 +++++++++++++++ examples/General.ps1 | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/README.md b/README.md index 967fed8..4c876eb 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,20 @@ $doc.Data['version'] = 2 Export-Toml -InputObject $doc -Path './config.toml' ``` +### Normalize TOML text + +```powershell +Get-Content 'Cargo.toml' -Raw | Format-Toml +Format-Toml -Path 'Cargo.toml' -Indent 4 +``` + +`Format-Toml` parses then re-serializes TOML text, producing consistent key +quoting and canonical scalar forms — equivalent to +`ConvertFrom-Toml | ConvertTo-Toml` as a single call. TOML itself has no +indentation semantics; `-Indent` (default `2`) only controls how many spaces +are used to visually nest table headers and their keys by depth. Set +`-Indent 0` for the flat, unindented form. + ## TOML type mapping | TOML type | PowerShell type | @@ -102,6 +116,7 @@ Export-Toml -InputObject $doc -Path './config.toml' | `ConvertTo-Toml` | Serialize object → TOML text | | `Import-Toml` | Read TOML file → `TomlDocument` | | `Export-Toml` | Write object or `TomlDocument` to file | +| `Format-Toml` | Normalize TOML text to canonical form | ## Implementation notes diff --git a/examples/General.ps1 b/examples/General.ps1 index d9b484c..877d8f7 100644 --- a/examples/General.ps1 +++ b/examples/General.ps1 @@ -50,6 +50,21 @@ Write-Host $toml # $doc.Data['version'] = 3 # Export-Toml -InputObject $doc -Path './config.toml' +# ── Normalize TOML text ──────────────────────────────────────────────────── +$normalized = Format-Toml -InputObject @' +title="My App" +[server] +host="localhost" +port=8080 +'@ +Write-Host $normalized +# title = "My App" +# +# [server] +# host = "localhost" +# port = 8080 + + # ── Pipeline usage ───────────────────────────────────────────────────────── # '[server] # host = "localhost"' | ConvertFrom-Toml | ConvertTo-Toml