Public/Edit-StringAtLine.ps1

<#
.SYNOPSIS
Replaces the first occurrence of a string in pipeline or parameter input, optionally preserving line structure for multiline replacement.

.DESCRIPTION
Edit-StringAtLine finds the first match of StringToMatch in the input string and replaces it with ReplacementString. When the replacement spans multiple lines, -Multiline outputs each resulting line separately; otherwise a single string is output. Useful for in-memory or pipeline text editing when you need to replace a pattern once and preserve alignment.

.PARAMETER InputString
The string to search and replace in. Accepts pipeline input and pipeline-by-property-name (e.g. from Get-Content -Raw).

.PARAMETER StringToMatch
The regular expression to find. Only the first match is replaced. Escape literal text with [regex]::Escape() when it contains regex characters.

.PARAMETER ReplacementString
The literal text to replace the match with (substitutions such as $1 are not expanded). Can be multiline; additional lines are indented to the column where the match started.

.PARAMETER Multiline
When set, outputs the result as one string per line. When not set, outputs a single string with "`r`n" between lines.
If there is no match the input is output unchanged (split into lines when -Multiline is set).

.EXAMPLE
Get-Content -Path 'C:\Script.ps1' -Raw | Edit-StringAtLine -StringToMatch 'Version = ''1.0''' -ReplacementString 'Version = ''2.0'''

Replaces the first occurrence of the version string in the file content and outputs the modified string.

.EXAMPLE
$text = "Line one`nLine two`nLine three"
$text | Edit-StringAtLine -StringToMatch 'Line two' -ReplacementString "Line two`n indented" -Multiline

Replaces "Line two" with two lines and outputs each line separately; the second line is indented to the match position.

.INPUTS
System.String. Input string can be piped by value or by property name (InputString).

.OUTPUTS
System.String. The modified string, or when -Multiline is set, one string per line.

.NOTES
Author: TheCodeSaiyan
Uses [regex]::Match; not compatible with Constrained Language Mode if that restricts the Regex type.
#>

function Edit-StringAtLine {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [AllowEmptyString()]
        [string]
        $InputString,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]
        $StringToMatch,

        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]
        $ReplacementString,

        [switch]
        $Multiline
    )

    begin {
        $TelemetryArgs = @{
            ModuleName    = $MyInvocation.MyCommand.Module.Name
            ModuleVersion = [string]$MyInvocation.MyCommand.Module.Version
            CommandName   = $MyInvocation.MyCommand.Name
            ExecutionID   = [guid]::NewGuid().ToString()
        }
        Invoke-TelemetryCollection @TelemetryArgs -Stage Start -ClearTimer
        $TelemetryFailed = $false
    }

    process {
        try {
            Write-Verbose "Input string: $($InputString.Length) characters long."
            $match = [regex]::Match($InputString, $StringToMatch)

            if (-not $match.Success) {
                Write-Verbose "No match for '$StringToMatch'; the input is returned unchanged."
                if ($Multiline) {
                    return $InputString -split "`r?`n"
                }
                return $InputString
            }

            # Column of the match on its line, used to indent additional replacement lines
            $lineStart = $InputString.LastIndexOf("`n", [Math]::Max($match.Index - 1, 0))
            if ($match.Index -eq 0 -or $lineStart -lt 0) {
                $lineStart = 0
            }
            else {
                $lineStart += 1
            }
            $positionOnLine = $match.Index - $lineStart
            Write-Verbose "Match found at position $($match.Index) (column $positionOnLine)."

            $replacementLines = @($ReplacementString -split "`r?`n")
            for ($i = 1; $i -lt $replacementLines.Count; $i++) {
                $replacementLines[$i] = (' ' * $positionOnLine) + $replacementLines[$i]
            }

            $result = $InputString.Substring(0, $match.Index) + ($replacementLines -join "`n") + $InputString.Substring($match.Index + $match.Length)
            $resultLines = $result -split "`r?`n"

            if ($Multiline) {
                $resultLines
            }
            else {
                $resultLines -join "`r`n"
            }
        }
        catch {
            if (-not $TelemetryFailed) {
                $TelemetryFailed = $true
                Invoke-TelemetryCollection @TelemetryArgs -Stage End -Failed $true -Exception $_
            }
            throw
        }
    }

    end {
        if (-not $TelemetryFailed) {
            Invoke-TelemetryCollection @TelemetryArgs -Stage End
        }
    }
}