lib/promptgen.ps1
|
# promptgen.ps1 -- write both halves of a generated style's prompt. # # Dot-sourced by tstyles.ps1, so everything here shares its $script: scope. # # Pure: takes a shape, a symbol, colours and a title and returns the text of # profile.ps1 (PowerShell) and prompt.sh (zsh/bash). No I/O. The two halves # promise to render byte-identically, as every shipped style's do, and # tests/Style-PromptParity.Tests.ps1 renders generated ones to hold them to it. # # The PowerShell half is not new code. Each shape is one of the placeholders # shell/tstyles.sh already expands, and the PowerShell that matches it is # copied from the style that already proved the match: {LEAF} from # styles/sober/profile.ps1, {CWD} and the git branch from # styles/gitbash/profile.ps1. # # ENCODING. profile.ps1 comes out ASCII-only, whatever the symbol or title: # Windows PowerShell 5.1 reads a BOM-less .ps1 in the system codepage, so a # literal U+276F in a string literal decodes as mojibake or breaks the parse. # Anything outside printable ASCII is emitted as [char]0xNNNN instead. This # file is held to the same rule, which is why the symbols below are built # with [char] too. prompt.sh is UTF-8 and carries the symbol as text; both # halves then print the same UTF-8 bytes. function Get-StylePromptShapes { # The prompt shapes tstyles create offers, in display order. # minimal the folder name, then the symbol {LEAF} # path the ~-abbreviated path, then the symbol {CWD} # twoline the path, and the symbol on the next line {CWD}{NL} # git the folder name and git branch, then the symbol {LEAF}{GITBRANCH} @('minimal', 'path', 'twoline', 'git') } function Get-StylePromptSymbols { # The prompt symbols on offer. Built from code points: see ENCODING above. [OutputType([string[]])] param() [string[]]@( '$', '>', '#', [string][char]0x276F, # heavy right-pointing angle quotation mark [string][char]0x03BB, # lambda [string][char]0x00BB # right-pointing double angle quotation mark ) } function ConvertTo-PromptGenRgb { # '#rrggbb' -> 'r;g;b', the parameter list of a 24-bit SGR. Internal. param([Parameter(Mandatory)][string]$Hex) $r = [Convert]::ToInt32($Hex.Substring(1, 2), 16) $g = [Convert]::ToInt32($Hex.Substring(3, 2), 16) $b = [Convert]::ToInt32($Hex.Substring(5, 2), 16) "$r;$g;$b" } function ConvertTo-PromptGenPsExpression { # Text -> an ASCII-only PowerShell expression that evaluates to it: # single-quoted runs of printable ASCII joined to [char]0xNNNN for # everything else. Internal. param([AllowEmptyString()][string]$Text) if ($Text.Length -eq 0) { return "''" } $parts = New-Object System.Collections.Generic.List[string] $run = '' foreach ($ch in $Text.ToCharArray()) { $code = [int]$ch if ($code -ge 0x20 -and $code -le 0x7E) { $run += [string]$ch } else { if ($run.Length -gt 0) { $parts.Add("'" + ($run -replace "'", "''") + "'"); $run = '' } $parts.Add(('[string][char]0x{0:X4}' -f $code)) } } if ($run.Length -gt 0) { $parts.Add("'" + ($run -replace "'", "''") + "'") } $parts -join ' + ' } function New-StylePromptText { <# .SYNOPSIS Writes profile.ps1 and prompt.sh for a generated style's prompt. .DESCRIPTION Returns @{ Profile = <profile.ps1 text>; Shell = <prompt.sh text> }. The two render the same bytes. Neither touches PSReadLine: the line editor belongs to the user. -Symbol must not carry a character that either shell reinterprets inside a prompt -- '%' (zsh), '\' and '!' (bash), a quote or backtick, a brace (the template's own placeholders), whitespace or a control character -- so the shell half can embed it without escaping beyond '$'. #> param( [Parameter(Mandatory)][string]$Shape, [Parameter(Mandatory)][string]$Symbol, [Parameter(Mandatory)][string]$PathColor, [Parameter(Mandatory)][string]$SymbolColor, [string]$BranchColor, [Parameter(Mandatory)][string]$Title ) if (@(Get-StylePromptShapes) -cnotcontains $Shape) { throw "Unknown prompt shape '$Shape'. Expected one of: $((Get-StylePromptShapes) -join ', ')." } $colors = @{ PathColor = $PathColor; SymbolColor = $SymbolColor } if ($PSBoundParameters.ContainsKey('BranchColor')) { $colors.BranchColor = $BranchColor } foreach ($k in $colors.Keys) { if ($colors[$k] -notmatch '^#[0-9a-fA-F]{6}$') { throw "-$k must be a #rrggbb colour, not '$($colors[$k])'." } } if ($Symbol.Length -eq 0 -or $Symbol -match '[\s%\\!`"''{}]' -or $Symbol -match '[\x00-\x1F\x7F]') { throw "The prompt symbol '$Symbol' carries a character a shell prompt would reinterpret." } if ($Title -match '[\x00-\x1F\x7F]') { throw 'The title must not carry a control character.' } $pathRgb = ConvertTo-PromptGenRgb $PathColor $symRgb = ConvertTo-PromptGenRgb $SymbolColor $hasBranchColor = $colors.ContainsKey('BranchColor') $branchRgb = '' if ($hasBranchColor) { $branchRgb = ConvertTo-PromptGenRgb $BranchColor } $usesLeaf = ($Shape -eq 'minimal') -or ($Shape -eq 'git') $usesBranch = ($Shape -eq 'git') # ---- profile.ps1 --------------------------------------------------------- $ps = New-Object System.Collections.Generic.List[string] $ps.Add('# Generated by tstyles create -- pwsh 7 and Windows PowerShell 5.1.') $ps.Add('# prompt.sh is the zsh/bash half; the two render the same bytes, so') $ps.Add('# change them together. function global:prompt so the binding sticks') $ps.Add('# when dot-sourced from inside tstyles.ps1''s Invoke-TerminalStyle.') $ps.Add('# ASCII-only on purpose: 5.1 reads a BOM-less .ps1 in the system') $ps.Add('# codepage, so the symbol is spelled as [char]0xNNNN.') $ps.Add('') $ps.Add('$Host.UI.RawUI.WindowTitle = ' + (ConvertTo-PromptGenPsExpression $Title)) $ps.Add('') $ps.Add('function global:prompt {') $ps.Add(' $Esc = [char]27') $ps.Add(' $PathC = "$Esc[38;2;' + $pathRgb + 'm"') $ps.Add(' $SymC = "$Esc[38;2;' + $symRgb + 'm"') if ($usesBranch -and $hasBranchColor) { $ps.Add(' $BranchC = "$Esc[38;2;' + $branchRgb + 'm"') } $ps.Add(' $X = "$Esc[0m"') $ps.Add(' $Sym = ' + (ConvertTo-PromptGenPsExpression $Symbol)) if ($usesLeaf) { # styles/sober/profile.ps1: {LEAF} is zsh's %1~. $ps.Add(' # {LEAF} in the zsh/bash half: zsh''s %1~, and ts_leaf in bash.') $ps.Add(' # Abbreviate $HOME to ~ FIRST, then keep the last component --') $ps.Add(' # except where that leaves only one, which zsh prints whole') $ps.Add(' # (''~'', ''/usr'', ''/'').') $ps.Add(' $abbr = $PWD.Path -replace (''^'' + [regex]::Escape($HOME) + ''(?=$|[\\/])''), ''~''') $ps.Add(' $where = if ($abbr -eq ''~'' -or ($abbr -replace ''^[\\/]'', '''') -notmatch ''[\\/]'') {') $ps.Add(' $abbr') $ps.Add(' } else {') $ps.Add(' Split-Path -Leaf $abbr') $ps.Add(' }') } else { # styles/gitbash/profile.ps1: {CWD} is zsh's %~ / bash's \w. $ps.Add(' # {CWD} in the zsh/bash half: %~ / \w. The abbreviation stops at a') $ps.Add(' # path boundary, so a sibling of $HOME such as ~/userXtra is not') $ps.Add(' # shortened to "~Xtra".') $ps.Add(' $where = ($PWD.Path -replace (''^'' + [regex]::Escape($HOME) + ''(?=$|[\\/])''), ''~'') -replace ''\\'', ''/''') } if ($usesBranch) { # styles/gitbash/profile.ps1: ts_git_branch's " (branch)". $ps.Add(' # {GITBRANCH}: " (branch)" in a git worktree, nothing elsewhere.') $ps.Add(' # Save/restore $LASTEXITCODE so calling git here does not') $ps.Add(' # pollute the user''s subsequent error checks.') $ps.Add(' $branchPart = ''''') $ps.Add(' $savedExit = $LASTEXITCODE') $ps.Add(' try {') $ps.Add(' $b = & git rev-parse --abbrev-ref HEAD 2>$null') $ps.Add(' if ($LASTEXITCODE -eq 0 -and $b) {') if ($hasBranchColor) { $ps.Add(' $branchPart = " ${BranchC}($b)${X}"') } else { $ps.Add(' $branchPart = " ($b)"') } $ps.Add(' }') $ps.Add(' } catch { }') $ps.Add(' $global:LASTEXITCODE = $savedExit') } $line = '${PathC}${where}${X}' if ($usesBranch) { $line += '${branchPart}' } if ($Shape -eq 'twoline') { $line += '`n' } else { $line += ' ' } $line += '${SymC}${Sym}${X} ' $ps.Add(' "' + $line + '"') $ps.Add('}') # ---- prompt.sh ----------------------------------------------------------- $placeholder = switch ($Shape) { 'minimal' { '{LEAF}' } 'path' { '{CWD}' } 'twoline' { '{CWD}' } 'git' { '{LEAF}' } } # Inside the double-quoted template only '$' still needs escaping: the # checks above refused every other character the shells would read. $shSym = $Symbol -replace '\$', '\$' $shTitle = "'" + ($Title -replace "'", "'\''") + "'" $tpl = '${_ts_pPath}' + $placeholder + '${_ts_pX}' if ($usesBranch) { $tpl += '{GITBRANCH}' } if ($Shape -eq 'twoline') { $tpl += '{NL}' } else { $tpl += ' ' } $tpl += '${_ts_pSym}' + $shSym + '${_ts_pX} ' $sh = New-Object System.Collections.Generic.List[string] $sh.Add('# Generated by tstyles create -- zsh and bash.') $sh.Add('#') $sh.Add('# profile.ps1 is the PowerShell half; the two render the same bytes, so') $sh.Add('# change them together.') $sh.Add('#') $sh.Add('# Sourced by shell/tstyles.sh, which defines ts_c / ts_title /') $sh.Add('# ts_prompt_expand / ts_prompt_apply. Every name assigned here is') $sh.Add('# _ts_-prefixed: this runs in the user''s own shell.') $sh.Add('') $sh.Add("_ts_pPath=`$(ts_c '$pathRgb')") $sh.Add("_ts_pSym=`$(ts_c '$symRgb')") $sh.Add('_ts_pX=$(ts_x)') if ($usesBranch) { $sh.Add('') $sh.Add('# Colours for the " (branch)" segment ts_git_branch appends. Always') $sh.Add('# assigned, so a branch colour left by an earlier style cannot leak in.') if ($hasBranchColor) { $sh.Add("_ts_git_open=`$(ts_cs '$branchRgb')") $sh.Add('_ts_git_close=$(ts_xs)') } else { $sh.Add("_ts_git_open=''") $sh.Add("_ts_git_close=''") } } $sh.Add('') $sh.Add('ts_title ' + $shTitle) $sh.Add('') $sh.Add('ts_prompt_apply "$(ts_prompt_expand "' + $tpl + '")"') @{ Profile = (($ps -join "`n") + "`n") Shell = (($sh -join "`n") + "`n") } } |