diff --git a/.github/workflows/Process-PSModule.yml b/.github/workflows/Process-PSModule.yml index f2b9b43..dceb2e0 100644 --- a/.github/workflows/Process-PSModule.yml +++ b/.github/workflows/Process-PSModule.yml @@ -27,6 +27,6 @@ permissions: jobs: Process-PSModule: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@fb1bdb8fefd243292f779d2a856a38db6fe6daf4 # v6.1.13 + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@11117919e65242d3388727819a751f74ad24ea9e # v5.5.0 secrets: APIKEY: ${{ secrets.APIKEY }} diff --git a/zensical.toml b/.github/zensical.toml similarity index 100% rename from zensical.toml rename to .github/zensical.toml diff --git a/.gitignore b/.gitignore index 456ca0f..4238184 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ # PSModule framework outputs folder outputs/* +output/ # .Net build output bin/ diff --git a/README.md b/README.md index f12b1d2..967fed8 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,135 @@ # Toml -Toml is a PowerShell module for reading TOML file content from disk so scripts can load and process configuration text. +PowerShell module for reading and writing [TOML](https://toml.io) data, with full TOML 1.0.0 specification support. + +## Prerequisites + +- PowerShell 7.6 LTS +- The [PSModule framework](https://github.com/PSModule/Process-PSModule) for building, testing and publishing the module. ## Installation -Install the module from the PowerShell Gallery: +To install the module from the PowerShell Gallery, you can use the following command: ```powershell Install-PSResource -Name Toml Import-Module -Name Toml ``` -## Capabilities +## Usage -Use this command to read TOML content from a file path: +### Parse a TOML string ```powershell -Get-Toml -Path '.\settings.toml' +$doc = ConvertFrom-Toml -InputObject @' +[database] +host = "localhost" +port = 5432 +enabled = true +'@ + +$doc.Data.database.host # "localhost" +$doc.Data.database.port # 5432 [long] +$doc.Data.database.enabled # $true ``` -## Documentation +### Import from a TOML file + +```powershell +$doc = Import-Toml -Path './config.toml' +$doc.FilePath # absolute path to the file +$doc.Data # OrderedDictionary of all top-level keys +``` + +### Serialize an object to TOML + +```powershell +$toml = ConvertTo-Toml -InputObject ([ordered]@{ + title = 'My App' + version = 1 + server = [ordered]@{ + host = 'localhost' + port = 8080 + } +}) +# title = "My App" +# version = 1 +# +# [server] +# host = "localhost" +# port = 8080 +``` + +### Export to a TOML file + +```powershell +$config = [ordered]@{ + name = 'example' + enabled = $true +} +Export-Toml -InputObject $config -Path './output.toml' +``` + +### Round-trip: file → modify → file + +```powershell +$doc = Import-Toml -Path './config.toml' +$doc.Data['version'] = 2 +Export-Toml -InputObject $doc -Path './config.toml' +``` + +## TOML type mapping + +| TOML type | PowerShell type | +|----------------------|----------------------------| +| String | `[string]` | +| Integer | `[long]` | +| Float | `[double]` | +| Boolean | `[bool]` | +| Offset date-time | `[System.DateTimeOffset]` | +| Local date-time | `[System.DateTime]` | +| Local date | `[System.DateTime]` | +| Local time | `[System.TimeSpan]` | +| Array | `[object[]]` | +| Table / Inline table | `[ordered]` hashtable | +| Array of tables | `[object[]]` of hashtables | + +## Commands -Documentation is published at [psmodule.io/Toml](https://psmodule.io/Toml/). +| Command | Description | +|-------------------|------------------------------------------| +| `ConvertFrom-Toml` | Parse TOML text → `TomlDocument` | +| `ConvertTo-Toml` | Serialize object → TOML text | +| `Import-Toml` | Read TOML file → `TomlDocument` | +| `Export-Toml` | Write object or `TomlDocument` to file | -Use PowerShell help and command discovery for details: +## Implementation notes + +- Pure PowerShell parser and serializer — no external TOML runtime dependency. +- Duplicate keys and table redefinition are rejected per the TOML 1.0.0 spec. +- Files are written as UTF-8 without BOM. + +## More examples + +See the [examples](examples) folder for runnable scripts. Use PowerShell help for per-command examples: ```powershell -Get-Command -Module Toml -Get-Help -Name Get-Toml -Examples +Get-Help ConvertFrom-Toml -Examples +Get-Help Import-Toml -Examples ``` + +## Documentation + +Full documentation is available at [psmodule.io/Toml](https://psmodule.io/Toml). + +## Contributing + +Coder or not, you can contribute to the project! We welcome all contributions. + +### For Users + +If you experience unexpected behavior, errors, or missing functionality, please open an issue on the [issues tab](https://github.com/PSModule/Toml/issues). + +### For Developers + +Please read the [Contribution guidelines](CONTRIBUTING.md) and pick up an existing issue or submit a new one. diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..8b07cf2 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,94 @@ +#!/usr/bin/env pwsh +#Requires -Version 7.0 +<# + .SYNOPSIS + Builds the Toml module from source into ./output/Toml/. + + .DESCRIPTION + Assembles all source files (classes, init, private functions, public functions) + into a single psm1 and creates a manifest, so tests can be run locally without + the PSModule CI pipeline. +#> +[CmdletBinding()] +param( + # Version to stamp into the manifest. + [string] $ModuleVersion = '0.0.1' +) + +$ErrorActionPreference = 'Stop' +$moduleName = 'Toml' +$srcPath = Join-Path $PSScriptRoot 'src' +$outputPath = Join-Path $PSScriptRoot 'output' $moduleName + +# ── clean / create output directory ───────────────────────────────────────── +if (Test-Path $outputPath) { + Remove-Item $outputPath -Recurse -Force +} +$null = New-Item -ItemType Directory -Path $outputPath -Force + +# ── build psm1 ─────────────────────────────────────────────────────────────── +$psm1Path = Join-Path $outputPath "$moduleName.psm1" +$sb = [System.Text.StringBuilder]::new() + +# header +$headerPath = Join-Path $srcPath 'header.ps1' +if (Test-Path $headerPath) { + $null = $sb.AppendLine((Get-Content $headerPath -Raw)) +} else { + $null = $sb.AppendLine('$ErrorActionPreference = ''Stop''') + $null = $sb.AppendLine() +} + +# init +foreach ($f in (Get-ChildItem (Join-Path $srcPath 'init') -Filter '*.ps1' -ErrorAction SilentlyContinue)) { + $null = $sb.AppendLine((Get-Content $f.FullName -Raw)) +} + +# classes private then public +foreach ($visibility in @('private', 'public')) { + $classPath = Join-Path $srcPath "classes/$visibility" + foreach ($f in (Get-ChildItem $classPath -Filter '*.ps1' -Recurse -ErrorAction SilentlyContinue)) { + $null = $sb.AppendLine((Get-Content $f.FullName -Raw)) + } +} + +# private functions +foreach ($f in (Get-ChildItem (Join-Path $srcPath 'functions/private') -Filter '*.ps1' -Recurse -ErrorAction SilentlyContinue)) { + $null = $sb.AppendLine((Get-Content $f.FullName -Raw)) +} + +# public functions +$publicFunctions = [System.Collections.Generic.List[string]]::new() +foreach ($f in (Get-ChildItem (Join-Path $srcPath 'functions/public') -Filter '*.ps1' -Recurse -ErrorAction SilentlyContinue)) { + $null = $sb.AppendLine((Get-Content $f.FullName -Raw)) + $publicFunctions.Add([System.IO.Path]::GetFileNameWithoutExtension($f.Name)) +} + +# finally +$finallyPath = Join-Path $srcPath 'finally.ps1' +if (Test-Path $finallyPath) { + $null = $sb.AppendLine((Get-Content $finallyPath -Raw)) +} + +# Export-ModuleMember +$null = $sb.AppendLine("Export-ModuleMember -Function @($($publicFunctions | ForEach-Object { "'$_'" } | Join-String -Separator ', '))") + +[System.IO.File]::WriteAllText($psm1Path, $sb.ToString(), [System.Text.UTF8Encoding]::new($false)) + +# ── write manifest ──────────────────────────────────────────────────────────── +$psd1Path = Join-Path $outputPath "$moduleName.psd1" +$manifest = @{ + Path = $psd1Path + ModuleVersion = $ModuleVersion + RootModule = "$moduleName.psm1" + FunctionsToExport = $publicFunctions.ToArray() + PowerShellVersion = '7.6' + Description = 'PowerShell module for reading and writing TOML data.' + Author = 'PSModule' + CompanyName = 'PSModule' + GUID = '6f1b6f8d-1234-4321-abcd-ef0123456789' +} +New-ModuleManifest @manifest + +Write-Host "Built $moduleName $ModuleVersion -> $outputPath" +Write-Host "Public functions: $($publicFunctions -join ', ')" diff --git a/examples/General.ps1 b/examples/General.ps1 new file mode 100644 index 0000000..d9b484c --- /dev/null +++ b/examples/General.ps1 @@ -0,0 +1,76 @@ +<# + .SYNOPSIS + General usage examples for the Toml module. +#> + +# Import the module +Import-Module -Name 'Toml' + +# ── Parse TOML string ────────────────────────────────────────────────────── +$doc = ConvertFrom-Toml -InputObject @' +[database] +host = "localhost" +port = 5432 +enabled = true +tags = ["production", "primary"] + +[database.credentials] +user = "admin" +'@ + +$doc.Data.database.host # "localhost" +$doc.Data.database.port # 5432 +$doc.Data.database.enabled # True +$doc.Data.database.tags # @("production", "primary") +$doc.Data.database.credentials.user # "admin" + +# ── Serialize to TOML ────────────────────────────────────────────────────── +$toml = ConvertTo-Toml -InputObject ([ordered]@{ + title = 'My Application' + version = 2 + debug = $false + server = [ordered]@{ + host = '0.0.0.0' + port = 8080 + } + features = @('auth', 'logging', 'metrics') +}) +Write-Host $toml + +# ── Import from file ─────────────────────────────────────────────────────── +# $doc = Import-Toml -Path './config.toml' +# $doc.FilePath # absolute path to the source file +# $doc.Data # [ordered] hashtable of all top-level keys + +# ── Export to file ───────────────────────────────────────────────────────── +# Export-Toml -InputObject ([ordered]@{ key = 'value' }) -Path './config.toml' + +# ── Round-trip: file → edit → file ──────────────────────────────────────── +# $doc = Import-Toml -Path './config.toml' +# $doc.Data['version'] = 3 +# Export-Toml -InputObject $doc -Path './config.toml' + +# ── Pipeline usage ───────────────────────────────────────────────────────── +# '[server] +# host = "localhost"' | ConvertFrom-Toml | ConvertTo-Toml + +# ── All TOML scalar types ────────────────────────────────────────────────── +$types = ConvertFrom-Toml -InputObject @' +a_string = "hello" +an_integer = 42 +a_float = 3.14 +a_bool = true +an_offset_dt = 1979-05-27T07:32:00Z +a_local_dt = 1979-05-27T07:32:00 +a_local_date = 1979-05-27 +a_local_time = 07:32:00 +'@ + +$types.Data.a_string # [string] +$types.Data.an_integer # [long] +$types.Data.a_float # [double] +$types.Data.a_bool # [bool] +$types.Data.an_offset_dt # [System.DateTimeOffset] +$types.Data.a_local_dt # [System.DateTime] (Kind=Unspecified) +$types.Data.a_local_date # [System.DateTime] (00:00:00) +$types.Data.a_local_time # [System.TimeSpan] diff --git a/examples/data/cargo-manifest.toml b/examples/data/cargo-manifest.toml new file mode 100644 index 0000000..391002b --- /dev/null +++ b/examples/data/cargo-manifest.toml @@ -0,0 +1,91 @@ +[package] +name = "my-awesome-lib" +version = "0.9.1" +edition = "2021" +authors = ["Alice ", "Bob "] +license = "MIT OR Apache-2.0" +description = "A library demonstrating TOML 1.0.0 features." +readme = "README.md" + +package.metadata.docs_rs.features = ["full"] +package.metadata.docs_rs.targets = ["x86_64-unknown-linux-gnu"] + +package.metadata.build_notes = ''' +Build with: + cargo build --release + +Windows cross-compile: + cargo build --target x86_64-pc-windows-gnu \ + --features "tls-native" +No escapes needed: C:\Users\Alice\project is literal. +''' + +[features] +default = ["std", "derive"] +full = ["std", "derive", "async", "serde-support"] +std = [] +derive = [] +async = [] +serde-support = [] + +[lib] +name = "my_awesome_lib" +crate-type = ["rlib", "cdylib"] + +[package.limits] +max-size-decimal = 1_048_576 +max-size-hex = 0x0010_0000 +max-size-octal = 0o4_000_000 +max-size-binary = 0b0001_0000_0000_0000_0000_0000 +page-size = 0o10000 +flag-bits = 0b1100_0011 +magic-number = 0xDEAD_BEEF + +[dependencies] +serde = { version = "1", features = ["derive"] } +tokio = { version = "1", features = ["full"], optional = true } +thiserror = "1" +tracing = "0.1" + +[dependencies.reqwest] +version = "0.12" +default-features = false +features = ["json", "rustls-tls"] +optional = true + +[dev-dependencies] +criterion = { version = "0.5", features = ["html_reports"] } +proptest = "1" +tempfile = "3" + +[[bench]] +name = "encode_throughput" +harness = false + +[[bench]] +name = "decode_throughput" +harness = false + +[[bench]] +name = "roundtrip" +harness = false + +[[example]] +name = "basic_usage" +required-features = ["std"] + +[[example]] +name = "async_client" +required-features = ["async", "serde-support"] + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 +strip = "symbols" +overflow-checks = false + +[profile.dev] +opt-level = 0 +debug = true +overflow-checks = true diff --git a/examples/data/ci-pipeline.toml b/examples/data/ci-pipeline.toml new file mode 100644 index 0000000..abc6406 --- /dev/null +++ b/examples/data/ci-pipeline.toml @@ -0,0 +1,71 @@ +[pipeline] +name = "my-app-ci" +version = 2 +timeout = 3_600 +fail-fast = true + +pipeline.meta.owner = "platform-team" +pipeline.meta.created = 2024-03-15 +pipeline.meta.description = """ +A multi-stage pipeline that builds, \ +tests, and deploys the application \ +to all target environments.""" + +[pipeline.triggers] +branches = ["main", "release/*", "hotfix/*"] +tags = ["v[0-9]*"] +on-pr = true + +[[pipeline.stages]] +name = "build" +image = "rust:1.78-slim" +allow-fail = false + +[pipeline.stages.env] +RUST_BACKTRACE = "1" +CARGO_TERM_COLOR = "always" + +[[pipeline.stages.steps]] +name = "restore-cache" +run = "cargo fetch --locked" + +[[pipeline.stages.steps]] +name = "compile" +run = "cargo build --release --locked" +timeout = 1_800 + +[[pipeline.stages]] +name = "test" +image = "rust:1.78-slim" +depends-on = ["build"] +allow-fail = false + +[pipeline.stages.env] +RUST_LOG = "debug" + +[[pipeline.stages.steps]] +name = "unit-tests" +run = "cargo test --workspace" + +[[pipeline.stages.steps]] +name = "coverage" +run = "cargo llvm-cov --lcov --output-path lcov.info" +timeout = 600 + +[[pipeline.stages]] +name = "deploy" +image = "alpine:3.19" +depends-on = ["build", "test"] +when = { branch = "main", event = "push" } + +[[pipeline.stages.steps]] +name = "upload-artifact" +run = "aws s3 cp ./target/release/my-app s3://releases/" + +[[pipeline.stages.steps]] +name = "notify-slack" +run = "curl -X POST $SLACK_WEBHOOK -d @payload.json" + +[pipeline.notifications] +on-success = { channel = "#deploys", message = "Deploy succeeded" } +on-failure = { channel = "#oncall", message = "Pipeline failed!" } diff --git a/examples/data/database-server.toml b/examples/data/database-server.toml new file mode 100644 index 0000000..13de59c --- /dev/null +++ b/examples/data/database-server.toml @@ -0,0 +1,147 @@ +cluster.name = "primary-pg-cluster" +cluster.version = "16.2" +cluster.region = "eu-central-1" + +"cluster.id" = "pg-eur-001" +"127.0.0.1" = "loopback-alias" +'special chars' = "spaces in key name" + +[audit] +cluster-created = 1970-01-01T00:00:00Z +last-failover = 2024-03-10T02:30:00-05:00 +last-maintenance = 2024-01-28T22:00:00+01:00 +last-config-edit = 2024-07-22T11:45:30 +last-vacuum-start = 2024-07-21T03:00:00.000 +certificate-expiry = 2025-08-14 +created-date = 2022-09-01 +daily-backup-time = 03:30:00 +maintenance-window = 22:00:00 +checkpoint-time = 00:00:00.000 + +[nodes.primary] +host = "pg-primary.internal" +port = 5432 +max-connections = 500 +shared-buffers = "4GB" + +nodes.primary.replication.slots = 4 +nodes.primary.replication.timeout = 60 +nodes.primary.replication.mode = "async" + +[nodes.primary.wal] +level = "replica" +archive = true +archive-dir = "/mnt/wal-archive/primary" +compression = "lz4" +keep-segments = 16 + +[nodes.primary.checkpoint] +completion-target = 0.9 +warning-seconds = 30 + +[[nodes.replicas]] +name = "replica-01" +host = "pg-replica-01.internal" +port = 5432 +priority = 100 +lag-warning-ms = 5_000 +lag-critical-ms = 30_000 +sync-mode = "async" +promoted = false + +[nodes.replicas.connection] +pool-size = 20 +keepalive = { idle = 60, interval = 10, count = 5 } + +[[nodes.replicas.slots]] +name = "wal_slot_app_01" +plugin = "pgoutput" +active = true + +[[nodes.replicas.slots]] +name = "wal_slot_analytics" +plugin = "wal2json" +active = false + +[[nodes.replicas]] +name = "replica-02" +host = "pg-replica-02.internal" +port = 5432 +priority = 90 +lag-warning-ms = 5_000 +lag-critical-ms = 30_000 +sync-mode = "sync" +promoted = false + +[nodes.replicas.connection] +pool-size = 15 +keepalive = { idle = 60, interval = 10, count = 5 } + +[[nodes.replicas.slots]] +name = "wal_slot_app_02" +plugin = "pgoutput" +active = true + +[storage] +block-size-decimal = 8192 +block-size-hex = 0x2000 +block-size-octal = 0o20000 +block-size-binary = 0b0010_0000_0000_0000 + +max-table-size-dec = 1_073_741_824 +max-table-size-hex = 0x4000_0000 + +file-permissions = 0o600 +cache-hit-target = 0.99 +bloat-threshold = 0.20 +fill-factor = 9.0e1 +compression-ratio = 3.5e+0 +dead-tuple-ratio = 1.0E-2 + +[monitoring] +scrape-interval = 15 +labels = { cluster = "primary-pg-cluster", env = "production" } + +alert-thresholds = [ + 0.8, + 0.9, + true, + "ops-team", +] + +retention-windows = [ + [60, "raw"], + [3600, "1h-rollup"], + [86400, "1d-rollup"], +] + +[[monitoring.alerts]] +name = "replication-lag" +severity = "critical" +threshold = 30_000 +enabled = true +created = 2023-11-01 + +[monitoring.alerts.notify] +channels = ["pagerduty", "slack-ops"] +cooldown = 300 + +[[monitoring.alerts.conditions]] +metric = "pg_replication_lag_bytes" +operator = ">" +value = 1_073_741_824 + +[[monitoring.alerts]] +name = "connection-saturation" +severity = "warning" +threshold = 0.85 +enabled = true + +[monitoring.alerts.notify] +channels = ["slack-ops"] +cooldown = 600 + +[[monitoring.alerts.conditions]] +metric = "pg_connections_ratio" +operator = ">=" +value = 0.85 diff --git a/src/classes/public/TomlDocument.ps1 b/src/classes/public/TomlDocument.ps1 new file mode 100644 index 0000000..3a6c46a --- /dev/null +++ b/src/classes/public/TomlDocument.ps1 @@ -0,0 +1,35 @@ +# Represents a parsed TOML document. +# Exposes the root key-value data as an ordered dictionary and records the +# file path when the document was loaded from disk. +class TomlDocument { + # The root key-value pairs of the TOML document, preserving insertion order. + [System.Collections.Specialized.OrderedDictionary] $Data + + # The absolute path to the source file, if loaded with Import-Toml. + # $null when the document was created from a string. + [string] $FilePath + + TomlDocument() { + $this.Data = [System.Collections.Specialized.OrderedDictionary]::new( + [System.StringComparer]::Ordinal + ) + } + + TomlDocument([System.Collections.Specialized.OrderedDictionary] $data) { + $this.Data = $data + } + + # Returns true when the document contains the given top-level key. + [bool] ContainsKey([string] $key) { + return $this.Data.Contains($key) + } + + # Returns the number of top-level keys. + [int] GetCount() { + return $this.Data.Count + } + + [string] ToString() { + return "TomlDocument[$($this.Data.Count) key(s)]" + } +} diff --git a/src/functions/private/Add-TomlTableText.ps1 b/src/functions/private/Add-TomlTableText.ps1 new file mode 100644 index 0000000..2b930a2 --- /dev/null +++ b/src/functions/private/Add-TomlTableText.ps1 @@ -0,0 +1,90 @@ +function Add-TomlTableText { + <# + .SYNOPSIS + Appends TOML text for a table to a string builder. + + .DESCRIPTION + Recursively emits TOML-formatted assignments for all scalar and sub-table + keys in the given ordered dictionary. Sub-tables are emitted as [path] headers + after all scalars; arrays of tables are emitted last as [[path]] sections. + + .EXAMPLE + $sb = [System.Text.StringBuilder]::new() + Add-TomlTableText -StringBuilder $sb -Table $root -Path '' -EmitHeader:$false + Appends all root-level keys to $sb without a section header. + + .INPUTS + None. Parameters only. + + .OUTPUTS + [void]. Content is appended to the StringBuilder passed via -StringBuilder. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Text.StringBuilder] $StringBuilder, + + [Parameter(Mandatory)] + [System.Collections.Specialized.OrderedDictionary] $Table, + + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Path, + + [Parameter(Mandatory)] + [switch] $EmitHeader + ) + + if ($EmitHeader -and -not [string]::IsNullOrEmpty($Path)) { + if ($StringBuilder.Length -gt 0 -and -not (Test-TomlEndsWithDoubleNewLine -StringBuilder $StringBuilder)) { + $null = $StringBuilder.AppendLine() + } + $null = $StringBuilder.AppendLine("[$Path]") + } + + foreach ($key in $Table.Keys) { + $value = $Table[$key] + if ($value -is [System.Collections.Specialized.OrderedDictionary]) { + continue + } + if (Test-TomlTableArray -Value $value) { + continue + } + $null = $StringBuilder.AppendLine("$(Format-TomlKey -Key $key) = $(ConvertTo-TomlValue -Value $value)") + } + + foreach ($key in $Table.Keys) { + $value = $Table[$key] + if ($value -isnot [System.Collections.Specialized.OrderedDictionary]) { + continue + } + + $childPath = if ([string]::IsNullOrEmpty($Path)) { + Format-TomlKey -Key $key + } else { + "$Path.$(Format-TomlKey -Key $key)" + } + Add-TomlTableText -StringBuilder $StringBuilder -Table $value -Path $childPath -EmitHeader:$true + } + + foreach ($key in $Table.Keys) { + $value = $Table[$key] + if (-not (Test-TomlTableArray -Value $value)) { + continue + } + + $arrayPath = if ([string]::IsNullOrEmpty($Path)) { + Format-TomlKey -Key $key + } else { + "$Path.$(Format-TomlKey -Key $key)" + } + + foreach ($item in $value) { + if ($StringBuilder.Length -gt 0 -and -not (Test-TomlEndsWithDoubleNewLine -StringBuilder $StringBuilder)) { + $null = $StringBuilder.AppendLine() + } + $null = $StringBuilder.AppendLine("[[$arrayPath]]") + Add-TomlTableText -StringBuilder $StringBuilder -Table $item -Path $arrayPath -EmitHeader:$false + } + } +} diff --git a/src/functions/private/ConvertFrom-TomlBasicStringValue.ps1 b/src/functions/private/ConvertFrom-TomlBasicStringValue.ps1 new file mode 100644 index 0000000..ae35f13 --- /dev/null +++ b/src/functions/private/ConvertFrom-TomlBasicStringValue.ps1 @@ -0,0 +1,88 @@ +function ConvertFrom-TomlBasicStringValue { + <# + .SYNOPSIS + Parses a TOML basic string at the current source index. + + .DESCRIPTION + Reads a single-line basic string ("...") or a multi-line basic string ("""...""") + starting at $Index.Value in $Source. Processes TOML escape sequences (\n, \t, + \uXXXX, etc.) and advances $Index past the closing delimiter. + + .EXAMPLE + $src = '"hello\nworld"'; $i = 0 + ConvertFrom-TomlBasicStringValue -Source $src -Index ([ref]$i) + # Returns: "hello`nworld" + Parses a single-line basic string with an escape sequence. + + .INPUTS + None. Parameters only. + + .OUTPUTS + [string] + #> + [OutputType([string])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Source, + + [Parameter(Mandatory)] + [ref] $Index + ) + + if ($Source.Substring($Index.Value).StartsWith('"""', [System.StringComparison]::Ordinal)) { + $Index.Value += 3 + $start = $Index.Value + $end = $Source.IndexOf('"""', $start, [System.StringComparison]::Ordinal) + if ($end -lt 0) { + throw [System.InvalidOperationException]::new('Unterminated multi-line basic string.') + } + $Index.Value = $end + 3 + return $Source.Substring($start, $end - $start) + } + + $Index.Value++ + $sb = [System.Text.StringBuilder]::new() + while ($Index.Value -lt $Source.Length) { + $ch = $Source[$Index.Value] + if ($ch -eq '"') { + $Index.Value++ + return $sb.ToString() + } + + if ($ch -ne '\') { + $null = $sb.Append($ch) + $Index.Value++ + continue + } + + $Index.Value++ + if ($Index.Value -ge $Source.Length) { + throw [System.InvalidOperationException]::new('Invalid trailing backslash in TOML string.') + } + $esc = $Source[$Index.Value] + switch ($esc) { + '"' { $null = $sb.Append('"') } + '\' { $null = $sb.Append('\') } + 'b' { $null = $sb.Append("`b") } + 't' { $null = $sb.Append("`t") } + 'n' { $null = $sb.Append("`n") } + 'f' { $null = $sb.Append("`f") } + 'r' { $null = $sb.Append("`r") } + 'u' { + if (($Index.Value + 4) -ge $Source.Length) { + throw [System.InvalidOperationException]::new('Invalid \u escape in TOML string.') + } + $hex = $Source.Substring($Index.Value + 1, 4) + $null = $sb.Append([char][Convert]::ToInt32($hex, 16)) + $Index.Value += 4 + } + default { + throw [System.InvalidOperationException]::new("Unsupported TOML escape sequence '\$esc'.") + } + } + $Index.Value++ + } + + throw [System.InvalidOperationException]::new('Unterminated TOML basic string.') +} diff --git a/src/functions/private/ConvertFrom-TomlDateTime.ps1 b/src/functions/private/ConvertFrom-TomlDateTime.ps1 new file mode 100644 index 0000000..1b66d94 --- /dev/null +++ b/src/functions/private/ConvertFrom-TomlDateTime.ps1 @@ -0,0 +1,60 @@ +function ConvertFrom-TomlDateTime { + <# + .SYNOPSIS + Converts a TOML date/time token to a PowerShell date/time value. + + .DESCRIPTION + Parses TOML local date, local time, local date-time, and offset date-time + tokens and returns the corresponding PowerShell type. + + .EXAMPLE + ConvertFrom-TomlDateTime -Token '1979-05-27T07:32:00Z' + # Returns: [DateTimeOffset] 1979-05-27 07:32:00 +00:00 + Parses an offset date-time token. + + .EXAMPLE + ConvertFrom-TomlDateTime -Token '07:32:00' + # Returns: [TimeSpan] 07:32:00 + Parses a local time token. + + .INPUTS + None. Parameters only. + + .OUTPUTS + [System.DateTimeOffset], [System.DateTime], or [System.TimeSpan] — depending on the token form. + #> + [OutputType([System.DateTimeOffset], [System.DateTime], [System.TimeSpan])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Token + ) + + $value = $Token.Trim() + $culture = [System.Globalization.CultureInfo]::InvariantCulture + $dateStyles = [System.Globalization.DateTimeStyles]::None + + if ($value -match '^\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+\-]\d{2}:\d{2})$') { + $normalized = $value -replace '^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})', '$1T$2' + $dto = [System.DateTimeOffset]::Parse($normalized, $culture, $dateStyles) + return $dto + } + + if ($value -match '^\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}:\d{2}(?:\.\d+)?$') { + $normalized = $value -replace '^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})', '$1T$2' + $dt = [System.DateTime]::Parse($normalized, $culture, $dateStyles) + return [System.DateTime]::SpecifyKind($dt, [System.DateTimeKind]::Unspecified) + } + + if ($value -match '^\d{4}-\d{2}-\d{2}$') { + $dt = [System.DateTime]::ParseExact($value, 'yyyy-MM-dd', $culture, $dateStyles) + return [System.DateTime]::SpecifyKind($dt, [System.DateTimeKind]::Unspecified) + } + + if ($value -match '^\d{2}:\d{2}:\d{2}(?:\.\d+)?$') { + return [System.TimeSpan]::Parse($value, $culture) + } + + throw [System.InvalidOperationException]::new("Invalid TOML date/time token: '$Token'.") +} diff --git a/src/functions/private/ConvertFrom-TomlLiteralStringValue.ps1 b/src/functions/private/ConvertFrom-TomlLiteralStringValue.ps1 new file mode 100644 index 0000000..b51177e --- /dev/null +++ b/src/functions/private/ConvertFrom-TomlLiteralStringValue.ps1 @@ -0,0 +1,56 @@ +function ConvertFrom-TomlLiteralStringValue { + <# + .SYNOPSIS + Parses a TOML literal string at the current source index. + + .DESCRIPTION + Reads a single-line literal string ('...') or a multi-line literal string ('''...''') + starting at $Index.Value in $Source. No escape processing is performed. + Advances $Index past the closing delimiter. + + .EXAMPLE + $src = "'C:\Users\Alice'"; $i = 0 + ConvertFrom-TomlLiteralStringValue -Source $src -Index ([ref]$i) + # Returns: "C:\Users\Alice" + Parses a literal string containing backslashes with no escape processing. + + .INPUTS + None. Parameters only. + + .OUTPUTS + [string] + #> + [OutputType([string])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Source, + + [Parameter(Mandatory)] + [ref] $Index + ) + + if ($Source.Substring($Index.Value).StartsWith("'''", [System.StringComparison]::Ordinal)) { + $Index.Value += 3 + $start = $Index.Value + $end = $Source.IndexOf("'''", $start, [System.StringComparison]::Ordinal) + if ($end -lt 0) { + throw [System.InvalidOperationException]::new('Unterminated multi-line literal string.') + } + $Index.Value = $end + 3 + return $Source.Substring($start, $end - $start) + } + + $Index.Value++ + $start = $Index.Value + while ($Index.Value -lt $Source.Length) { + if ($Source[$Index.Value] -eq '''') { + $literal = $Source.Substring($start, $Index.Value - $start) + $Index.Value++ + return $literal + } + $Index.Value++ + } + + throw [System.InvalidOperationException]::new('Unterminated TOML literal string.') +} diff --git a/src/functions/private/ConvertFrom-TomlParsedValue.ps1 b/src/functions/private/ConvertFrom-TomlParsedValue.ps1 new file mode 100644 index 0000000..5ab978d --- /dev/null +++ b/src/functions/private/ConvertFrom-TomlParsedValue.ps1 @@ -0,0 +1,127 @@ +function ConvertFrom-TomlParsedValue { + <# + .SYNOPSIS + Parses a TOML value at the current source index. + + .DESCRIPTION + Dispatches to the appropriate parser based on the leading character at + $Index.Value: basic string, literal string, inline array ([...]), inline + table ({...}), or a bare scalar token (boolean, integer, float, datetime). + Advances $Index past the consumed value. + + .EXAMPLE + $src = '"hello"'; $i = 0 + ConvertFrom-TomlParsedValue -Source $src -Index ([ref]$i) + # Returns: "hello" + Parses a basic string value. + + .EXAMPLE + $src = '42'; $i = 0 + ConvertFrom-TomlParsedValue -Source $src -Index ([ref]$i) + # Returns: 42 ([long]) + Parses an integer value. + + .INPUTS + None. Parameters only. + + .OUTPUTS + [object] — type depends on the TOML value kind. + #> + [OutputType([object])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Source, + + [Parameter(Mandatory)] + [ref] $Index + ) + + Skip-TomlWhitespace -Source $Source -Index $Index + if ($Index.Value -ge $Source.Length) { + throw [System.InvalidOperationException]::new('Unexpected end of TOML value.') + } + + $ch = $Source[$Index.Value] + if ($ch -eq '"') { + return ConvertFrom-TomlBasicStringValue -Source $Source -Index $Index + } + if ($ch -eq '''') { + return ConvertFrom-TomlLiteralStringValue -Source $Source -Index $Index + } + + if ($ch -eq '[') { + $Index.Value++ + $items = [System.Collections.ArrayList]::new() + while ($true) { + Skip-TomlWhitespace -Source $Source -Index $Index + if ($Index.Value -ge $Source.Length) { + throw [System.InvalidOperationException]::new('Unterminated TOML array.') + } + if ($Source[$Index.Value] -eq ']') { + $Index.Value++ + break + } + + $null = $items.Add((ConvertFrom-TomlParsedValue -Source $Source -Index $Index)) + Skip-TomlWhitespace -Source $Source -Index $Index + if ($Index.Value -lt $Source.Length -and $Source[$Index.Value] -eq ',') { + $Index.Value++ + continue + } + if ($Index.Value -lt $Source.Length -and $Source[$Index.Value] -eq ']') { + continue + } + if ($Index.Value -ge $Source.Length) { + throw [System.InvalidOperationException]::new('Unterminated TOML array.') + } + throw [System.InvalidOperationException]::new('Invalid TOML array separator.') + } + return $items.ToArray() + } + + if ($ch -eq '{') { + $Index.Value++ + $dict = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::Ordinal) + while ($true) { + Skip-TomlWhitespace -Source $Source -Index $Index + if ($Index.Value -ge $Source.Length) { + throw [System.InvalidOperationException]::new('Unterminated TOML inline table.') + } + + if ($Source[$Index.Value] -eq '}') { + $Index.Value++ + break + } + + $key = if ($Source[$Index.Value] -eq '"') { + ConvertFrom-TomlBasicStringValue -Source $Source -Index $Index + } elseif ($Source[$Index.Value] -eq '''') { + ConvertFrom-TomlLiteralStringValue -Source $Source -Index $Index + } else { + Get-TomlBareToken -Source $Source -Index $Index -StopAtEquals + } + + Skip-TomlWhitespace -Source $Source -Index $Index + if ($Index.Value -ge $Source.Length -or $Source[$Index.Value] -ne '=') { + throw [System.InvalidOperationException]::new("Invalid TOML inline table entry for key '$key'.") + } + $Index.Value++ + + $dict[$key] = ConvertFrom-TomlParsedValue -Source $Source -Index $Index + Skip-TomlWhitespace -Source $Source -Index $Index + + if ($Index.Value -lt $Source.Length -and $Source[$Index.Value] -eq ',') { + $Index.Value++ + continue + } + if ($Index.Value -lt $Source.Length -and $Source[$Index.Value] -eq '}') { + continue + } + } + return $dict + } + + $token = Get-TomlBareToken -Source $Source -Index $Index + return ConvertFrom-TomlScalarToken -Token $token +} diff --git a/src/functions/private/ConvertFrom-TomlScalarToken.ps1 b/src/functions/private/ConvertFrom-TomlScalarToken.ps1 new file mode 100644 index 0000000..9db48cf --- /dev/null +++ b/src/functions/private/ConvertFrom-TomlScalarToken.ps1 @@ -0,0 +1,78 @@ +function ConvertFrom-TomlScalarToken { + <# + .SYNOPSIS + Converts a scalar TOML token to a PowerShell value. + + .DESCRIPTION + Interprets a bare scalar token string as the correct PowerShell type: + boolean, special float (inf/nan), datetime, hex/octal/binary/decimal + integer, or floating-point number. Throws for unrecognized tokens. + + .EXAMPLE + ConvertFrom-TomlScalarToken -Token 'true' + # Returns: [bool] $true + Converts the TOML boolean literal. + + .EXAMPLE + ConvertFrom-TomlScalarToken -Token '0xFF' + # Returns: 255 ([long]) + Converts a hexadecimal integer literal. + + .INPUTS + None. Parameters only. + + .OUTPUTS + [object] — bool, double, long, DateTimeOffset, DateTime, or TimeSpan. + #> + [OutputType([object])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Token + ) + + if ($Token -ceq 'true') { return $true } + if ($Token -ceq 'false') { return $false } + if ($Token -ceq 'inf' -or $Token -ceq '+inf') { return [double]::PositiveInfinity } + if ($Token -ceq '-inf') { return [double]::NegativeInfinity } + if ($Token -ceq 'nan' -or $Token -ceq '+nan' -or $Token -ceq '-nan') { return [double]::NaN } + + if ($Token -match '^\d{4}-\d{2}-\d{2}(?:[Tt ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+\-]\d{2}:\d{2})?)?$' -or + $Token -match '^\d{2}:\d{2}:\d{2}(?:\.\d+)?$') { + return ConvertFrom-TomlDateTime -Token $Token + } + + if ($Token -match '^[+\-]?0x[0-9A-Fa-f_]+$') { + $isNegative = $Token.StartsWith('-') + $hex = $Token.TrimStart('+', '-').Substring(2).Replace('_', '') + $number = [Convert]::ToUInt64($hex, 16) + if ($isNegative) { return - ([long]$number) } + return [long]$number + } + + if ($Token -match '^[+\-]?0o[0-7_]+$') { + $isNegative = $Token.StartsWith('-') + $oct = $Token.TrimStart('+', '-').Substring(2).Replace('_', '') + $number = [Convert]::ToInt64($oct, 8) + if ($isNegative) { return - $number } + return [long]$number + } + + if ($Token -match '^[+\-]?0b[01_]+$') { + $isNegative = $Token.StartsWith('-') + $bin = $Token.TrimStart('+', '-').Substring(2).Replace('_', '') + $number = [Convert]::ToInt64($bin, 2) + if ($isNegative) { return - $number } + return [long]$number + } + + if ($Token -match '^[+\-]?\d[\d_]*$') { + return [long]::Parse($Token.Replace('_', ''), [System.Globalization.CultureInfo]::InvariantCulture) + } + + if ($Token -match '^[+\-]?(?:\d[\d_]*\.\d[\d_]*|\d[\d_]*[eE][+\-]?\d[\d_]*|\d[\d_]*\.\d[\d_]*[eE][+\-]?\d[\d_]*)$') { + return [double]::Parse($Token.Replace('_', ''), [System.Globalization.CultureInfo]::InvariantCulture) + } + + throw [System.InvalidOperationException]::new("Unsupported or invalid TOML scalar value: '$Token'.") +} diff --git a/src/functions/private/ConvertFrom-TomlTable.ps1 b/src/functions/private/ConvertFrom-TomlTable.ps1 new file mode 100644 index 0000000..a43c3b4 --- /dev/null +++ b/src/functions/private/ConvertFrom-TomlTable.ps1 @@ -0,0 +1,186 @@ +function ConvertFrom-TomlTable { + <# + .SYNOPSIS + Parses a TOML document string into an ordered dictionary. + + .DESCRIPTION + Processes TOML line-by-line: strips comments, handles standard table headers + ([...]), array-of-table headers ([[...]]), multi-line values (""", ''', [, {), + and dotted key assignments. Returns the root ordered dictionary preserving + key insertion order. Validates against duplicate keys and structural conflicts. + + .EXAMPLE + ConvertFrom-TomlTable -InputObject "title = `"Test`"`n[server]`nport = 80" + # Returns: OrderedDictionary { title = "Test", server = { port = 80 } } + Parses a minimal two-key TOML document. + + .INPUTS + None. Parameters only. + + .OUTPUTS + [System.Collections.Specialized.OrderedDictionary] + #> + [OutputType([System.Collections.Specialized.OrderedDictionary])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $InputObject + ) + + $root = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::Ordinal) + $currentTable = $root + $definedHeaders = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + $arrayLastTableByPath = @{} + + $text = $InputObject -replace "`r`n", "`n" -replace "`r", "`n" + $lines = $text -split "`n" + + for ($i = 0; $i -lt $lines.Count; $i++) { + $rawLine = $lines[$i] + $line = (Get-TomlContentWithoutComment -Line $rawLine).Trim() + if ([string]::IsNullOrWhiteSpace($line)) { + continue + } + + if ($line.StartsWith('[[') -and $line.EndsWith(']]')) { + $header = $line.Substring(2, $line.Length - 4).Trim() + $pathSegments = Split-TomlDottedKey -KeyPath $header + if ($pathSegments.Count -eq 0) { + throw [System.InvalidOperationException]::new('Invalid array-of-tables header.') + } + + # Clear headers from sub-tables of any previous entry in this same array, + # so that each [[arr]] entry may independently define [arr.sub] headers. + $aoKeyPath = Join-TomlKeyPath -Segments $pathSegments + $toRemove = $definedHeaders | Where-Object { $_ -eq $aoKeyPath -or $_.StartsWith("$aoKeyPath.") } + foreach ($h in @($toRemove)) { + $null = $definedHeaders.Remove($h) + } + + $parentSegments = if ($pathSegments.Count -gt 1) { $pathSegments[0..($pathSegments.Count - 2)] } else { @() } + $name = $pathSegments[-1] + $parent = Get-TomlNestedTable -StartTable $root -Segments $parentSegments -ArrayLastTableByPath $arrayLastTableByPath + + if (-not $parent.Contains($name)) { + $parent[$name] = [System.Collections.ArrayList]::new() + } elseif ($parent[$name] -isnot [System.Collections.ArrayList]) { + throw [System.InvalidOperationException]::new("Cannot redefine '$header' as array-of-tables.") + } + + $entry = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::Ordinal) + $null = $parent[$name].Add($entry) + $currentTable = $entry + $arrayLastTableByPath[(Join-TomlKeyPath -Segments $pathSegments)] = $entry + continue + } + + if ($line.StartsWith('[') -and $line.EndsWith(']')) { + $header = $line.Substring(1, $line.Length - 2).Trim() + $pathSegments = Split-TomlDottedKey -KeyPath $header + $headerPath = Join-TomlKeyPath -Segments $pathSegments + if ($definedHeaders.Contains($headerPath)) { + throw [System.InvalidOperationException]::new("The key '$headerPath' is already defined and cannot be redefined.") + } + $null = $definedHeaders.Add($headerPath) + + $parentSegments = if ($pathSegments.Count -gt 1) { $pathSegments[0..($pathSegments.Count - 2)] } else { @() } + $name = $pathSegments[-1] + $parent = Get-TomlNestedTable -StartTable $root -Segments $parentSegments -ArrayLastTableByPath $arrayLastTableByPath + + if ($parent.Contains($name)) { + if ($parent[$name] -is [System.Collections.Specialized.OrderedDictionary]) { + throw [System.InvalidOperationException]::new("The key '$headerPath' is already defined and cannot be redefined.") + } + throw [System.InvalidOperationException]::new("Cannot define table '$headerPath' because the key already exists as a non-table.") + } + + $parent[$name] = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::Ordinal) + $currentTable = $parent[$name] + continue + } + + $eqIndex = $line.IndexOf('=') + if ($eqIndex -lt 1) { + throw [System.InvalidOperationException]::new("Invalid TOML assignment on line $($i + 1): '$rawLine'.") + } + + $keyPath = $line.Substring(0, $eqIndex).Trim() + $valueText = $line.Substring($eqIndex + 1).Trim() + if ($valueText.Length -eq 0) { + throw [System.InvalidOperationException]::new("Missing value for key '$keyPath' on line $($i + 1).") + } + + if ($valueText.StartsWith('"""') -and + ($valueText.Length -lt 6 -or -not $valueText.Substring(3).Contains('"""'))) { + $collector = [System.Text.StringBuilder]::new() + $null = $collector.Append($valueText) + while ($i -lt ($lines.Count - 1)) { + $i++ + $next = $lines[$i] + $null = $collector.Append("`n") + $null = $collector.Append($next) + if ($next.Contains('"""')) { + break + } + } + $valueText = $collector.ToString() + } elseif ($valueText.StartsWith("'''") -and + ($valueText.Length -lt 6 -or -not $valueText.Substring(3).Contains("'''"))) { + $collector = [System.Text.StringBuilder]::new() + $null = $collector.Append($valueText) + while ($i -lt ($lines.Count - 1)) { + $i++ + $next = $lines[$i] + $null = $collector.Append("`n") + $null = $collector.Append($next) + if ($next.Contains("'''")) { + break + } + } + $valueText = $collector.ToString() + } elseif (($valueText.StartsWith('[') -and -not $valueText.EndsWith(']')) -or + ($valueText.StartsWith('{') -and -not $valueText.EndsWith('}'))) { + $collector = [System.Text.StringBuilder]::new() + $null = $collector.Append($valueText) + while ($i -lt ($lines.Count - 1)) { + $i++ + $next = (Get-TomlContentWithoutComment -Line $lines[$i]).Trim() + if ($next.Length -eq 0) { + continue + } + $null = $collector.Append(' ') + $null = $collector.Append($next) + if ($valueText.StartsWith('[') -and $collector.ToString().Trim().EndsWith(']')) { break } + if ($valueText.StartsWith('{') -and $collector.ToString().Trim().EndsWith('}')) { break } + } + $valueText = $collector.ToString() + } + + $keySegments = Split-TomlDottedKey -KeyPath $keyPath + $target = $currentTable + for ($s = 0; $s -lt ($keySegments.Count - 1); $s++) { + $segment = $keySegments[$s] + if (-not $target.Contains($segment)) { + $target[$segment] = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::Ordinal) + } + if ($target[$segment] -isnot [System.Collections.Specialized.OrderedDictionary]) { + throw [System.InvalidOperationException]::new("Cannot define dotted key '$keyPath' because '$segment' is not a table.") + } + $target = $target[$segment] + } + + $leafKey = $keySegments[-1] + if ($target.Contains($leafKey)) { + throw [System.InvalidOperationException]::new("The key '$keyPath' is already defined and cannot be redefined.") + } + + if ($valueText.Trim() -eq '[]') { + $target[$leafKey] = @() + } else { + $target[$leafKey] = ConvertFrom-TomlValue -Value $valueText + } + } + + return $root +} diff --git a/src/functions/private/ConvertFrom-TomlValue.ps1 b/src/functions/private/ConvertFrom-TomlValue.ps1 new file mode 100644 index 0000000..e765c8f --- /dev/null +++ b/src/functions/private/ConvertFrom-TomlValue.ps1 @@ -0,0 +1,48 @@ +function ConvertFrom-TomlValue { + <# + .SYNOPSIS + Converts a TOML value token to a native PowerShell value. + + .DESCRIPTION + Entry point for full value parsing. Trims the token, delegates to + ConvertFrom-TomlParsedValue via a char-level index, and validates that + no trailing content remains after the value. + + .EXAMPLE + ConvertFrom-TomlValue -Value '"hello world"' + # Returns: "hello world" + Parses a basic string token. + + .EXAMPLE + ConvertFrom-TomlValue -Value '[1, 2, 3]' + # Returns: @(1, 2, 3) + Parses an inline array. + + .INPUTS + None. Parameters only. + + .OUTPUTS + [object] — type depends on the TOML value. + #> + [OutputType([object])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Value + ) + + $text = $Value.Trim() + if ($text.Length -eq 0) { + throw [System.InvalidOperationException]::new('Missing TOML value.') + } + + $index = 0 + $parsed = ConvertFrom-TomlParsedValue -Source $text -Index ([ref]$index) + Skip-TomlWhitespace -Source $text -Index ([ref]$index) + if ($index -lt $text.Length) { + throw [System.InvalidOperationException]::new("Unexpected trailing token in TOML value: '$($text.Substring($index))'.") + } + + return $parsed +} diff --git a/src/functions/private/ConvertTo-TomlArrayObject.ps1 b/src/functions/private/ConvertTo-TomlArrayObject.ps1 new file mode 100644 index 0000000..da288bc --- /dev/null +++ b/src/functions/private/ConvertTo-TomlArrayObject.ps1 @@ -0,0 +1,35 @@ +function ConvertTo-TomlArrayObject { + <# + .SYNOPSIS + Normalizes a PowerShell enumerable to a TOML-compatible array. + + .DESCRIPTION + Iterates each item of the enumerable and passes it through + ConvertTo-TomlTableObject so that dictionaries, PSCustomObjects, and + nested arrays are each normalized recursively. Returns an object array. + + .EXAMPLE + ConvertTo-TomlArrayObject -Value @(1, 'two', $true) + # Returns: @(1, "two", $true) + Normalizes a mixed scalar array. + + .INPUTS + None. Parameters only. + + .OUTPUTS + [object[]] + #> + [OutputType([object[]])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Collections.IEnumerable] $Value + ) + + $items = [System.Collections.Generic.List[object]]::new() + foreach ($item in $Value) { + $items.Add((ConvertTo-TomlTableObject -Value $item)) + } + + return , $items.ToArray() +} diff --git a/src/functions/private/ConvertTo-TomlTableObject.ps1 b/src/functions/private/ConvertTo-TomlTableObject.ps1 new file mode 100644 index 0000000..06ea9ab --- /dev/null +++ b/src/functions/private/ConvertTo-TomlTableObject.ps1 @@ -0,0 +1,68 @@ +function ConvertTo-TomlTableObject { + <# + .SYNOPSIS + Normalizes input data to TOML-compatible objects. + + .DESCRIPTION + Converts any PowerShell value into a form the serializer can handle: + IDictionary → OrderedDictionary, PSCustomObject → OrderedDictionary, + IEnumerable (non-string) → object[] via ConvertTo-TomlArrayObject, + scalars → unchanged. Throws if the value is null. + + .EXAMPLE + ConvertTo-TomlTableObject -Value @{ a = 1; b = 'two' } + # Returns: [OrderedDictionary] { a = 1, b = "two" } + Converts a regular hashtable to an ordered dictionary. + + .INPUTS + None. Parameters only. + + .OUTPUTS + [object] — OrderedDictionary, object[], or the original scalar. + #> + [OutputType([object])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowNull()] + [object] $Value + ) + + if ($null -eq $Value) { + throw [System.ArgumentNullException]::new('Value', 'TOML does not support null values.') + } + + if ($Value -is [System.Collections.Specialized.OrderedDictionary]) { + $result = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::Ordinal) + foreach ($key in $Value.Keys) { + $result[$key] = ConvertTo-TomlTableObject -Value $Value[$key] + } + return $result + } + + if ($Value -is [System.Collections.IDictionary]) { + $result = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::Ordinal) + foreach ($key in $Value.Keys) { + $result[$key.ToString()] = ConvertTo-TomlTableObject -Value $Value[$key] + } + return $result + } + + if ($Value -is [System.Management.Automation.PSCustomObject] -or + ($Value -is [psobject] -and $Value -isnot [string] -and $Value -isnot [System.Collections.IEnumerable])) { + $result = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::Ordinal) + foreach ($prop in $Value.PSObject.Properties) { + if ($prop.MemberType -notin 'NoteProperty', 'Property', 'ScriptProperty') { + continue + } + $result[$prop.Name] = ConvertTo-TomlTableObject -Value $prop.Value + } + return $result + } + + if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { + return ConvertTo-TomlArrayObject -Value $Value + } + + return $Value +} diff --git a/src/functions/private/ConvertTo-TomlValue.ps1 b/src/functions/private/ConvertTo-TomlValue.ps1 new file mode 100644 index 0000000..eed39ec --- /dev/null +++ b/src/functions/private/ConvertTo-TomlValue.ps1 @@ -0,0 +1,106 @@ +function ConvertTo-TomlValue { + <# + .SYNOPSIS + Converts a normalized value to a TOML literal. + + .DESCRIPTION + Serializes a single normalized PowerShell value to its TOML literal + representation: strings are quoted and escaped, booleans become true/false, + integers and floats use invariant-culture formatting, date/time types map to + their TOML forms, arrays become inline [ ... ], and dictionaries become + inline { ... }. Throws for null or unserializable types. + + .EXAMPLE + ConvertTo-TomlValue -Value 'hello"world' + # Returns: '"hello\"world"' + Serializes a string containing a double quote. + + .EXAMPLE + ConvertTo-TomlValue -Value ([System.DateTimeOffset]::Parse('1979-05-27T07:32:00Z')) + # Returns: '1979-05-27T07:32:00+00:00' + Serializes an offset date-time. + + .INPUTS + None. Parameters only. + + .OUTPUTS + [string] + #> + [OutputType([string])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowNull()] + [object] $Value + ) + + if ($null -eq $Value) { + throw [System.InvalidOperationException]::new('TOML does not support null values.') + } + + $culture = [System.Globalization.CultureInfo]::InvariantCulture + + if ($Value -is [string]) { + $escaped = $Value.Replace('\', '\\').Replace('"', '\"').Replace("`t", '\t').Replace("`r", '\r').Replace("`n", '\n') + return '"' + $escaped + '"' + } + + if ($Value -is [bool]) { + if ($Value) { return 'true' } + return 'false' + } + + if ($Value -is [byte] -or $Value -is [sbyte] -or $Value -is [int16] -or + $Value -is [uint16] -or $Value -is [int32] -or $Value -is [uint32] -or + $Value -is [int64] -or $Value -is [uint64]) { + return [string]::Format($culture, '{0}', $Value) + } + + if ($Value -is [single] -or $Value -is [double] -or $Value -is [decimal]) { + $d = [double]$Value + if ([double]::IsNaN($d)) { return 'nan' } + if ([double]::IsPositiveInfinity($d)) { return 'inf' } + if ([double]::IsNegativeInfinity($d)) { return '-inf' } + return $d.ToString('R', $culture) + } + + if ($Value -is [System.DateTimeOffset]) { + return $Value.ToString('yyyy-MM-ddTHH:mm:ssK', $culture) + } + + if ($Value -is [System.DateTime]) { + $dt = [System.DateTime]::SpecifyKind($Value, [System.DateTimeKind]::Unspecified) + if ($dt.TimeOfDay -eq [System.TimeSpan]::Zero) { + return $dt.ToString('yyyy-MM-dd', $culture) + } + return $dt.ToString('yyyy-MM-ddTHH:mm:ss', $culture) + } + + if ($Value -is [System.TimeSpan]) { + $base = '{0:00}:{1:00}:{2:00}' -f $Value.Hours, $Value.Minutes, $Value.Seconds + if ($Value.Milliseconds -gt 0) { + return $base + '.' + $Value.Milliseconds.ToString('000', $culture) + } + return $base + } + + if ($Value -is [object[]]) { + $parts = [System.Collections.Generic.List[string]]::new() + foreach ($item in $Value) { + $parts.Add((ConvertTo-TomlValue -Value $item)) + } + return '[ ' + ($parts -join ', ') + ' ]' + } + + if ($Value -is [System.Collections.Specialized.OrderedDictionary]) { + $parts = [System.Collections.Generic.List[string]]::new() + foreach ($key in $Value.Keys) { + $parts.Add("$(Format-TomlKey -Key $key) = $(ConvertTo-TomlValue -Value $Value[$key])") + } + return '{ ' + ($parts -join ', ') + ' }' + } + + throw [System.InvalidOperationException]::new( + "Cannot serialize value of type '$($Value.GetType().FullName)' to TOML." + ) +} diff --git a/src/functions/private/Format-TomlKey.ps1 b/src/functions/private/Format-TomlKey.ps1 new file mode 100644 index 0000000..afacffb --- /dev/null +++ b/src/functions/private/Format-TomlKey.ps1 @@ -0,0 +1,40 @@ +function Format-TomlKey { + <# + .SYNOPSIS + Formats a key for TOML output. + + .DESCRIPTION + Returns the key unchanged when it consists entirely of alphanumeric characters, + hyphens, and underscores (bare key). Otherwise wraps it in double quotes and + escapes internal backslashes and double quotes. + + .EXAMPLE + Format-TomlKey -Key 'server-host' + # Returns: 'server-host' + A bare key is returned as-is. + + .EXAMPLE + Format-TomlKey -Key 'my key' + # Returns: '"my key"' + A key with a space is wrapped in double quotes. + + .INPUTS + None. Parameters only. + + .OUTPUTS + [string] + #> + [OutputType([string])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Key + ) + + if ($Key -match '^[A-Za-z0-9_-]+$') { + return $Key + } + + $escaped = $Key.Replace('\', '\\').Replace('"', '\"') + return '"' + $escaped + '"' +} diff --git a/src/functions/private/Get-TomlBareToken.ps1 b/src/functions/private/Get-TomlBareToken.ps1 new file mode 100644 index 0000000..0f2fc6b --- /dev/null +++ b/src/functions/private/Get-TomlBareToken.ps1 @@ -0,0 +1,50 @@ +function Get-TomlBareToken { + <# + .SYNOPSIS + Reads a bare TOML token from source. + + .DESCRIPTION + Advances $Index through $Source collecting characters until a delimiter + (comma, closing bracket, or closing brace) is encountered. When -StopAtEquals + is set, also stops at the equals sign, enabling bare key extraction in inline + tables. Returns the trimmed token. + + .EXAMPLE + $src = 'true, 42'; $i = 0 + Get-TomlBareToken -Source $src -Index ([ref]$i) + # Returns: 'true' + Reads a bare token up to the comma delimiter. + + .INPUTS + None. Parameters only. + + .OUTPUTS + [string] + #> + [OutputType([string])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Source, + + [Parameter(Mandatory)] + [ref] $Index, + + [Parameter()] + [switch] $StopAtEquals + ) + + $start = $Index.Value + while ($Index.Value -lt $Source.Length) { + $ch = $Source[$Index.Value] + if ($ch -in ',', ']', '}') { + break + } + if ($StopAtEquals -and $ch -eq '=') { + break + } + $Index.Value++ + } + + return $Source.Substring($start, $Index.Value - $start).Trim() +} diff --git a/src/functions/private/Get-TomlContentWithoutComment.ps1 b/src/functions/private/Get-TomlContentWithoutComment.ps1 new file mode 100644 index 0000000..5af75ff --- /dev/null +++ b/src/functions/private/Get-TomlContentWithoutComment.ps1 @@ -0,0 +1,80 @@ +function Get-TomlContentWithoutComment { + <# + .SYNOPSIS + Removes TOML inline comments while preserving quoted text. + + .DESCRIPTION + Scans a TOML source line character-by-character, tracking basic-string and + literal-string context to avoid treating # inside a string as a comment + delimiter. Returns the content up to (but not including) the first unquoted #. + + .EXAMPLE + Get-TomlContentWithoutComment -Line 'port = 5432 # the DB port' + # Returns: 'port = 5432 ' + Strips the inline comment from a key-value line. + + .INPUTS + None. Parameters only. + + .OUTPUTS + [string] + #> + [OutputType([string])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Line + ) + + $sb = [System.Text.StringBuilder]::new() + $inBasic = $false + $inLiteral = $false + $escape = $false + for ($i = 0; $i -lt $Line.Length; $i++) { + $ch = $Line[$i] + + if ($escape) { + $null = $sb.Append($ch) + $escape = $false + continue + } + + if ($inBasic) { + $null = $sb.Append($ch) + if ($ch -eq '\') { + $escape = $true + } elseif ($ch -eq '"') { + $inBasic = $false + } + continue + } + + if ($inLiteral) { + $null = $sb.Append($ch) + if ($ch -eq '''') { + $inLiteral = $false + } + continue + } + + if ($ch -eq '"') { + $inBasic = $true + $null = $sb.Append($ch) + continue + } + if ($ch -eq '''') { + $inLiteral = $true + $null = $sb.Append($ch) + continue + } + + if ($ch -eq '#') { + break + } + + $null = $sb.Append($ch) + } + + return $sb.ToString() +} diff --git a/src/functions/private/Get-TomlNestedTable.ps1 b/src/functions/private/Get-TomlNestedTable.ps1 new file mode 100644 index 0000000..a630ff7 --- /dev/null +++ b/src/functions/private/Get-TomlNestedTable.ps1 @@ -0,0 +1,67 @@ +function Get-TomlNestedTable { + <# + .SYNOPSIS + Resolves or creates nested table path segments. + + .DESCRIPTION + Walks the path segments into the root ordered dictionary, creating + intermediate tables when absent. When a segment resolves to an + ArrayList (array-of-tables), the last entry of the array is used + as the current table context, matching TOML's semantics for nested + table headers inside [[arr]] blocks. + + .EXAMPLE + $root = [ordered]@{} + Get-TomlNestedTable -StartTable $root -Segments @('a', 'b') -ArrayLastTableByPath @{} + # Returns: the OrderedDictionary at root['a']['b'], creating it if needed. + Resolves a two-level path, creating intermediate tables. + + .INPUTS + None. Parameters only. + + .OUTPUTS + [System.Collections.Specialized.OrderedDictionary] + #> + [OutputType([System.Collections.Specialized.OrderedDictionary])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Collections.Specialized.OrderedDictionary] $StartTable, + + [Parameter()] + [AllowEmptyCollection()] + [string[]] $Segments, + + [Parameter(Mandatory)] + [hashtable] $ArrayLastTableByPath + ) + + $table = $StartTable + $pathParts = [System.Collections.Generic.List[string]]::new() + foreach ($segment in $Segments) { + $pathParts.Add($segment) + $path = Join-TomlKeyPath -Segments $pathParts.ToArray() + + if (-not $table.Contains($segment)) { + $table[$segment] = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::Ordinal) + } + + $candidate = $table[$segment] + if ($candidate -is [System.Collections.ArrayList]) { + if ($candidate.Count -eq 0) { + throw [System.InvalidOperationException]::new("Array-of-tables '$path' has no current item.") + } + $table = $candidate[-1] + $ArrayLastTableByPath[$path] = $table + continue + } + + if ($candidate -isnot [System.Collections.Specialized.OrderedDictionary]) { + throw [System.InvalidOperationException]::new("Cannot define table '$path' because '$segment' is already a non-table value.") + } + + $table = $candidate + } + + return $table +} diff --git a/src/functions/private/Join-TomlKeyPath.ps1 b/src/functions/private/Join-TomlKeyPath.ps1 new file mode 100644 index 0000000..5154f46 --- /dev/null +++ b/src/functions/private/Join-TomlKeyPath.ps1 @@ -0,0 +1,35 @@ +function Join-TomlKeyPath { + <# + .SYNOPSIS + Joins key path segments into a dotted path. + + .DESCRIPTION + Concatenates an array of key segments with a dot separator. Returns an + empty string for a null or empty segment array. Used to build the canonical + path string for header-deduplication tracking. + + .EXAMPLE + Join-TomlKeyPath -Segments @('a', 'b', 'c') + # Returns: 'a.b.c' + Joins three segments into a dotted path. + + .INPUTS + None. Parameters only. + + .OUTPUTS + [string] + #> + [OutputType([string])] + [CmdletBinding()] + param( + [Parameter()] + [AllowEmptyCollection()] + [string[]] $Segments + ) + + if ($null -eq $Segments) { + return '' + } + + return ($Segments -join '.') +} diff --git a/src/functions/private/Skip-TomlWhitespace.ps1 b/src/functions/private/Skip-TomlWhitespace.ps1 new file mode 100644 index 0000000..cf3a352 --- /dev/null +++ b/src/functions/private/Skip-TomlWhitespace.ps1 @@ -0,0 +1,36 @@ +function Skip-TomlWhitespace { + <# + .SYNOPSIS + Advances an index past whitespace in a TOML source string. + + .DESCRIPTION + Increments $Index.Value while the character at that position in $Source is + classified as whitespace by [char]::IsWhiteSpace. Used to position the index + before the next meaningful character during value parsing. + + .EXAMPLE + $src = ' 42'; $i = 0 + Skip-TomlWhitespace -Source $src -Index ([ref]$i) + # $i is now 3, pointing at '4' + Skips three leading spaces. + + .INPUTS + None. Parameters only. + + .OUTPUTS + [void] + #> + [OutputType([void])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Source, + + [Parameter(Mandatory)] + [ref] $Index + ) + + while ($Index.Value -lt $Source.Length -and [char]::IsWhiteSpace($Source[$Index.Value])) { + $Index.Value++ + } +} diff --git a/src/functions/private/Split-TomlDottedKey.ps1 b/src/functions/private/Split-TomlDottedKey.ps1 new file mode 100644 index 0000000..68a5cb8 --- /dev/null +++ b/src/functions/private/Split-TomlDottedKey.ps1 @@ -0,0 +1,108 @@ +function Split-TomlDottedKey { + <# + .SYNOPSIS + Splits a TOML dotted key into normalized key segments. + + .DESCRIPTION + Tokenizes a TOML key path respecting basic-string ("...") and literal-string + ('...') quoting, so dots inside quoted segments are not treated as separators. + Quoted segments are decoded (basic) or used as-is (literal). Bare segments are + returned unchanged. Throws for empty segments. + + .EXAMPLE + Split-TomlDottedKey -KeyPath 'a.b.c' + # Returns: @('a', 'b', 'c') + Splits a simple dotted key. + + .EXAMPLE + Split-TomlDottedKey -KeyPath '"my.key".sub' + # Returns: @('my.key', 'sub') + Dot inside a quoted segment is treated as literal. + + .INPUTS + None. Parameters only. + + .OUTPUTS + [string[]] + #> + [OutputType([string[]])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $KeyPath + ) + + $segments = [System.Collections.Generic.List[string]]::new() + $sb = [System.Text.StringBuilder]::new() + $inBasic = $false + $inLiteral = $false + $escape = $false + + for ($i = 0; $i -lt $KeyPath.Length; $i++) { + $ch = $KeyPath[$i] + if ($escape) { + $null = $sb.Append($ch) + $escape = $false + continue + } + + if ($inBasic) { + $null = $sb.Append($ch) + if ($ch -eq '\') { + $escape = $true + } elseif ($ch -eq '"') { + $inBasic = $false + } + continue + } + + if ($inLiteral) { + $null = $sb.Append($ch) + if ($ch -eq '''') { + $inLiteral = $false + } + continue + } + + if ($ch -eq '"') { + $inBasic = $true + $null = $sb.Append($ch) + continue + } + if ($ch -eq '''') { + $inLiteral = $true + $null = $sb.Append($ch) + continue + } + + if ($ch -eq '.') { + $segments.Add($sb.ToString().Trim()) + $null = $sb.Clear() + continue + } + + $null = $sb.Append($ch) + } + + $segments.Add($sb.ToString().Trim()) + $normalized = [System.Collections.Generic.List[string]]::new() + foreach ($segment in $segments) { + if ($segment.Length -eq 0) { + throw [System.InvalidOperationException]::new("Invalid dotted key path '$KeyPath'.") + } + + if ($segment.StartsWith('"') -and $segment.EndsWith('"')) { + $normalized.Add((ConvertFrom-TomlValue -Value $segment)) + continue + } + + if ($segment.StartsWith("'") -and $segment.EndsWith("'")) { + $normalized.Add((ConvertFrom-TomlValue -Value $segment)) + continue + } + + $normalized.Add($segment) + } + + return , $normalized.ToArray() +} diff --git a/src/functions/private/Test-TomlEndsWithDoubleNewLine.ps1 b/src/functions/private/Test-TomlEndsWithDoubleNewLine.ps1 new file mode 100644 index 0000000..1de98de --- /dev/null +++ b/src/functions/private/Test-TomlEndsWithDoubleNewLine.ps1 @@ -0,0 +1,38 @@ +function Test-TomlEndsWithDoubleNewLine { + <# + .SYNOPSIS + Tests whether a StringBuilder ends with two LF characters. + + .DESCRIPTION + Inspects the last two characters of the given StringBuilder to determine + whether the buffer already ends with a blank line (two consecutive LF + characters). Used by the serializer to avoid inserting extra blank lines + between sections. + + .EXAMPLE + $sb = [System.Text.StringBuilder]::new("line1`n`n") + Test-TomlEndsWithDoubleNewLine -StringBuilder $sb + # Returns: $true + Detects a trailing blank line. + + .INPUTS + None. Parameters only. + + .OUTPUTS + [bool] + #> + [OutputType([bool])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Text.StringBuilder] $StringBuilder + ) + + if ($StringBuilder.Length -lt 2) { + return $false + } + + $last = $StringBuilder[$StringBuilder.Length - 1] + $prev = $StringBuilder[$StringBuilder.Length - 2] + return ($prev -eq "`n" -and $last -eq "`n") +} diff --git a/src/functions/private/Test-TomlTableArray.ps1 b/src/functions/private/Test-TomlTableArray.ps1 new file mode 100644 index 0000000..164ddc5 --- /dev/null +++ b/src/functions/private/Test-TomlTableArray.ps1 @@ -0,0 +1,63 @@ +function Test-TomlTableArray { + <# + .SYNOPSIS + Tests whether a value is an array of TOML tables. + + .DESCRIPTION + Returns $true when the value is a non-empty IEnumerable (but not a string + or IDictionary) whose every element is an OrderedDictionary. This is used by + the serializer to distinguish TOML arrays-of-tables from regular scalar arrays. + + .EXAMPLE + $aot = [System.Collections.ArrayList]@( + [ordered]@{ name = 'a' }, + [ordered]@{ name = 'b' } + ) + Test-TomlTableArray -Value $aot + # Returns: $true + A list of ordered dictionaries is an array of tables. + + .EXAMPLE + Test-TomlTableArray -Value @(1, 2, 3) + # Returns: $false + A scalar array is not an array of tables. + + .INPUTS + None. Parameters only. + + .OUTPUTS + [bool] + #> + [OutputType([bool])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object] $Value + ) + + if ($null -eq $Value) { + return $false + } + + if ($Value -is [string]) { + return $false + } + + if ($Value -is [System.Collections.IDictionary]) { + return $false + } + + if ($Value -isnot [System.Collections.IEnumerable]) { + return $false + } + + $hasAny = $false + foreach ($item in $Value) { + $hasAny = $true + if ($item -isnot [System.Collections.Specialized.OrderedDictionary]) { + return $false + } + } + + return $hasAny +} diff --git a/src/functions/public/ConvertFrom-Toml.ps1 b/src/functions/public/ConvertFrom-Toml.ps1 new file mode 100644 index 0000000..0f211da --- /dev/null +++ b/src/functions/public/ConvertFrom-Toml.ps1 @@ -0,0 +1,70 @@ +function ConvertFrom-Toml { + <# + .SYNOPSIS + Converts TOML text to a TomlDocument. + + .DESCRIPTION + Parses TOML-formatted text into a TomlDocument object with OrderedDictionary + semantics and TOML-compatible scalar mappings. + + Supported scalar types and their PowerShell equivalents: + - String → [string] + - Integer → [long] + - Float → [double] + - Boolean → [bool] + - Offset date-time → [System.DateTimeOffset] + - Local date-time → [System.DateTime] (Kind = Unspecified) + - Local date → [System.DateTime] (time = 00:00:00) + - Local time → [System.TimeSpan] + - Array → [object[]] + - Table → [System.Collections.Specialized.OrderedDictionary] + + .EXAMPLE + $doc = ConvertFrom-Toml -InputObject @' + [server] + host = "localhost" + port = 8080 + '@ + $doc.Data.server.host # "localhost" + + Parses an inline TOML string and accesses a nested value. + + .EXAMPLE + 'title = "My Doc"' | ConvertFrom-Toml + + Converts a TOML string from the pipeline. + + .INPUTS + [string] + + .OUTPUTS + [TomlDocument] + + .NOTES + Throws [System.InvalidOperationException] for any TOML syntax error, + duplicate key, or structural violation. + + .LINK + https://psmodule.io/Toml/Functions/ConvertFrom-Toml + #> + [OutputType([TomlDocument])] + [CmdletBinding()] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [ValidateNotNullOrEmpty()] + [string] $InputObject + ) + + process { + Write-Verbose "Parsing TOML string ($($InputObject.Length) character(s))." + try { + $data = ConvertFrom-TomlTable -InputObject $InputObject + return [TomlDocument]::new($data) + } catch { + throw [System.InvalidOperationException]::new( + "Failed to parse TOML: $($_.Exception.Message)", + $_.Exception + ) + } + } +} diff --git a/src/functions/public/ConvertTo-Toml.ps1 b/src/functions/public/ConvertTo-Toml.ps1 new file mode 100644 index 0000000..a4b3970 --- /dev/null +++ b/src/functions/public/ConvertTo-Toml.ps1 @@ -0,0 +1,78 @@ +function ConvertTo-Toml { + <# + .SYNOPSIS + Converts a PowerShell object graph to TOML text. + + .DESCRIPTION + Serializes a PowerShell object — TomlDocument, hashtable, ordered dictionary, + or PSCustomObject — into a TOML string. Nested dictionaries become TOML tables; + arrays of dictionaries become TOML arrays of tables. + + Supported PowerShell → TOML type mappings: + - [string] → basic string (special characters escaped) + - [bool] → true / false + - Integer types → TOML integer + - [double] / [float] → TOML float (inf, -inf, nan handled) + - [System.DateTimeOffset] → offset date-time + - [System.DateTime] → local date or local date-time + - [System.TimeSpan] → local time + - Scalar [object[]] → inline TOML array + - IDictionary → TOML table + - Array of IDictionary → TOML array of tables + + .EXAMPLE + $toml = ConvertTo-Toml -InputObject ([ordered]@{ + title = 'My App' + server = [ordered]@{ host = 'localhost'; port = 8080 } + }) + Write-Host $toml + + Serializes a nested ordered dictionary to TOML text. + + .EXAMPLE + Import-Toml -Path 'config.toml' | ConvertTo-Toml + + Round-trips a TOML file back to TOML text. + + .INPUTS + [object] — pipeline input supported. + + .OUTPUTS + [string] + + .NOTES + Throws when the object graph contains null values (TOML has no null type), + or types that cannot be serialized such as script blocks or COM objects. + Key order is preserved when the input uses an ordered dictionary. + + .LINK + https://psmodule.io/Toml/Functions/ConvertTo-Toml + #> + [OutputType([string])] + [CmdletBinding()] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [ValidateNotNull()] + [object] $InputObject + ) + + process { + Write-Verbose "Serializing object graph to TOML." + $source = if ($InputObject -is [TomlDocument]) { + $InputObject.Data + } else { + $InputObject + } + + $root = ConvertTo-TomlTableObject -Value $source + if ($root -isnot [System.Collections.Specialized.OrderedDictionary]) { + throw [System.InvalidOperationException]::new( + 'TOML documents must be dictionaries/objects at the root.' + ) + } + + $sb = [System.Text.StringBuilder]::new() + Add-TomlTableText -StringBuilder $sb -Table $root -Path '' -EmitHeader:$false + return $sb.ToString().TrimEnd() + } +} diff --git a/src/functions/public/Export-Toml.ps1 b/src/functions/public/Export-Toml.ps1 new file mode 100644 index 0000000..0fba427 --- /dev/null +++ b/src/functions/public/Export-Toml.ps1 @@ -0,0 +1,77 @@ +function Export-Toml { + <# + .SYNOPSIS + Serializes an object graph to a TOML file. + + .DESCRIPTION + Converts the input object to a TOML string using ConvertTo-Toml and + writes it to the specified file path. Intermediate directories are + created when they do not exist. + + The file is written in UTF-8 encoding without a BOM, which is the + recommended encoding for TOML files. + + .EXAMPLE + $config = [ordered]@{ + title = 'My App' + server = [ordered]@{ host = 'localhost'; port = 8080 } + } + Export-Toml -InputObject $config -Path 'config.toml' + + Writes a TOML file with a string key and a nested table. + + .EXAMPLE + Import-Toml 'input.toml' | Export-Toml -Path 'output.toml' + + Round-trips a TOML file: parse then re-serialize. + + .INPUTS + [object] — pipeline input supported. + + .OUTPUTS + [void] — nothing is written to the output stream. + + .NOTES + Throws when: + - the object graph contains null values (TOML has no null) + - the object graph contains types that cannot be serialized to TOML + - the file cannot be created or written + + .LINK + https://psmodule.io/Toml/Functions/Export-Toml + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The object to serialize. Accepts TomlDocument, hashtable, + # ordered dictionary, or PSCustomObject. + [Parameter(Mandatory, ValueFromPipeline)] + [ValidateNotNull()] + [object] $InputObject, + + # Destination file path. Created (including parent directories) if absent. + [Parameter(Mandatory, Position = 0)] + [ValidateNotNullOrEmpty()] + [string] $Path + ) + + process { + Write-Verbose "Serializing object graph to TOML." + $tomlString = ConvertTo-Toml -InputObject $InputObject + + $resolvedPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) + $parent = [System.IO.Path]::GetDirectoryName($resolvedPath) + + if (-not [System.IO.Directory]::Exists($parent)) { + $null = [System.IO.Directory]::CreateDirectory($parent) + } + + if ($PSCmdlet.ShouldProcess($resolvedPath, 'Write TOML file')) { + Write-Verbose "Writing $($tomlString.Length) character(s) to: $resolvedPath" + [System.IO.File]::WriteAllText( + $resolvedPath, + $tomlString, + [System.Text.UTF8Encoding]::new($false) # UTF-8 without BOM + ) + } + } +} diff --git a/src/functions/public/Import-Toml.ps1 b/src/functions/public/Import-Toml.ps1 new file mode 100644 index 0000000..a1581f2 --- /dev/null +++ b/src/functions/public/Import-Toml.ps1 @@ -0,0 +1,60 @@ +function Import-Toml { + <# + .SYNOPSIS + Imports and parses a TOML file into a TomlDocument. + + .DESCRIPTION + Reads the content of a TOML file from disk and parses it using + ConvertFrom-Toml. The returned TomlDocument includes the resolved + absolute file path in its FilePath property. + + UTF-8 encoding (with or without BOM) is used when reading the file. + + .EXAMPLE + $config = Import-Toml -Path 'config.toml' + $config.Data.database.host + + Reads config.toml and returns a TomlDocument. The nested value is + accessed via the Data property. + + .EXAMPLE + Import-Toml -Path 'settings.toml' | ForEach-Object { $_.Data } + + Pipes the document and inspects the data dictionary. + + .INPUTS + [string] — pipeline input supported for the Path parameter. + + .OUTPUTS + [TomlDocument] + + .NOTES + Throws when: + - the file does not exist + - the file cannot be read + - the file content is not valid TOML 1.0.0 + + .LINK + https://psmodule.io/Toml/Functions/Import-Toml + #> + [OutputType([TomlDocument])] + [CmdletBinding()] + param( + # Path to the TOML file to import. Accepts relative and absolute paths. + [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [ValidateNotNullOrEmpty()] + [string] $Path + ) + + process { + $resolvedPath = Resolve-Path -Path $Path -ErrorAction Stop + Write-Verbose "Importing TOML file: $($resolvedPath.ProviderPath)" + + $content = [System.IO.File]::ReadAllText($resolvedPath.ProviderPath) + Write-Verbose "Read $($content.Length) character(s). Parsing..." + + $doc = ConvertFrom-Toml -InputObject $content + $doc.FilePath = $resolvedPath.ProviderPath + $doc + } +} diff --git a/src/functions/public/Toml/Get-Toml.ps1 b/src/functions/public/Toml/Get-Toml.ps1 deleted file mode 100644 index beb853e..0000000 --- a/src/functions/public/Toml/Get-Toml.ps1 +++ /dev/null @@ -1,36 +0,0 @@ -function Get-Toml { - <# - .SYNOPSIS - Get TOML content from a file. - - .DESCRIPTION - Reads a TOML file from disk and returns its raw text content as a single string. - - .EXAMPLE - Get-Toml -Path '.\settings.toml' - Returns the TOML content from settings.toml as a string. - - .INPUTS - [string] - - .OUTPUTS - [string] - - .NOTES - This command returns raw TOML text and does not parse TOML into objects. - - .LINK - https://github.com/PSModule/Toml - #> - [OutputType([string])] - [CmdletBinding()] - param( - # Path to the TOML file. - [Parameter(Mandatory, Position = 0)] - [ValidateNotNullOrEmpty()] - [string] $Path - ) - - $resolvedPath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($Path) - [System.IO.File]::ReadAllText($resolvedPath) -} diff --git a/src/functions/public/Toml/Toml.md b/src/functions/public/Toml/Toml.md deleted file mode 100644 index 01def8c..0000000 --- a/src/functions/public/Toml/Toml.md +++ /dev/null @@ -1,7 +0,0 @@ -# Toml - -Public commands for reading TOML content from files. - -## Commands - -- `Get-Toml`: Reads TOML file content and returns it as raw text. diff --git a/tests/BeforeAll.ps1 b/tests/BeforeAll.ps1 new file mode 100644 index 0000000..1981541 --- /dev/null +++ b/tests/BeforeAll.ps1 @@ -0,0 +1,3 @@ +# Shared setup executed once before the test matrix. +# The module is imported by the CI runner before tests are invoked. +# Add any session-wide test fixtures or environment configuration here. diff --git a/tests/Get-Toml.Tests.ps1 b/tests/Get-Toml.Tests.ps1 deleted file mode 100644 index 2b29698..0000000 --- a/tests/Get-Toml.Tests.ps1 +++ /dev/null @@ -1,27 +0,0 @@ -#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } - -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSReviewUnusedParameter', '', - Justification = 'Required for Pester tests' -)] -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseDeclaredVarsMoreThanAssignments', '', - Justification = 'Required for Pester tests' -)] -[CmdletBinding()] -param() - -Describe 'Get-Toml' { - It 'Returns raw TOML content from file' { - $path = Join-Path -Path $TestDrive -ChildPath 'sample.toml' - $expected = @' -[database] -host = "localhost" -port = 5432 -'@ - - Set-Content -LiteralPath $path -Value $expected -NoNewline - - Get-Toml -Path $path | Should -BeExactly $expected - } -} diff --git a/tests/Toml.Tests.ps1 b/tests/Toml.Tests.ps1 new file mode 100644 index 0000000..1cbbcc3 --- /dev/null +++ b/tests/Toml.Tests.ps1 @@ -0,0 +1,804 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Required for Pester tests')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Required for Pester tests')] +[CmdletBinding()] +param() + +Describe 'Toml' { + BeforeAll { + $dataDir = Join-Path $PSScriptRoot 'data' + } + + Context 'Module' { + It 'exposes the expected public commands' { + $commands = Get-Command -Module Toml | Select-Object -ExpandProperty Name | Sort-Object + $commands | Should -Contain 'ConvertFrom-Toml' + $commands | Should -Contain 'ConvertTo-Toml' + $commands | Should -Contain 'Import-Toml' + $commands | Should -Contain 'Export-Toml' + } + } + + Describe 'ConvertFrom-Toml' { + + Context 'Return type' { + It 'returns a TomlDocument for a minimal document' { + $result = ConvertFrom-Toml -InputObject 'key = "value"' + $result.GetType().Name | Should -Be 'TomlDocument' + } + + It 'Data property is an OrderedDictionary' { + $result = ConvertFrom-Toml -InputObject 'key = "value"' + $result.Data | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + } + + It 'FilePath is null when parsed from string' { + $result = ConvertFrom-Toml -InputObject 'key = "value"' + $result.FilePath | Should -BeNullOrEmpty + } + + It 'accepts pipeline input' { + $result = 'key = "value"' | ConvertFrom-Toml + $result.GetType().Name | Should -Be 'TomlDocument' + } + } + + Context 'Strings' { + It 'parses a basic string' { + $result = ConvertFrom-Toml -InputObject 'key = "hello"' + $result.Data['key'] | Should -Be 'hello' + } + + It 'parses a literal string' { + $result = ConvertFrom-Toml -InputObject "key = 'C:\path'" + $result.Data['key'] | Should -Be 'C:\path' + } + + It 'parses escape sequences in a basic string' { + $result = ConvertFrom-Toml -InputObject 'key = "tab\there"' + $result.Data['key'] | Should -Be "tab`there" + } + + It 'parses escaped quotes in a basic string' { + $result = ConvertFrom-Toml -InputObject 'key = "say \"hi\""' + $result.Data['key'] | Should -Be 'say "hi"' + } + + It 'parses a Unicode \uXXXX escape sequence' { + $result = ConvertFrom-Toml -InputObject 'key = "\u03B1"' + $result.Data['key'] | Should -Be ([char]0x03B1).ToString() + } + + It 'parses a multi-line basic string' { + $toml = "key = `"`"`"`nline one`nline two`n`"`"`"" + $result = ConvertFrom-Toml -InputObject $toml + $result.Data['key'] | Should -Match 'line one' + $result.Data['key'] | Should -Match 'line two' + } + + It 'parses a multi-line literal string' { + $toml = "key = '''" + [System.Environment]::NewLine + "raw\nno escape" + [System.Environment]::NewLine + "'''" + $result = ConvertFrom-Toml -InputObject $toml + $result.Data['key'] | Should -Match 'raw\\nno escape' + } + + It 'parses an empty string' { + $result = ConvertFrom-Toml -InputObject 'key = ""' + $result.Data['key'] | Should -Be '' + } + + It 'parses strings fixture file' { + $result = ConvertFrom-Toml -InputObject (Get-Content (Join-Path $dataDir 'strings.toml') -Raw) + $result.Data['literal'] | Should -Be 'C:\Users\nodejs\templates' + $result.Data['empty'] | Should -Be '' + } + } + + Context 'Integers' { + It 'parses a decimal integer as [long]' { + $result = ConvertFrom-Toml -InputObject 'n = 42' + $result.Data['n'] | Should -Be 42 + $result.Data['n'] | Should -BeOfType [long] + } + + It 'parses a negative integer' { + $result = ConvertFrom-Toml -InputObject 'n = -17' + $result.Data['n'] | Should -Be -17 + } + + It 'parses zero' { + $result = ConvertFrom-Toml -InputObject 'n = 0' + $result.Data['n'] | Should -Be 0 + } + + It 'parses an integer with underscore separators' { + $result = ConvertFrom-Toml -InputObject 'n = 1_000_000' + $result.Data['n'] | Should -Be 1000000 + } + + It 'parses a hexadecimal integer' { + $result = ConvertFrom-Toml -InputObject 'n = 0xFF' + $result.Data['n'] | Should -Be 255 + } + + It 'parses an octal integer' { + $result = ConvertFrom-Toml -InputObject 'n = 0o17' + $result.Data['n'] | Should -Be 15 + } + + It 'parses a binary integer' { + $result = ConvertFrom-Toml -InputObject 'n = 0b1010' + $result.Data['n'] | Should -Be 10 + } + + It 'parses integers fixture file' { + $result = ConvertFrom-Toml -InputObject (Get-Content (Join-Path $dataDir 'integers.toml') -Raw) + $result.Data['decimal'] | Should -Be 42 + $result.Data['negative'] | Should -Be -17 + $result.Data['hex'] | Should -Be 3735928559 + $result.Data['octal'] | Should -Be 493 + $result.Data['binary'] | Should -Be 214 + } + } + + Context 'Floats' { + It 'parses a positive float as [double]' { + $result = ConvertFrom-Toml -InputObject 'n = 3.14' + $result.Data['n'] | Should -Be 3.14 + $result.Data['n'] | Should -BeOfType [double] + } + + It 'parses a negative float' { + $result = ConvertFrom-Toml -InputObject 'n = -0.01' + $result.Data['n'] | Should -Be -0.01 + } + + It 'parses scientific notation' { + $result = ConvertFrom-Toml -InputObject 'n = 5e+22' + $result.Data['n'] | Should -Be 5e22 + } + + It 'parses inf' { + $result = ConvertFrom-Toml -InputObject 'n = inf' + $result.Data['n'] | Should -Be ([double]::PositiveInfinity) + } + + It 'parses -inf' { + $result = ConvertFrom-Toml -InputObject 'n = -inf' + $result.Data['n'] | Should -Be ([double]::NegativeInfinity) + } + + It 'parses nan' { + $result = ConvertFrom-Toml -InputObject 'n = nan' + [double]::IsNaN($result.Data['n']) | Should -BeTrue + } + + It 'parses floats fixture file' { + $result = ConvertFrom-Toml -InputObject (Get-Content (Join-Path $dataDir 'floats.toml') -Raw) + ([double]$result.Data['positive'] - 3.1415) | Should -BeLessThan 0.0001 + ([double]$result.Data['negative'] - (-0.01)) | Should -BeLessThan 0.0001 + } + } + + Context 'Booleans' { + It 'parses true as [bool]' { + $result = ConvertFrom-Toml -InputObject 'flag = true' + $result.Data['flag'] | Should -Be $true + $result.Data['flag'] | Should -BeOfType [bool] + } + + It 'parses false as [bool]' { + $result = ConvertFrom-Toml -InputObject 'flag = false' + $result.Data['flag'] | Should -Be $false + } + } + + Context 'Datetimes' { + It 'parses an offset datetime with Z suffix as DateTimeOffset' { + $result = ConvertFrom-Toml -InputObject 'dt = 1979-05-27T07:32:00Z' + $result.Data['dt'] | Should -BeOfType [System.DateTimeOffset] + ([System.DateTimeOffset]$result.Data['dt']).Year | Should -Be 1979 + ([System.DateTimeOffset]$result.Data['dt']).Day | Should -Be 27 + } + + It 'parses an offset datetime with +HH:MM offset' { + $result = ConvertFrom-Toml -InputObject 'dt = 1979-05-27T07:32:00+05:30' + $result.Data['dt'] | Should -BeOfType [System.DateTimeOffset] + ([System.DateTimeOffset]$result.Data['dt']).Offset.Hours | Should -Be 5 + ([System.DateTimeOffset]$result.Data['dt']).Offset.Minutes | Should -Be 30 + } + + It 'parses an offset datetime with space separator' { + $result = ConvertFrom-Toml -InputObject 'dt = 1987-07-05 17:45:00Z' + $result.Data['dt'] | Should -BeOfType [System.DateTimeOffset] + ([System.DateTimeOffset]$result.Data['dt']).Year | Should -Be 1987 + } + + It 'parses a local datetime as DateTime with Unspecified kind' { + $result = ConvertFrom-Toml -InputObject 'dt = 1979-05-27T07:32:00' + $result.Data['dt'] | Should -BeOfType [System.DateTime] + ([System.DateTime]$result.Data['dt']).Kind | Should -Be ([System.DateTimeKind]::Unspecified) + } + + It 'parses a local date as DateTime with zero time' { + $result = ConvertFrom-Toml -InputObject 'dt = 1979-05-27' + $result.Data['dt'] | Should -BeOfType [System.DateTime] + $dt = [System.DateTime]$result.Data['dt'] + $dt.Year | Should -Be 1979 + $dt.Month | Should -Be 5 + $dt.Day | Should -Be 27 + $dt.Hour | Should -Be 0 + } + + It 'parses a local time as TimeSpan' { + $result = ConvertFrom-Toml -InputObject 'tm = 07:32:00' + $result.Data['tm'] | Should -BeOfType [System.TimeSpan] + ([System.TimeSpan]$result.Data['tm']).Hours | Should -Be 7 + ([System.TimeSpan]$result.Data['tm']).Minutes | Should -Be 32 + } + + It 'parses datetimes fixture file' { + $result = ConvertFrom-Toml -InputObject (Get-Content (Join-Path $dataDir 'datetimes.toml') -Raw) + $result.Data['offset_dt_z'] | Should -BeOfType [System.DateTimeOffset] + $result.Data['offset_dt_num'] | Should -BeOfType [System.DateTimeOffset] + $result.Data['local_dt'] | Should -BeOfType [System.DateTime] + $result.Data['local_date'] | Should -BeOfType [System.DateTime] + $result.Data['local_time'] | Should -BeOfType [System.TimeSpan] + } + } + + Context 'Arrays' { + It 'parses an integer array' { + $result = ConvertFrom-Toml -InputObject 'arr = [1, 2, 3]' + $result.Data['arr'] | Should -HaveCount 3 + $result.Data['arr'][0] | Should -Be 1 + $result.Data['arr'][2] | Should -Be 3 + } + + It 'parses a string array' { + $result = ConvertFrom-Toml -InputObject 'arr = ["a", "b", "c"]' + $result.Data['arr'][0] | Should -Be 'a' + $result.Data['arr'][1] | Should -Be 'b' + } + + It 'parses an empty array' { + $result = ConvertFrom-Toml -InputObject 'arr = []' + $result.Data['arr'] | Should -HaveCount 0 + } + + It 'parses a nested array' { + $result = ConvertFrom-Toml -InputObject 'arr = [[1, 2], [3, 4]]' + $result.Data['arr'] | Should -HaveCount 2 + $result.Data['arr'][0][1] | Should -Be 2 + } + + It 'parses a multiline array' { + $result = ConvertFrom-Toml -InputObject "arr = [`n 1,`n 2,`n 3,`n]" + $result.Data['arr'] | Should -HaveCount 3 + } + + It 'parses arrays fixture file' { + $result = ConvertFrom-Toml -InputObject (Get-Content (Join-Path $dataDir 'arrays.toml') -Raw) + $result.Data['integers'] | Should -HaveCount 3 + $result.Data['strings'] | Should -HaveCount 3 + $result.Data['empty'] | Should -HaveCount 0 + $result.Data['nested'] | Should -HaveCount 2 + } + } + + Context 'Tables' { + It 'parses a standard table' { + $result = ConvertFrom-Toml -InputObject "[server]`nhost = `"localhost`"" + $result.Data['server'] | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + $result.Data['server']['host'] | Should -Be 'localhost' + } + + It 'parses an inline table' { + $result = ConvertFrom-Toml -InputObject 'point = { x = 1, y = 2 }' + $result.Data['point']['x'] | Should -Be 1 + $result.Data['point']['y'] | Should -Be 2 + } + + It 'parses deeply nested tables via dotted header' { + $result = ConvertFrom-Toml -InputObject "[a.b.c]`nkey = `"deep`"" + $result.Data['a']['b']['c']['key'] | Should -Be 'deep' + } + + It 'parses tables fixture file' { + $result = ConvertFrom-Toml -InputObject (Get-Content (Join-Path $dataDir 'tables.toml') -Raw) + $result.Data['simple']['key'] | Should -Be 'value' + $result.Data['dotted']['key']['works'] | Should -Be $true + $result.Data['inline_parent']['inline']['one'] | Should -Be 1 + } + } + + Context 'Array of tables' { + It 'parses array of tables from fixture file' { + $result = ConvertFrom-Toml -InputObject (Get-Content (Join-Path $dataDir 'array-of-tables.toml') -Raw) + $result.Data['products'] | Should -HaveCount 2 + $result.Data['products'][0]['name'] | Should -Be 'Hammer' + $result.Data['products'][1]['name'] | Should -Be 'Nail' + } + + It 'parses nested array of tables' { + $result = ConvertFrom-Toml -InputObject (Get-Content (Join-Path $dataDir 'array-of-tables.toml') -Raw) + $result.Data['fruits'][0]['varieties'] | Should -HaveCount 2 + $result.Data['fruits'][0]['varieties'][0]['name'] | Should -Be 'red delicious' + } + + It 'parses sub-tables independently per array entry' { + $toml = "[[stages]]`nname = `"build`"`n`n [stages.env]`n KEY = `"A`"`n`n[[stages]]`nname = `"test`"`n`n [stages.env]`n KEY = `"B`"" + $result = ConvertFrom-Toml -InputObject $toml + $result.Data['stages'][0]['env']['KEY'] | Should -Be 'A' + $result.Data['stages'][1]['env']['KEY'] | Should -Be 'B' + } + } + + Context 'Keys' { + It 'parses a bare key' { + $result = ConvertFrom-Toml -InputObject 'bare_key = "value"' + $result.Data['bare_key'] | Should -Be 'value' + } + + It 'parses a quoted key with spaces' { + $result = ConvertFrom-Toml -InputObject '"quoted key" = "value"' + $result.Data['quoted key'] | Should -Be 'value' + } + + It 'parses a dotted key into nested tables' { + $result = ConvertFrom-Toml -InputObject 'a.b = "value"' + $result.Data['a']['b'] | Should -Be 'value' + } + + It 'preserves key insertion order' { + $result = ConvertFrom-Toml -InputObject "z = 1`ny = 2`nx = 3" + $keys = $result.Data.Keys + $keys[0] | Should -Be 'z' + $keys[1] | Should -Be 'y' + $keys[2] | Should -Be 'x' + } + } + + Context 'Comments and whitespace' { + It 'ignores inline comments' { + $result = ConvertFrom-Toml -InputObject 'key = "value" # comment' + $result.Data['key'] | Should -Be 'value' + } + + It 'ignores full-line comments' { + $result = ConvertFrom-Toml -InputObject "# comment`nkey = `"value`"" + $result.Data['key'] | Should -Be 'value' + } + + It 'handles CRLF line endings' { + $result = ConvertFrom-Toml -InputObject "a = 1`r`nb = 2" + $result.Data['a'] | Should -Be 1 + $result.Data['b'] | Should -Be 2 + } + } + + Context 'Full example' { + It 'parses the full example fixture file' { + $result = ConvertFrom-Toml -InputObject (Get-Content (Join-Path $dataDir 'full-example.toml') -Raw) + $result.Data['title'] | Should -Be 'TOML Example' + $result.Data['owner']['name'] | Should -Be 'Tom Preston-Werner' + $result.Data['database']['enabled'] | Should -Be $true + $result.Data['database']['ports'] | Should -HaveCount 3 + $result.Data['servers']['alpha']['ip'] | Should -Be '10.0.0.1' + $result.Data['servers']['beta']['role'] | Should -Be 'backend' + } + } + + Context 'Advanced fixtures' { + It 'parses ci-pipeline.toml' { + $result = ConvertFrom-Toml -InputObject (Get-Content (Join-Path $dataDir 'advanced\ci-pipeline.toml') -Raw) + $result.Data['pipeline']['name'] | Should -Be 'my-app-ci' + $result.Data['pipeline']['stages'] | Should -HaveCount 3 + $result.Data['pipeline']['stages'][0]['steps'] | Should -HaveCount 2 + } + + It 'parses numbers-and-datetimes.toml' { + $result = ConvertFrom-Toml -InputObject (Get-Content (Join-Path $dataDir 'advanced\numbers-and-datetimes.toml') -Raw) + $result.Data['integers']['hex-upper'] | Should -Be 3735928559 + $result.Data['floats']['infinity'] | Should -Be ([double]::PositiveInfinity) + $result.Data['datetimes']['utc-space-sep'] | Should -BeOfType [System.DateTimeOffset] + $result.Data['arrays']['deeply-nested'] | Should -HaveCount 2 + } + + It 'parses app-config.toml' { + $result = ConvertFrom-Toml -InputObject (Get-Content (Join-Path $dataDir 'advanced\app-config.toml') -Raw) + $result.Data['app']['version'] | Should -Be '2.4.1' + $result.Data['feature_flag'] | Should -HaveCount 3 + $result.Data['feature_flag'][0]['targeting']['segments'] | Should -Contain 'beta-users' + } + + It 'parses cargo-manifest.toml' { + $result = ConvertFrom-Toml -InputObject (Get-Content (Join-Path $dataDir 'advanced\cargo-manifest.toml') -Raw) + $result.Data['package']['name'] | Should -Be 'my-awesome-lib' + $result.Data['package']['limits']['max-size-binary'] | Should -Be 1048576 + $result.Data['bench'] | Should -HaveCount 3 + } + + It 'parses database-server.toml' { + $result = ConvertFrom-Toml -InputObject (Get-Content (Join-Path $dataDir 'advanced\database-server.toml') -Raw) + $result.Data['cluster']['name'] | Should -Be 'primary-pg-cluster' + $result.Data['nodes']['replicas'] | Should -HaveCount 2 + $result.Data['nodes']['replicas'][0]['slots'] | Should -HaveCount 2 + } + } + + Context 'Error handling' { + It 'throws on invalid TOML syntax' { + { ConvertFrom-Toml -InputObject 'invalid = = "bad"' } | Should -Throw + } + + It 'throws on a duplicate key' { + { ConvertFrom-Toml -InputObject "key = 1`nkey = 2" } | Should -Throw + } + + It 'throws on a missing value' { + { ConvertFrom-Toml -InputObject 'key =' } | Should -Throw + } + + It 'throws on empty string input' { + { ConvertFrom-Toml -InputObject '' } | Should -Throw + } + + It 'throws on table redefinition' { + { ConvertFrom-Toml -InputObject "[a]`nkey = 1`n[a]`nother = 2" } | Should -Throw + } + } + } + + Describe 'ConvertTo-Toml' { + + Context 'Return type' { + It 'returns a string' { + $result = ConvertTo-Toml -InputObject ([ordered]@{ key = 'value' }) + $result | Should -BeOfType [string] + } + + It 'result is non-empty for non-empty input' { + $result = ConvertTo-Toml -InputObject ([ordered]@{ key = 'value' }) + $result | Should -Not -BeNullOrEmpty + } + + It 'accepts pipeline input' { + $result = [ordered]@{ key = 'value' } | ConvertTo-Toml + $result | Should -BeOfType [string] + } + } + + Context 'Scalar serialization' { + It 'serializes a string value' { + $result = ConvertTo-Toml -InputObject ([ordered]@{ title = 'Hello' }) + $result | Should -Match 'title = "Hello"' + } + + It 'serializes an integer value' { + $result = ConvertTo-Toml -InputObject ([ordered]@{ n = [long]42 }) + $result | Should -Match 'n = 42' + } + + It 'serializes true and false' { + ConvertTo-Toml -InputObject ([ordered]@{ flag = $true }) | Should -Match 'flag = true' + ConvertTo-Toml -InputObject ([ordered]@{ flag = $false }) | Should -Match 'flag = false' + } + + It 'serializes a float value' { + $result = ConvertTo-Toml -InputObject ([ordered]@{ pi = 3.14 }) + $result | Should -Match '3\.14' + } + + It 'serializes a DateTimeOffset' { + $dto = [System.DateTimeOffset]::new(1979, 5, 27, 7, 32, 0, [System.TimeSpan]::Zero) + ConvertTo-Toml -InputObject ([ordered]@{ dt = $dto }) | Should -Match '1979-05-27' + } + + It 'serializes a DateTime (local datetime)' { + $dt = [System.DateTime]::new(2024, 6, 1, 15, 30, 0, [System.DateTimeKind]::Unspecified) + ConvertTo-Toml -InputObject ([ordered]@{ dt = $dt }) | Should -Match '2024-06-01' + } + + It 'serializes a TimeSpan as local time' { + $ts = [System.TimeSpan]::new(0, 8, 30, 0) + ConvertTo-Toml -InputObject ([ordered]@{ tm = $ts }) | Should -Match '08:30:00' + } + } + + Context 'Nested tables and arrays' { + It 'serializes a nested ordered dict as a TOML table header' { + $result = ConvertTo-Toml -InputObject ([ordered]@{ + server = [ordered]@{ host = 'localhost'; port = [long]8080 } + }) + $result | Should -Match '\[server\]' + $result | Should -Match 'host = "localhost"' + } + + It 'serializes an array of integers' { + $result = ConvertTo-Toml -InputObject ([ordered]@{ ports = @([long]80, [long]443) }) + $result | Should -Match '80' + $result | Should -Match '443' + } + + It 'serializes a PSCustomObject' { + $result = ConvertTo-Toml -InputObject ([PSCustomObject]@{ name = 'Alice'; age = [long]30 }) + $result | Should -Match 'name = "Alice"' + $result | Should -Match 'age = 30' + } + + It 'accepts a TomlDocument as input' { + $doc = ConvertFrom-Toml -InputObject "title = `"Test`"`n[section]`nvalue = 42" + $result = ConvertTo-Toml -InputObject $doc + $result | Should -Match 'title = "Test"' + $result | Should -Match '\[section\]' + } + } + + Context 'Round-trip' { + It 'string and integer values survive a round-trip' { + $toml = ConvertTo-Toml -InputObject ([ordered]@{ key = 'value' }) + (ConvertFrom-Toml -InputObject $toml).Data['key'] | Should -Be 'value' + } + + It 'integer value survives a round-trip' { + $toml = ConvertTo-Toml -InputObject ([ordered]@{ n = [long]12345 }) + (ConvertFrom-Toml -InputObject $toml).Data['n'] | Should -Be 12345 + } + + It 'float value survives a round-trip' { + $toml = ConvertTo-Toml -InputObject ([ordered]@{ n = 2.718 }) + ([double](ConvertFrom-Toml -InputObject $toml).Data['n'] - 2.718) | Should -BeLessThan 0.001 + } + + It 'DateTimeOffset survives a round-trip' { + $dto = [System.DateTimeOffset]::new(2024, 3, 15, 12, 0, 0, [System.TimeSpan]::FromHours(2)) + $toml = ConvertTo-Toml -InputObject ([ordered]@{ dt = $dto }) + $rt = (ConvertFrom-Toml -InputObject $toml).Data['dt'] + $rt | Should -BeOfType [System.DateTimeOffset] + ([System.DateTimeOffset]$rt).Year | Should -Be 2024 + } + + It 'nested table survives a round-trip' { + $toml = ConvertTo-Toml -InputObject ([ordered]@{ + database = [ordered]@{ host = 'db.example.com'; port = [long]5432 } + }) + $rt = (ConvertFrom-Toml -InputObject $toml).Data + $rt['database']['host'] | Should -Be 'db.example.com' + $rt['database']['port'] | Should -Be 5432 + } + + It 'integer array survives a round-trip' { + $toml = ConvertTo-Toml -InputObject ([ordered]@{ nums = @([long]1, [long]2, [long]3) }) + $rt = (ConvertFrom-Toml -InputObject $toml).Data['nums'] + $rt | Should -HaveCount 3 + $rt[0] | Should -Be 1 + } + + It 'full example fixture file survives a round-trip' { + $original = ConvertFrom-Toml -InputObject (Get-Content (Join-Path $dataDir 'full-example.toml') -Raw) + $rt = ConvertFrom-Toml -InputObject (ConvertTo-Toml -InputObject $original) + $rt.Data['title'] | Should -Be 'TOML Example' + $rt.Data['owner']['name'] | Should -Be 'Tom Preston-Werner' + $rt.Data['database']['enabled'] | Should -Be $true + $rt.Data['database']['ports'] | Should -HaveCount 3 + $rt.Data['servers']['alpha']['ip'] | Should -Be '10.0.0.1' + } + } + + Context 'Error handling' { + It 'throws on null input' { + { ConvertTo-Toml -InputObject $null } | Should -Throw + } + } + } + + Describe 'Import-Toml' { + + Context 'Return type' { + It 'returns a TomlDocument' { + $result = Import-Toml -Path (Join-Path $dataDir 'full-example.toml') + $result.GetType().Name | Should -Be 'TomlDocument' + } + + It 'sets FilePath to the resolved absolute path' { + $result = Import-Toml -Path (Join-Path $dataDir 'full-example.toml') + $result.FilePath | Should -Not -BeNullOrEmpty + [System.IO.Path]::IsPathRooted($result.FilePath) | Should -BeTrue + } + + It 'Data is an OrderedDictionary' { + $result = Import-Toml -Path (Join-Path $dataDir 'full-example.toml') + $result.Data | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + } + } + + Context 'File content' { + It 'reads the full example fixture file' { + $result = Import-Toml -Path (Join-Path $dataDir 'full-example.toml') + $result.Data['title'] | Should -Be 'TOML Example' + $result.Data['owner']['name'] | Should -Be 'Tom Preston-Werner' + $result.Data['database']['enabled'] | Should -Be $true + } + + It 'reads integers fixture file' { + $result = Import-Toml -Path (Join-Path $dataDir 'integers.toml') + $result.Data['decimal'] | Should -Be 42 + $result.Data['negative'] | Should -Be -17 + $result.Data['hex'] | Should -Be 3735928559 + } + + It 'reads floats fixture file' { + $result = Import-Toml -Path (Join-Path $dataDir 'floats.toml') + ([double]$result.Data['positive'] - 3.1415) | Should -BeLessThan 0.0001 + [double]::IsNaN($result.Data['special_nan']) | Should -BeTrue + } + + It 'reads booleans fixture file' { + $result = Import-Toml -Path (Join-Path $dataDir 'booleans.toml') + $result.Data['enabled'] | Should -Be $true + $result.Data['disabled'] | Should -Be $false + } + + It 'reads datetimes fixture file' { + $result = Import-Toml -Path (Join-Path $dataDir 'datetimes.toml') + $result.Data['offset_dt_z'] | Should -BeOfType [System.DateTimeOffset] + $result.Data['local_date'] | Should -BeOfType [System.DateTime] + $result.Data['local_time'] | Should -BeOfType [System.TimeSpan] + } + + It 'reads arrays fixture file' { + $result = Import-Toml -Path (Join-Path $dataDir 'arrays.toml') + $result.Data['integers'] | Should -HaveCount 3 + $result.Data['empty'] | Should -HaveCount 0 + } + + It 'reads array-of-tables fixture file' { + $result = Import-Toml -Path (Join-Path $dataDir 'array-of-tables.toml') + $result.Data['products'] | Should -HaveCount 2 + $result.Data['products'][0]['name'] | Should -Be 'Hammer' + } + } + + Context 'Pipeline' { + It 'accepts a path from the pipeline' { + $result = (Join-Path $dataDir 'booleans.toml') | Import-Toml + $result.GetType().Name | Should -Be 'TomlDocument' + } + } + + Context 'Error handling' { + It 'throws when the file does not exist' { + { Import-Toml -Path 'nonexistent-file.toml' } | Should -Throw + } + + It 'throws when the file contains invalid TOML' { + $bad = Join-Path $TestDrive 'bad.toml' + Set-Content -Path $bad -Value 'invalid = = "bad"' + { Import-Toml -Path $bad } | Should -Throw + } + } + } + + Describe 'Export-Toml' { + + Context 'Basic write' { + It 'creates a file at the given path' { + $path = Join-Path $TestDrive 'output.toml' + Export-Toml -InputObject ([ordered]@{ key = 'value' }) -Path $path + Test-Path -Path $path | Should -BeTrue + } + + It 'file content is valid TOML' { + $path = Join-Path $TestDrive 'content.toml' + Export-Toml -InputObject ([ordered]@{ title = 'Hello' }) -Path $path + Get-Content -Path $path -Raw | Should -Match 'title = "Hello"' + } + + It 'returns nothing to the output stream' { + $path = Join-Path $TestDrive 'void.toml' + $result = Export-Toml -InputObject ([ordered]@{ k = 'v' }) -Path $path + $result | Should -BeNullOrEmpty + } + + It 'creates parent directories when they do not exist' { + $path = Join-Path $TestDrive 'nested\subdir\output.toml' + Export-Toml -InputObject ([ordered]@{ k = 'v' }) -Path $path + Test-Path -Path $path | Should -BeTrue + } + } + + Context 'Round-trip via Import-Toml' { + It 'preserves string values' { + $path = Join-Path $TestDrive 'rt-string.toml' + Export-Toml -InputObject ([ordered]@{ greeting = 'Hello World' }) -Path $path + (Import-Toml -Path $path).Data['greeting'] | Should -Be 'Hello World' + } + + It 'preserves integer values' { + $path = Join-Path $TestDrive 'rt-int.toml' + Export-Toml -InputObject ([ordered]@{ count = [long]42 }) -Path $path + (Import-Toml -Path $path).Data['count'] | Should -Be 42 + } + + It 'preserves boolean values' { + $path = Join-Path $TestDrive 'rt-bool.toml' + Export-Toml -InputObject ([ordered]@{ enabled = $true; disabled = $false }) -Path $path + $rt = (Import-Toml -Path $path).Data + $rt['enabled'] | Should -Be $true + $rt['disabled'] | Should -Be $false + } + + It 'preserves float values' { + $path = Join-Path $TestDrive 'rt-float.toml' + Export-Toml -InputObject ([ordered]@{ pi = 3.14159 }) -Path $path + ([double](Import-Toml -Path $path).Data['pi'] - 3.14159) | Should -BeLessThan 0.00001 + } + + It 'preserves nested tables' { + $path = Join-Path $TestDrive 'rt-nested.toml' + Export-Toml -InputObject ([ordered]@{ server = [ordered]@{ host = 'localhost'; port = [long]8080 } }) -Path $path + $rt = (Import-Toml -Path $path).Data + $rt['server']['host'] | Should -Be 'localhost' + $rt['server']['port'] | Should -Be 8080 + } + + It 'preserves integer arrays' { + $path = Join-Path $TestDrive 'rt-array.toml' + Export-Toml -InputObject ([ordered]@{ ports = @([long]80, [long]443) }) -Path $path + $rt = (Import-Toml -Path $path).Data['ports'] + $rt | Should -HaveCount 2 + $rt[0] | Should -Be 80 + } + + It 'full file survives a round-trip' { + $outPath = Join-Path $TestDrive 'rt-full.toml' + Export-Toml -InputObject (Import-Toml -Path (Join-Path $dataDir 'full-example.toml')) -Path $outPath + $rt = (Import-Toml -Path $outPath).Data + $rt['title'] | Should -Be 'TOML Example' + $rt['owner']['name'] | Should -Be 'Tom Preston-Werner' + $rt['database']['enabled'] | Should -Be $true + $rt['servers']['alpha']['ip'] | Should -Be '10.0.0.1' + } + } + + Context 'UTF-8 encoding' { + It 'writes UTF-8 without BOM' { + $path = Join-Path $TestDrive 'utf8.toml' + Export-Toml -InputObject ([ordered]@{ key = 'αβγ' }) -Path $path + $bytes = [System.IO.File]::ReadAllBytes($path) + if ($bytes.Length -ge 3) { + ($bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) | Should -BeFalse + } + } + + It 'round-trips Unicode string content correctly' { + $path = Join-Path $TestDrive 'unicode.toml' + Export-Toml -InputObject ([ordered]@{ msg = 'こんにちは' }) -Path $path + (Import-Toml -Path $path).Data['msg'] | Should -Be 'こんにちは' + } + } + + Context 'Pipeline input' { + It 'accepts a TomlDocument from the pipeline' { + $outPath = Join-Path $TestDrive 'from-pipeline.toml' + Import-Toml -Path (Join-Path $dataDir 'booleans.toml') | Export-Toml -Path $outPath + Test-Path $outPath | Should -BeTrue + (Import-Toml -Path $outPath).Data['enabled'] | Should -Be $true + } + } + + Context 'WhatIf' { + It 'does not create a file when -WhatIf is passed' { + $path = Join-Path $TestDrive 'whatif.toml' + Export-Toml -InputObject ([ordered]@{ k = 'v' }) -Path $path -WhatIf + Test-Path -Path $path | Should -BeFalse + } + } + + Context 'Error handling' { + It 'throws on null InputObject' { + { Export-Toml -InputObject $null -Path (Join-Path $TestDrive 'null.toml') } | Should -Throw + } + } + } +} diff --git a/tests/data/advanced/app-config.toml b/tests/data/advanced/app-config.toml new file mode 100644 index 0000000..491caab --- /dev/null +++ b/tests/data/advanced/app-config.toml @@ -0,0 +1,117 @@ +# app-config.toml +# Production application configuration — TOML 1.0.0 +# Features: multi-line basic/literal strings, all escape sequences, Unicode, +# dotted keys, inline tables, arrays, standard/dotted table headers, +# array-of-tables, integer underscores, comments. + +[app] +name = "Web Service" +version = "2.4.1" +debug = false +workers = 4 + +app.build.commit = "a3f9e12b" +app.build.date = 2024-07-23T14:00:00Z +app.build.tag = "v2.4.1" + +# ── String types ────────────────────────────────────────────────────────────── + +[strings] +tabs-and-newlines = "column1\tcolumn2\nrow2-col1\trow2-col2" +quote-in-string = "She said \"hello\" and left." +backslash = "C:\\Users\\Alice\\Documents" +null-byte = "before\u0000after" +smiley = "\U0001F600" +greek-delta = "\u03B4" +snowman = "\u2603" + +sql-query = """ + SELECT u.id, u.name, u.email, \ + p.plan, p.expires_at + FROM users u + JOIN plans p ON p.user_id = u.id + WHERE u.active = TRUE + AND p.expires_at > NOW() + ORDER BY u.name ASC + LIMIT 100;""" + +changelog-entry = """ +## v2.4.1 — 2024-07-23 + +### Fixed +- Memory leak in connection pool +- Race condition in cache eviction +""" + +regex-pattern = '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}' +windows-path-raw = 'C:\Program Files\MyApp\config.ini' + +shell-script = ''' +#!/usr/bin/env bash +set -euo pipefail +echo "Done at $(date)" +''' + +# ── Server ──────────────────────────────────────────────────────────────────── + +[server] +host = "0.0.0.0" +port = 8_080 +read-timeout = 30 +write-timeout = 60 +idle-timeout = 120 + +[server.tls] +enabled = true +cert-file = "/etc/ssl/certs/app.crt" +key-file = "/etc/ssl/private/app.key" +min-version = "TLS1.2" +ciphers = [ + "TLS_AES_128_GCM_SHA256", + "TLS_AES_256_GCM_SHA384", + "TLS_CHACHA20_POLY1305_SHA256", + "ECDHE-RSA-AES256-GCM-SHA384", +] + +[server.cors] +allowed-origins = ["https://example.com", "https://app.example.com"] +allowed-methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] +max-age = 86_400 + +# ── Database ────────────────────────────────────────────────────────────────── + +[database] +driver = "postgres" +pool = { min = 5, max = 50, timeout = 30, idle-timeout = 600 } + +# ── Feature flags ───────────────────────────────────────────────────────────── + +[[feature_flag]] +name = "new-dashboard" +enabled = true +rollout-pct = 25 +started = 2024-05-01 + + [feature_flag.targeting] + segments = ["beta-users", "internal"] + regions = ["us-east-1", "eu-west-1"] + +[[feature_flag]] +name = "graphql-api" +enabled = false +rollout-pct = 0 +started = 2024-07-01T00:00:00Z + + [feature_flag.targeting] + segments = [] + regions = ["us-east-1"] + +[[feature_flag]] +name = "ml-recommendations" +enabled = true +rollout-pct = 100 +started = 2024-03-15T09:00:00+02:00 + + [feature_flag.targeting] + segments = ["all"] + regions = ["us-east-1", "us-west-2", "eu-west-1", "ap-southeast-1"] diff --git a/tests/data/advanced/cargo-manifest.toml b/tests/data/advanced/cargo-manifest.toml new file mode 100644 index 0000000..45c077b --- /dev/null +++ b/tests/data/advanced/cargo-manifest.toml @@ -0,0 +1,95 @@ +# Cargo.toml — Rust-style package manifest — TOML 1.0.0 showcase +# Features: dotted keys, multi-line literal strings, all integer forms, +# dotted table paths, array of tables, inline tables, comments. + +[package] +name = "my-awesome-lib" +version = "0.9.1" +edition = "2021" +authors = ["Alice ", "Bob "] +license = "MIT OR Apache-2.0" +description = "A library demonstrating TOML 1.0.0 features." +readme = "README.md" + +package.metadata.docs_rs.features = ["full"] +package.metadata.docs_rs.targets = ["x86_64-unknown-linux-gnu"] + +package.metadata.build_notes = ''' +Build with: + cargo build --release + +Windows cross-compile: + cargo build --target x86_64-pc-windows-gnu \ + --features "tls-native" +No escapes needed: C:\Users\Alice\project is literal. +''' + +[features] +default = ["std", "derive"] +full = ["std", "derive", "async", "serde-support"] +std = [] +derive = [] +async = [] +serde-support = [] + +[lib] +name = "my_awesome_lib" +crate-type = ["rlib", "cdylib"] + +[package.limits] +max-size-decimal = 1_048_576 +max-size-hex = 0x0010_0000 +max-size-octal = 0o4_000_000 +max-size-binary = 0b0001_0000_0000_0000_0000_0000 +page-size = 0o10000 +flag-bits = 0b1100_0011 +magic-number = 0xDEAD_BEEF + +[dependencies] +serde = { version = "1", features = ["derive"] } +tokio = { version = "1", features = ["full"], optional = true } +thiserror = "1" +tracing = "0.1" + +[dependencies.reqwest] +version = "0.12" +default-features = false +features = ["json", "rustls-tls"] +optional = true + +[dev-dependencies] +criterion = { version = "0.5", features = ["html_reports"] } +proptest = "1" +tempfile = "3" + +[[bench]] +name = "encode_throughput" +harness = false + +[[bench]] +name = "decode_throughput" +harness = false + +[[bench]] +name = "roundtrip" +harness = false + +[[example]] +name = "basic_usage" +required-features = ["std"] + +[[example]] +name = "async_client" +required-features = ["async", "serde-support"] + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 +strip = "symbols" +overflow-checks = false + +[profile.dev] +opt-level = 0 +debug = true +overflow-checks = true diff --git a/tests/data/advanced/ci-pipeline.toml b/tests/data/advanced/ci-pipeline.toml new file mode 100644 index 0000000..d402660 --- /dev/null +++ b/tests/data/advanced/ci-pipeline.toml @@ -0,0 +1,78 @@ +# ci-pipeline.toml +# Continuous integration pipeline configuration — TOML 1.0.0 +# Features: dotted keys, standard/dotted table headers, array-of-tables, +# multi-level nesting, inline tables, multi-line basic strings, +# integer underscore separators, comments alongside keys. + +[pipeline] # standard table header +name = "my-app-ci" +version = 2 +timeout = 3_600 # integer with underscore separator +fail-fast = true + +# Dotted keys build the "pipeline.meta" sub-table without a [pipeline.meta] header +pipeline.meta.owner = "platform-team" +pipeline.meta.created = 2024-03-15 +pipeline.meta.description = """ +A multi-stage pipeline that builds, \ +tests, and deploys the application \ +to all target environments.""" + +[pipeline.triggers] +branches = ["main", "release/*", "hotfix/*"] +tags = ["v[0-9]*"] +on-pr = true + +[[pipeline.stages]] +name = "build" +image = "rust:1.78-slim" +allow-fail = false + + [pipeline.stages.env] + RUST_BACKTRACE = "1" + CARGO_TERM_COLOR = "always" + + [[pipeline.stages.steps]] + name = "restore-cache" + run = "cargo fetch --locked" + + [[pipeline.stages.steps]] + name = "compile" + run = "cargo build --release --locked" + timeout = 1_800 + +[[pipeline.stages]] +name = "test" +image = "rust:1.78-slim" +depends-on = ["build"] +allow-fail = false + + [pipeline.stages.env] + RUST_LOG = "debug" + + [[pipeline.stages.steps]] + name = "unit-tests" + run = "cargo test --workspace" + + [[pipeline.stages.steps]] + name = "coverage" + run = "cargo llvm-cov --lcov --output-path lcov.info" + timeout = 600 + +[[pipeline.stages]] +name = "deploy" +image = "alpine:3.19" +depends-on = ["build", "test"] +when = { branch = "main", event = "push" } + + [[pipeline.stages.steps]] + name = "upload-artifact" + run = "aws s3 cp ./target/release/my-app s3://releases/" + + [[pipeline.stages.steps]] + name = "notify-slack" + run = "curl -X POST $SLACK_WEBHOOK -d @payload.json" + +[pipeline.notifications] +on-success = { channel = "#deploys", message = "Deploy succeeded" } +on-failure = { channel = "#oncall", message = "Pipeline failed!" } diff --git a/tests/data/advanced/database-server.toml b/tests/data/advanced/database-server.toml new file mode 100644 index 0000000..efead75 --- /dev/null +++ b/tests/data/advanced/database-server.toml @@ -0,0 +1,164 @@ +# database-server.toml +# Database cluster configuration — TOML 1.0.0 deep nesting showcase +# Features: dotted top-level keys, quoted keys with dots/spaces, all four +# datetime forms, dotted table paths, array-of-tables with nested +# sub-tables and nested AoT, inline tables, all integer bases, +# all float forms, heterogeneous arrays. + +cluster.name = "primary-pg-cluster" +cluster.version = "16.2" +cluster.region = "eu-central-1" + +"cluster.id" = "pg-eur-001" +"127.0.0.1" = "loopback-alias" +'special chars' = "spaces in key name" + +# ── Temporal audit data ─────────────────────────────────────────────────────── + +[audit] +cluster-created = 1970-01-01T00:00:00Z +last-failover = 2024-03-10T02:30:00-05:00 +last-maintenance = 2024-01-28T22:00:00+01:00 +last-config-edit = 2024-07-22T11:45:30 +last-vacuum-start = 2024-07-21T03:00:00.000 +certificate-expiry = 2025-08-14 +created-date = 2022-09-01 +daily-backup-time = 03:30:00 +maintenance-window = 22:00:00 +checkpoint-time = 00:00:00.000 + +# ── Primary node ────────────────────────────────────────────────────────────── + +[nodes.primary] +host = "pg-primary.internal" +port = 5432 +max-connections = 500 +shared-buffers = "4GB" + +nodes.primary.replication.slots = 4 +nodes.primary.replication.timeout = 60 +nodes.primary.replication.mode = "async" + +[nodes.primary.wal] +level = "replica" +archive = true +archive-dir = "/mnt/wal-archive/primary" +compression = "lz4" +keep-segments = 16 + +[nodes.primary.checkpoint] +completion-target = 0.9 +warning-seconds = 30 + +# ── Replica nodes ───────────────────────────────────────────────────────────── + +[[nodes.replicas]] +name = "replica-01" +host = "pg-replica-01.internal" +port = 5432 +priority = 100 +lag-warning-ms = 5_000 +lag-critical-ms = 30_000 +sync-mode = "async" +promoted = false + + [nodes.replicas.connection] + pool-size = 20 + keepalive = { idle = 60, interval = 10, count = 5 } + + [[nodes.replicas.slots]] + name = "wal_slot_app_01" + plugin = "pgoutput" + active = true + + [[nodes.replicas.slots]] + name = "wal_slot_analytics" + plugin = "wal2json" + active = false + +[[nodes.replicas]] +name = "replica-02" +host = "pg-replica-02.internal" +port = 5432 +priority = 90 +lag-warning-ms = 5_000 +lag-critical-ms = 30_000 +sync-mode = "sync" +promoted = false + + [nodes.replicas.connection] + pool-size = 15 + keepalive = { idle = 60, interval = 10, count = 5 } + + [[nodes.replicas.slots]] + name = "wal_slot_app_02" + plugin = "pgoutput" + active = true + +# ── Storage — all numeric bases ─────────────────────────────────────────────── + +[storage] +block-size-decimal = 8192 +block-size-hex = 0x2000 +block-size-octal = 0o20000 +block-size-binary = 0b0010_0000_0000_0000 + +max-table-size-dec = 1_073_741_824 +max-table-size-hex = 0x4000_0000 + +file-permissions = 0o600 +cache-hit-target = 0.99 +bloat-threshold = 0.20 +fill-factor = 9.0e1 +compression-ratio = 3.5e+0 +dead-tuple-ratio = 1.0E-2 + +# ── Monitoring ──────────────────────────────────────────────────────────────── + +[monitoring] +scrape-interval = 15 +labels = { cluster = "primary-pg-cluster", env = "production" } + +alert-thresholds = [ + 0.8, + 0.9, + true, + "ops-team", +] + +retention-windows = [ + [60, "raw"], + [3600, "1h-rollup"], + [86400, "1d-rollup"], +] + +[[monitoring.alerts]] +name = "replication-lag" +severity = "critical" +threshold = 30_000 +enabled = true +created = 2023-11-01 + + [monitoring.alerts.notify] + channels = ["pagerduty", "slack-ops"] + cooldown = 300 + + [[monitoring.alerts.conditions]] + metric = "pg_replication_lag_bytes" + operator = ">" + value = 1_073_741_824 + +[[monitoring.alerts]] +name = "connection-saturation" +severity = "warning" +threshold = 0.85 +enabled = true + + [monitoring.alerts.notify] + channels = ["slack-ops"] + cooldown = 600 + + [[monitoring.alerts.conditions]] + metric = "pg_connections_ratio" + operator = ">=" + value = 0.85 diff --git a/tests/data/advanced/numbers-and-datetimes.toml b/tests/data/advanced/numbers-and-datetimes.toml new file mode 100644 index 0000000..ed5faff --- /dev/null +++ b/tests/data/advanced/numbers-and-datetimes.toml @@ -0,0 +1,103 @@ +# numbers-and-datetimes.toml +# Exhaustive showcase of every TOML 1.0.0 numeric and datetime form. +# Features: all integer literals, all float literals, all four datetime types, +# mixed/heterogeneous arrays, nested arrays, comments. + +# ── Integers ───────────────────────────────────────────────────────────────── + +[integers] +answer = 42 +positive = +42 +negative = -42 +zero = 0 +one-million = 1_000_000 +phone-num = 8_675_309 + +hex-upper = 0xDEADBEEF +hex-lower = 0xdeadbeef +hex-under = 0xdead_beef +octal-perms = 0o755 +octal-full = 0o01234567 +binary-byte = 0b1100_0011 +binary-long = 0b0001_0000_0000_0000_0000_0000 + +# ── Floats ─────────────────────────────────────────────────────────────────── + +[floats] +pi = 3.14159_26535 +neg-pi = -3.14 +pos-pi = +3.14 +zero-frac = 0.0 +neg-zero = -0.0 +small = 0.0123 + +avogadro = 6.022e23 +planck = 6.626e-34 +speed-c = 2.998e+8 +exp-upper = 1.0E6 +neg-e = -1e-1 + +under-int = 3_141.5927 +under-frac = 3141.592_7 +under-exp = 3e1_4 + +infinity = inf +neg-infinity = -inf +pos-infinity = +inf +not-a-number = nan +neg-nan = -nan +pos-nan = +nan + +# ── Datetimes ──────────────────────────────────────────────────────────────── + +[datetimes] +utc-zulu = 1987-07-05T17:45:56Z +utc-space-sep = 1987-07-05 17:45:00Z +utc-lowercase-z = 1987-07-05t17:45:00z +utc-millis = 2024-01-15T08:30:00.123Z + +eastern-us = 2024-06-21T09:00:00-04:00 +western-eu = 2024-06-21T15:00:00+02:00 +india = 2024-01-01T05:30:00+05:30 +nepal = 2024-01-01T05:45:00+05:45 + +local-dt = 1987-07-05T17:45:00 +local-dt-millis = 1977-12-21T10:32:00.555 +local-dt-space = 1987-07-05 17:45:00 + +birthday = 1987-07-05 +epoch-date = 1970-01-01 +leap-day = 2000-02-29 + +start-of-day = 00:00:00 +noon = 12:00:00 +end-of-day = 23:59:59 +with-millis = 17:45:00.123 + +# ── Mixed-type and nested arrays ───────────────────────────────────────────── + +[arrays] +integers = [1, 2, 3, 4, 5] +floats = [1.1, 2.2, 3.3] +strings = ["alpha", "beta", "gamma"] +booleans = [true, false, true] +mixed = [1, 1.5, "two", true] + +dates = [ + 1987-07-05T17:45:00Z, + 1979-05-27T07:32:00, + 2006-06-01, + 11:00:00, +] + +nested-strings = [["a", "b"], ["c", "d"], ["e"]] +nested-ints = [[1, 2], [3, 4, 5], [6]] +deeply-nested = [[[1, 2], [3]], [[4, 5]]] + +build-targets = [ + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-pc-windows-msvc", +] diff --git a/tests/data/array-of-tables.toml b/tests/data/array-of-tables.toml new file mode 100644 index 0000000..5117a59 --- /dev/null +++ b/tests/data/array-of-tables.toml @@ -0,0 +1,21 @@ +[[products]] +name = "Hammer" +sku = 738594937 + +[[products]] +name = "Nail" +sku = 284758393 +color = "gray" + +[[fruits]] +name = "apple" + +[fruits.physical] +color = "red" +shape = "round" + +[[fruits.varieties]] +name = "red delicious" + +[[fruits.varieties]] +name = "granny smith" diff --git a/tests/data/arrays.toml b/tests/data/arrays.toml new file mode 100644 index 0000000..1ce7fd0 --- /dev/null +++ b/tests/data/arrays.toml @@ -0,0 +1,11 @@ +# Array types +integers = [1, 2, 3] +strings = ["a", "b", "c"] +mixed_types = [1, "two", 3.0, true] +nested = [[1, 2], [3, 4, 5]] +empty = [] +multiline = [ + 1, + 2, + 3, +] diff --git a/tests/data/booleans.toml b/tests/data/booleans.toml new file mode 100644 index 0000000..db0df2c --- /dev/null +++ b/tests/data/booleans.toml @@ -0,0 +1,3 @@ +# Boolean values +enabled = true +disabled = false diff --git a/tests/data/datetimes.toml b/tests/data/datetimes.toml new file mode 100644 index 0000000..5a53ae5 --- /dev/null +++ b/tests/data/datetimes.toml @@ -0,0 +1,6 @@ +# Date and time types +offset_dt_z = 1979-05-27T07:32:00Z +offset_dt_num = 1979-05-27T07:32:00+05:30 +local_dt = 1979-05-27T07:32:00 +local_date = 1979-05-27 +local_time = 07:32:00 diff --git a/tests/data/floats.toml b/tests/data/floats.toml new file mode 100644 index 0000000..3da6357 --- /dev/null +++ b/tests/data/floats.toml @@ -0,0 +1,9 @@ +# Float values +positive = 3.1415 +negative = -0.01 +exponent = 5e+22 +large = 1e6 +combo = 6.626e-34 +special_inf = inf +special_neg_inf = -inf +special_nan = nan diff --git a/tests/data/full-example.toml b/tests/data/full-example.toml new file mode 100644 index 0000000..00a6050 --- /dev/null +++ b/tests/data/full-example.toml @@ -0,0 +1,22 @@ +# Full TOML 1.0.0 example - valid document +title = "TOML Example" + +[owner] +name = "Tom Preston-Werner" +dob = 1979-05-27T07:32:00-08:00 + +[database] +enabled = true +ports = [ 8001, 8001, 8002 ] +data = [ ["delta", "phi"], [3.14] ] +temp_targets = { cpu = 79.5, case = 72.0 } + +[servers] + +[servers.alpha] +ip = "10.0.0.1" +role = "frontend" + +[servers.beta] +ip = "10.0.0.2" +role = "backend" diff --git a/tests/data/integers.toml b/tests/data/integers.toml new file mode 100644 index 0000000..9d9e862 --- /dev/null +++ b/tests/data/integers.toml @@ -0,0 +1,9 @@ +# All integer representations +decimal = 42 +negative = -17 +positive = +99 +zero = 0 +large = 9_007_199_254_740_992 +hex = 0xDEADBEEF +octal = 0o755 +binary = 0b11010110 diff --git a/tests/data/strings.toml b/tests/data/strings.toml new file mode 100644 index 0000000..ae05e4c --- /dev/null +++ b/tests/data/strings.toml @@ -0,0 +1,12 @@ +# Strings - all four TOML string types +basic = "I'm a string. \"You can quote me\". Name\tTab\nNewLine." +literal = 'C:\Users\nodejs\templates' +multiline_basic = """ +Roses are red +Violets are blue""" +multiline_literal = ''' +First paragraph. + +Second paragraph.''' +escaped_unicode = "\u03B1\u03B2\u03B3" +empty = "" diff --git a/tests/data/tables.toml b/tests/data/tables.toml new file mode 100644 index 0000000..c857d63 --- /dev/null +++ b/tests/data/tables.toml @@ -0,0 +1,9 @@ +# Table types +[simple] +key = "value" + +[dotted.key] +works = true + +[inline_parent] +inline = { one = 1, two = 2 }