Public/Move-HDTStep.ps1

function Move-HDTStep {
    <#
        .SYNOPSIS
            Moves a step or group up or down past its neighbour, carrying the
            comment that explains it.
 
        .DESCRIPTION
            The Up and Down buttons, and the cmdlet an administrator can type
            instead.
 
            REORDERING IS MOST OF WHAT EDITING A TASK SEQUENCE IS, and it is the
            operation a text splice makes least obvious: a move is a removal and
            an insertion of the SAME lines, which is why it creates and destroys
            nothing and the line count is unchanged.
 
            THE COMMENT MOVES WITH THE STEP. Every comment in DEMO-M4 explains
            the step beneath it - why minRamMB is 2048, what ConfigureBoot does
            to the firmware boot order. A move that took only the dash line
            would leave that explanation attached to whatever slid up into its
            place, and the file would then assert something false about a step
            nobody edited.
 
            IT MOVES PAST A SIBLING, NOT OUT OF A GROUP. Up on the first step in
            a group is refused rather than interpreted, because "before the
            group" and "the last step of the group above" are both plausible and
            the console must not guess - the same refusal DiskPartition makes
            about an ambiguous target. Moving a step between groups
            is Copy, Paste and Remove, where each half is visible.
 
            A GROUP MOVES WHOLE. Its block spans its steps, so they travel with
            it and their order inside it is untouched.
 
        .PARAMETER Line
            The document, already split into lines.
 
        .PARAMETER Name
            The step or group to move.
 
        .PARAMETER Direction
            Up or Down.
 
        .INPUTS
            None. This command does not accept pipeline input.
 
        .OUTPUTS
            System.String[] - the document with the block moved.
 
        .EXAMPLE
            Move-HDTStep -Line $line -Name 'Apply OS' -Direction Down
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [AllowEmptyCollection()]
        [AllowEmptyString()]
        [string[]] $Line,

        [Parameter(Mandatory = $true, Position = 1)]
        [ValidateNotNullOrEmpty()]
        [string] $Name,

        [Parameter(Mandatory = $true, Position = 2)]
        [ValidateSet('Up', 'Down')]
        [string] $Direction
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    # ONE CALL TO THE LOCATOR, AND EVERYTHING FOUND INSIDE ITS RESULT. Resolving
    # the name separately would build a SECOND set of block objects, and an
    # identity comparison against the first set then matches nothing - which
    # reads as "this step is already first" for every step in the document.
    $block = @(Get-HDTStepBlock -Line $Line)

    $found = Resolve-HDTStepBlock -Line $Line -Name $Name
    $current = @($block | Where-Object { $_.Entry -eq $found.Entry })[0]

    # A SIBLING SHARES A PARENT, NOT MERELY AN INDENTATION. Every step in the
    # document sits at the same column, so matching on indentation alone would
    # make the last step of one group the neighbour of the first step of the
    # next - and Down would silently move a step across a group boundary, which
    # is the one thing this command refuses to do.
    $parent = @(Get-HDTStepParent -Block $block)

    $at = [array]::IndexOf($block, $current)
    $sibling = @($block | Where-Object { $_.Indent -eq $current.Indent -and $parent[[array]::IndexOf($block, $_)] -eq $parent[$at] })

    $at = [array]::IndexOf($sibling, $current)

    if ($Direction -eq 'Up' -and $at -le 0) {
        throw (New-HDTErrorRecord -Path $Name -Category InvalidOperation `
                -Message ("'{0}' is already the first step of its group, and moving it further up would mean moving it OUT of the group - which could mean before the group or into the one above it. Use Copy, Paste and Remove, where each half of that is visible." -f $Name))
    }

    if ($Direction -eq 'Down' -and $at -ge ($sibling.Count - 1)) {
        throw (New-HDTErrorRecord -Path $Name -Category InvalidOperation `
                -Message ("'{0}' is already the last step of its group, and moving it further down would mean moving it OUT of the group. Use Copy, Paste and Remove, where each half of that is visible." -f $Name))
    }

    if (-not $PSCmdlet.ShouldProcess($Name, ('Move {0}' -f $Direction))) {
        return [string[]] @($Line)
    }

    $other = $sibling[$at - 1]
    if ($Direction -eq 'Down') { $other = $sibling[$at + 1] }

    # The two blocks, in document order, and the lines each owns.
    $first = $current
    $second = $other
    if ($current.Start -gt $other.Start) {
        $first = $other
        $second = $current
    }

    $firstText = @($Line[$first.Start..$first.End])
    $secondText = @($Line[$second.Start..$second.End])

    # Whatever sits BETWEEN them - the blank line, and nothing else - stays
    # between them, so the spacing is the same after the swap as before it.
    $between = @()
    if (($second.Start - 1) -ge ($first.End + 1)) {
        $between = @($Line[($first.End + 1)..($second.Start - 1)])
    }

    $result = New-Object -TypeName System.Collections.ArrayList

    for ($i = 0; $i -lt $first.Start; $i++) { [void] $result.Add($Line[$i]) }

    foreach ($text in $secondText) { [void] $result.Add($text) }
    foreach ($text in $between) { [void] $result.Add($text) }
    foreach ($text in $firstText) { [void] $result.Add($text) }

    for ($i = $second.End + 1; $i -lt $Line.Count; $i++) { [void] $result.Add($Line[$i]) }

    return [string[]] @($result)
}