Classes/IniDocument.ps1
|
class IniDocument : AbstractIniDocument { IniDocument() { $this.Lines = [System.Collections.Generic.List[AbstractIniLine]]::new() $this.RebuildSections() } static [IniDocument] Parse([string[]]$Lines, [IniInlineCommentMode]$InlineCommentMode) { $doc = [IniDocument]::new() $doc.InlineCommentMode = $InlineCommentMode # Documents built by parsing existing content preserve its original spacing; New-IniDocument normalizes $doc.PreserveDelimiterSpacing = $true $doc.PreserveCommentSpacing = $true $currentSection = "" # Sample the first 10 key/value separators to auto-detect the spacing and delimiter style $sampledSeparators = 0 $spacedSeparators = 0 $colonSeparators = 0 # Detect the comment symbol up front so inline-comment parsing can use it $sampledComments = 0 $hashComments = 0 foreach ($probe in $Lines) { if ($probe -match "^\s*([;#])") { $sampledComments++ if ($matches[1] -eq "#") { $hashComments++ } if ($sampledComments -ge 10) { break } } } if ($sampledComments -gt 0 -and ($hashComments * 2) -gt $sampledComments) { $doc.CommentSymbol = "#" } $symbol = $doc.CommentSymbol $mode = $doc.InlineCommentMode switch -regex ($Lines) { # Match blank lines "^\s*$" { $doc.Lines.Add([IniBlankLine]::new($doc, $currentSection)) continue } # Match comments (starts with ; or #) "^\s*([;#])( *)(.*)" { $doc.Lines.Add([IniCommentLine]::new($doc, $currentSection, $matches[1], $matches[3], $matches[2].Length)) continue } # Match sections [SectionName] with optional trailing inline comment "^\s*\[(.+?)\]\s*(.*)$" { $currentSection = $matches[1].Trim() $sectionLine = [IniSectionLine]::new($doc, $currentSection) if ($mode -ne [IniInlineCommentMode]::None -and $matches[2]) { $trailing = $matches[2] $idx = if ($mode -eq [IniInlineCommentMode]::First) { $trailing.IndexOf($symbol) } else { $trailing.LastIndexOf($symbol) } if ($idx -ge 0) { $sectionLine.InlineComment = [IniComment]::Parse($trailing.Substring($idx)) } } $doc.Lines.Add($sectionLine) continue } # Match key = value or key : value pairs "^\s*(.+?)( *)([:=])( *)(.*)" { $name = $matches[1].Trim() $foundDelimiter = $matches[3] $value = $matches[5].Trim() $spacing = [IniKeyValueLine]::DetectSpacing($matches[2].Length, $matches[4].Length) $inline = $null if ($mode -ne [IniInlineCommentMode]::None) { $idx = if ($mode -eq [IniInlineCommentMode]::First) { $value.IndexOf($symbol) } else { $value.LastIndexOf($symbol) } if ($idx -ge 0) { $inline = [IniComment]::Parse($value.Substring($idx)) $value = $value.Substring(0, $idx).Trim() } } $kv = [IniKeyValueLine]::new($doc, $currentSection, $name, $value, $foundDelimiter) $kv.DelimiterSpacing = $spacing $kv.InlineComment = $inline $doc.Lines.Add($kv) if ($sampledSeparators -lt 10) { $sampledSeparators++ if ($_ -match "\s[:=]\s") { $spacedSeparators++ } if ($foundDelimiter -eq ":") { $colonSeparators++ } } continue } } # Spaced separators win only if they are the majority of the sample if ($sampledSeparators -gt 0) { $doc.DelimiterSpacing = if (($spacedSeparators * 2) -gt $sampledSeparators) { [IniDelimiterSpacing]::Spaces } else { [IniDelimiterSpacing]::None } # Colon wins as the delimiter only if it is the majority of the sample if (($colonSeparators * 2) -gt $sampledSeparators) { $doc.Delimiter = ":" } } # Empty-value lines have no trailing spacing to detect, so they follow the document's DelimiterSpacing foreach ($line in $doc.Lines) { if ($line.IsKeyValue() -and $line.Value -eq "") { $line.DelimiterSpacing = $doc.DelimiterSpacing } } $doc.RebuildSections() return $doc } static [IniDocument] Parse([string[]]$Lines) { return [IniDocument]::Parse($Lines, [IniInlineCommentMode]::None) } static [IniDocument] Load([string]$FilePath, [IniInlineCommentMode]$InlineCommentMode) { $doc = [IniDocument]::Parse([System.IO.File]::ReadAllLines($FilePath), $InlineCommentMode) $doc.Path = $FilePath return $doc } static [IniDocument] Load([string]$FilePath) { return [IniDocument]::Load($FilePath, [IniInlineCommentMode]::None) } # Index of the last line owned by a section, or -1 if the section has no lines yet hidden [int] LastIndexOfSection([string]$Section) { for ($i = $this.Lines.Count - 1; $i -ge 0; $i--) { if ($this.Lines[$i].Section -eq $Section) { return $i } } return -1 } # Index of the last key/value line for a key within a section, or -1 if the key is not present hidden [int] LastIndexOfKey([string]$Section, [string]$Key) { for ($i = $this.Lines.Count - 1; $i -ge 0; $i--) { $line = $this.Lines[$i] if ($line.IsKeyValue() -and $line.Section -eq $Section -and $line.Key -eq $Key) { return $i } } return -1 } # Insert a line at the end of its section, creating a section header for new named sections hidden [void] AppendLine([AbstractIniLine]$Line) { $section = $Line.Section $idx = $this.LastIndexOfSection($section) if ($idx -ge 0) { $this.Lines.Insert($idx + 1, $Line) } elseif ($section -eq "") { # Reached only when no top-level line exists yet, so every existing line belongs to a # section; insert ahead of them all to keep top-level entries first. $this.Lines.Insert(0, $Line) } else { $this.Lines.Add([IniSectionLine]::new($this, $section)) $this.Lines.Add($Line) } } # Rebuild the Sections read projection and per-section key-width cache from the line list hidden [void] RebuildSections() { $proj = [ordered]@{} $proj[""] = [ordered]@{} $widths = [System.Collections.Generic.Dictionary[string, int]]::new() foreach ($line in $this.Lines) { if ($line.IsSection()) { if (-not $proj.Contains($line.Section)) { $proj[$line.Section] = [ordered]@{} } } elseif ($line.IsKeyValue()) { $sec = $line.Section # The width cache tracks every key in the section, regardless of disabled state if (-not $widths.ContainsKey($sec)) { $widths[$sec] = 0 } if ($line.Key.Length -gt $widths[$sec]) { $widths[$sec] = $line.Key.Length } if (-not $line.IsDisabled) { if (-not $proj.Contains($sec)) { $proj[$sec] = [ordered]@{} } if ($proj[$sec].Contains($line.Key)) { $proj[$sec][$line.Key] = @($proj[$sec][$line.Key]) + $line.Value } else { $proj[$sec][$line.Key] = $line.Value } } } } $this.Sections = $proj $this.KeyWidths = $widths } # When $Scoped is true, only lines belonging to $Section are considered hidden [void] CommentWhere([scriptblock]$Predicate, [bool]$Scoped, [string]$Section) { for ($i = 0; $i -lt $this.Lines.Count; $i++) { $line = $this.Lines[$i] if ($Scoped -and $line.Section -ne $Section) { continue } if ($line.IsKeyValue()) { # Match and store the line exactly as it renders (delimiter, spacing, inline comment) $lineContent = $line.ToString() if (& $Predicate $lineContent) { $this.Lines[$i] = [IniCommentLine]::new($this, $line.Section, $this.CommentSymbol, $lineContent) } } elseif ($line.IsSection()) { $lineContent = $line.ToString() if (& $Predicate $lineContent) { # The section's lines fall back to the enclosing (previous) section $previousSection = "" for ($k = $i - 1; $k -ge 0; $k--) { if ($this.Lines[$k].IsSection()) { $previousSection = $this.Lines[$k].Section break } } for ($j = $i + 1; $j -lt $this.Lines.Count; $j++) { if ($this.Lines[$j].IsSection()) { break } $this.Lines[$j].Section = $previousSection } $this.Lines[$i] = [IniCommentLine]::new($this, $previousSection, $this.CommentSymbol, $lineContent) } } } $this.RebuildSections() } # When $Scoped is true, only lines belonging to $Section are considered hidden [void] UncommentWhere([scriptblock]$Predicate, [bool]$Scoped, [string]$Section) { for ($i = 0; $i -lt $this.Lines.Count; $i++) { $line = $this.Lines[$i] if (-not $line.IsCommentText()) { continue } if ($Scoped -and $line.Section -ne $Section) { continue } # Content is marker-free; MatchKey normalizes the single separator space for exact matching $lineContent = $line.Comment.Content $matchKey = $line.Comment.MatchKey() if (-not (& $Predicate $matchKey)) { continue } if ($lineContent -match "^\s*\[(.+)\]\s*$") { # Uncomment a section header and re-scope the following lines into it $name = $matches[1].Trim() $this.Lines[$i] = [IniSectionLine]::new($this, $name) for ($j = $i + 1; $j -lt $this.Lines.Count; $j++) { if ($this.Lines[$j].IsSection()) { break } $this.Lines[$j].Section = $name } } elseif ($lineContent -match "^\s*(.+?)( *)([:=])( *)(.*)") { # Uncomment a key/value line in place, preserving its original delimiter spacing $uncommentedValue = $matches[5].Trim() $kv = [IniKeyValueLine]::new($this, $line.Section, $matches[1].Trim(), $uncommentedValue, $matches[3]) # With no value there is no trailing spacing to detect, so inherit the document's setting $kv.DelimiterSpacing = if ($uncommentedValue -eq "") { $this.DelimiterSpacing } else { [IniKeyValueLine]::DetectSpacing($matches[2].Length, $matches[4].Length) } $this.Lines[$i] = $kv } } $this.RebuildSections() } # Add a value while recording a specific delimiter for that line (used by the parser) [void] Add([string]$Section, [string]$Key, [string]$Value, [string]$LineDelimiter) { if ([string]::IsNullOrEmpty($Key)) { throw [System.ArgumentException]::new('Key cannot be null or empty.', 'Key') } $line = [IniKeyValueLine]::new($this, $Section, $Key, $Value, $LineDelimiter) # Keep repeated keys together: insert right after the last existing instance of the key $keyIdx = $this.LastIndexOfKey($Section, $Key) if ($keyIdx -ge 0) { $this.Lines.Insert($keyIdx + 1, $line) } else { $this.AppendLine($line) } $this.RebuildSections() } # Add a value to a key in a section; repeated keys collect into an array of values. # New additions record the document's current Delimiter as their original delimiter. [void] Add([string]$Section, [string]$Key, [string]$Value) { $this.Add($Section, $Key, $Value, $this.Delimiter) } # Add a top-level value (no section) using the current Delimiter [void] Add([string]$Key, [string]$Value) { $this.Add("", $Key, $Value) } # Append a comment line to a section. The text may include a leading ";" or "#" marker, # which is split off into the comment symbol; otherwise the document's CommentSymbol is used. [void] AddComment([string]$Section, [string]$Text) { if ($Text -match "^\s*([;#])( *)(.*)$") { $symbol = $matches[1] $spaceCount = $matches[2].Length $content = $matches[3] } else { $symbol = $this.CommentSymbol $spaceCount = 1 $content = $Text } $this.AppendLine([IniCommentLine]::new($this, $Section, $symbol, $content, $spaceCount)) $this.RebuildSections() } # Append a top-level comment (no section) [void] AddComment([string]$Text) { $this.AddComment("", $Text) } # Add an empty section header if the section does not already exist [void] AddSection([string]$Section) { if ([string]::IsNullOrEmpty($Section)) { throw [System.ArgumentException]::new('Section name cannot be null or empty.', 'Section') } if ($this.LastIndexOfSection($Section) -lt 0) { $this.Lines.Add([IniSectionLine]::new($this, $Section)) $this.RebuildSections() } } # Comment out a key/value or section line by exact, case-sensitive text [void] Comment([string]$Text) { if ([string]::IsNullOrEmpty($Text)) { throw [System.ArgumentException]::new('Text cannot be null or empty.', 'Text') } $this.CommentWhere({ param($line) $line -ceq $Text }, $false, "") } # Comment out matching lines only within a specific section [void] CommentPattern([string]$Section, [string]$Pattern, [bool]$CaseSensitive) { if ([string]::IsNullOrEmpty($Pattern)) { throw [System.ArgumentException]::new('Pattern cannot be null or empty.', 'Pattern') } if ($CaseSensitive) { $this.CommentWhere({ param($line) $line -cmatch $Pattern }, $true, $Section) } else { $this.CommentWhere({ param($line) $line -imatch $Pattern }, $true, $Section) } } [void] CommentPattern([string]$Section, [string]$Pattern) { $this.CommentPattern($Section, $Pattern, $false) } # Comment out key/value or section lines whose text matches a regex pattern (case-insensitive by default). # Pass $true for $CaseSensitive to match case-sensitively (-cmatch) [void] CommentPattern([string]$Pattern, [bool]$CaseSensitive) { if ([string]::IsNullOrEmpty($Pattern)) { throw [System.ArgumentException]::new('Pattern cannot be null or empty.', 'Pattern') } if ($CaseSensitive) { $this.CommentWhere({ param($line) $line -cmatch $Pattern }, $false, "") } else { $this.CommentWhere({ param($line) $line -imatch $Pattern }, $false, "") } } [void] CommentPattern([string]$Pattern) { $this.CommentPattern($Pattern, $false) } # Get a key's value from a section; returns $null if absent, or an array for repeated keys [object] Get([string]$Section, [string]$Key) { if ([string]::IsNullOrEmpty($Key)) { throw [System.ArgumentException]::new('Key cannot be null or empty.', 'Key') } if ($this.Sections.Contains($Section) -and $this.Sections[$Section].Contains($Key)) { return $this.Sections[$Section][$Key] } return $null } # Get a top-level (no-section) key's value [object] Get([string]$Key) { return $this.Get("", $Key) } # Remove a key and all of its values from a section [void] Remove([string]$Section, [string]$Key) { if ([string]::IsNullOrEmpty($Key)) { throw [System.ArgumentException]::new('Key cannot be null or empty.', 'Key') } for ($i = $this.Lines.Count - 1; $i -ge 0; $i--) { $line = $this.Lines[$i] if ($line.IsKeyValue() -and $line.Section -eq $Section -and $line.Key -eq $Key) { $this.Lines.RemoveAt($i) } } $this.RebuildSections() } # Remove a top-level key (no section) [void] Remove([string]$Key) { $this.Remove("", $Key) } # Clear the inline comment on a key/value line, if it has one [void] RemoveInlineComment([string]$Section, [string]$Key) { if ([string]::IsNullOrEmpty($Key)) { throw [System.ArgumentException]::new('Key cannot be null or empty.', 'Key') } foreach ($line in $this.Lines) { if ($line.IsKeyValue() -and $line.Section -eq $Section -and $line.Key -eq $Key) { $line.InlineComment = $null break } } } # Clear a top-level (no-section) key's inline comment [void] RemoveInlineComment([string]$Key) { $this.RemoveInlineComment("", $Key) } # Remove a section and every line that belongs to it, leaving other sections untouched [void] RemoveSection([string]$Section) { if ([string]::IsNullOrEmpty($Section)) { throw [System.ArgumentException]::new('Section name cannot be null or empty.', 'Section') } for ($i = $this.Lines.Count - 1; $i -ge 0; $i--) { if ($this.Lines[$i].Section -eq $Section) { $this.Lines.RemoveAt($i) } } $this.RebuildSections() } # Replace a comment's text (case-sensitive match on the content after the marker). # A leading ";"/"#" marker in either argument is ignored; the line keeps its own symbol. [void] ReplaceComment([string]$OldText, [string]$NewText) { if ([string]::IsNullOrEmpty($OldText)) { throw [System.ArgumentException]::new('OldText cannot be null or empty.', 'OldText') } $oldContent = [IniComment]::Parse($OldText).Content $newContent = [IniComment]::Parse($NewText).Content foreach ($line in $this.Lines) { if ($line.IsCommentText() -and $line.Comment.Content -ceq $oldContent) { $line.Comment.Content = $newContent } } } [void] Save([string]$FilePath) { if ([string]::IsNullOrEmpty($FilePath)) { throw [System.ArgumentException]::new('FilePath cannot be null or empty.', 'FilePath') } # Remember the path so a later Save() with no arguments can reuse it $this.Path = $FilePath Set-Content -Path $FilePath -Value $this.ToString() -Encoding utf8 -NoNewline } # Save to the document's remembered path (set by Load or a prior Save) [void] Save() { if ([string]::IsNullOrEmpty($this.Path)) { throw "No path is set on this document; call Save(path) or load it from a file first." } $this.Save($this.Path) } # Set a key's value in a section, replacing any existing value(s). # The key adopts the document's current Delimiter as its original delimiter. [void] Set([string]$Section, [string]$Key, [string]$Value) { if ([string]::IsNullOrEmpty($Key)) { throw [System.ArgumentException]::new('Key cannot be null or empty.', 'Key') } $existing = @($this.Lines | Where-Object { $_.IsKeyValue() -and -not $_.IsDisabled -and $_.Section -eq $Section -and $_.Key -eq $Key }) if ($existing.Count -gt 0) { $existing[0].Value = $Value $existing[0].Delimiter = $this.Delimiter for ($i = 1; $i -lt $existing.Count; $i++) { $this.Lines.Remove($existing[$i]) | Out-Null } } else { $this.AppendLine([IniKeyValueLine]::new($this, $Section, $Key, $Value, $this.Delimiter)) } $this.RebuildSections() } # Set a top-level value (no section) [void] Set([string]$Key, [string]$Value) { $this.Set("", $Key, $Value) } # Set (or, with empty text, clear) the inline comment on a key/value line [void] SetInlineComment([string]$Section, [string]$Key, [string]$Text) { if ([string]::IsNullOrEmpty($Key)) { throw [System.ArgumentException]::new('Key cannot be null or empty.', 'Key') } if ([string]::IsNullOrEmpty($Text)) { $this.RemoveInlineComment($Section, $Key) return } foreach ($line in $this.Lines) { if ($line.IsKeyValue() -and $line.Section -eq $Section -and $line.Key -eq $Key) { $line.InlineComment = [IniComment]::Parse($Text) break } } } # Set a top-level (no-section) key's inline comment [void] SetInlineComment([string]$Key, [string]$Text) { $this.SetInlineComment("", $Key, $Text) } # Uncomment commented lines by exact, case-sensitive text. # A leading ";"/"#" marker in the input is ignored, so a whole comment line can be pasted in. [void] Uncomment([string]$Text) { if ([string]::IsNullOrEmpty($Text)) { throw [System.ArgumentException]::new('Text cannot be null or empty.', 'Text') } $key = [IniComment]::Parse($Text).MatchKey() $this.UncommentWhere({ param($line) $line -ceq $key }, $false, "") } # Uncomment matching commented lines only within a specific section [void] UncommentPattern([string]$Section, [string]$Pattern, [bool]$CaseSensitive) { if ([string]::IsNullOrEmpty($Pattern)) { throw [System.ArgumentException]::new('Pattern cannot be null or empty.', 'Pattern') } if ($CaseSensitive) { $this.UncommentWhere({ param($line) $line -cmatch $Pattern }, $true, $Section) } else { $this.UncommentWhere({ param($line) $line -imatch $Pattern }, $true, $Section) } } [void] UncommentPattern([string]$Section, [string]$Pattern) { $this.UncommentPattern($Section, $Pattern, $false) } # Uncomment commented lines whose text matches a regex pattern (case-insensitive by default). # Pass $true for $CaseSensitive to match case-sensitively (-cmatch) [void] UncommentPattern([string]$Pattern, [bool]$CaseSensitive) { if ([string]::IsNullOrEmpty($Pattern)) { throw [System.ArgumentException]::new('Pattern cannot be null or empty.', 'Pattern') } if ($CaseSensitive) { $this.UncommentWhere({ param($line) $line -cmatch $Pattern }, $false, "") } else { $this.UncommentWhere({ param($line) $line -imatch $Pattern }, $false, "") } } [void] UncommentPattern([string]$Pattern) { $this.UncommentPattern($Pattern, $false) } [string] ToString() { $output = [System.Collections.Generic.List[string]]::new() # Group lines by section, preserving first-appearance order $order = [System.Collections.Generic.List[string]]::new() $groups = @{} foreach ($line in $this.Lines) { if (-not $groups.ContainsKey($line.Section)) { $groups[$line.Section] = [System.Collections.Generic.List[AbstractIniLine]]::new() $order.Add($line.Section) } $groups[$line.Section].Add($line) } $sectionOrder = @($order) if ($this.SortSections) { # Keep top-level keys (empty section) first, then sort the named sections $sectionOrder = @($sectionOrder | Where-Object { $_ -eq "" }) + @($sectionOrder | Where-Object { $_ -ne "" } | Sort-Object) } foreach ($sectionName in $sectionOrder) { $sectionLines = $groups[$sectionName] if ($sectionName -ne "") { if ($output.Count -gt 0 -and $null -ne $this.SectionSpacing) { # Normalize the gap before this section to exactly SectionSpacing blank lines while ($output.Count -gt 0 -and $output[$output.Count - 1] -eq "") { $output.RemoveAt($output.Count - 1) } for ($i = 0; $i -lt $this.SectionSpacing; $i++) { $output.Add("") } } # Prefer the stored header line so its inline comment is preserved $header = $sectionLines | Where-Object { $_.IsSection() } | Select-Object -First 1 if ($null -ne $header) { $output.Add($header.ToString()) } else { $output.Add("[$sectionName]") } } # Section headers are synthesized above, so drop stored header lines from the body $body = @($sectionLines | Where-Object { -not $_.IsSection() }) if ($this.SortKeys) { # Alphabetize key/value lines into the slots they occupy; comments/blanks keep their positions $sortedKV = @($body | Where-Object { $_.IsKeyValue() -and -not $_.IsDisabled } | Sort-Object -Property Key) $next = 0 $body = foreach ($line in $body) { if ($line.IsKeyValue() -and -not $line.IsDisabled) { $sortedKV[$next] $next++ } else { $line } } } foreach ($line in $body) { if ($this.StripComments -and ($line.IsCommentText() -or ($line.IsKeyValue() -and $line.IsDisabled))) { continue } $output.Add($line.ToString()) } } return ($output -join [System.Environment]::NewLine) + [System.Environment]::NewLine } } |