completers/uv_completer/uv_completer.ps1
|
Set-StrictMode -Version Latest if (-not (Get-Variable -Name UvCompletionCache -Scope Script -ErrorAction Ignore)) { $script:UvCompletionCache = @{ ExecutablePaths = @{} ProbedPaths = @{} PathData = @{} StaticTree = @{ '' = @('auth', 'run', 'init', 'add', 'remove', 'version', 'sync', 'lock', 'export', 'tree', 'format', 'check', 'audit', 'tool', 'python', 'pip', 'venv', 'build', 'publish', 'workspace', 'cache', 'self', 'help') 'auth' = @('login', 'logout', 'token', 'dir') 'tool' = @('run', 'install', 'upgrade', 'list', 'audit', 'uninstall', 'update-shell', 'dir') 'python' = @('list', 'install', 'upgrade', 'find', 'pin', 'dir', 'uninstall', 'update-shell') 'pip' = @('compile', 'sync', 'install', 'uninstall', 'freeze', 'list', 'show', 'tree', 'check') 'workspace' = @('metadata', 'dir', 'list') 'cache' = @('clean', 'prune', 'dir', 'size') 'self' = @('update', 'version') } # `uv tool dir` / `uv python dir`, keyed by binary, verb and the env override. Directories = @{} # `uv python list --only-installed` versions, keyed by binary and install dir. PythonVersions = @{} PythonTtlSeconds = 300 # Parsed pyproject.toml files, keyed by path and invalidated by write time. Pyprojects = @{} } } function Get-UvCacheKey { param([string[]]$Path) $pathItems = @($Path) if ($null -eq $Path) { $pathItems = @() } $pathItems = @($pathItems | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) ($pathItems | ForEach-Object { $_.ToLowerInvariant() }) -join ' ' } function Get-UvPathDataCacheKey { param( [string]$SourceName = 'uv', [string[]]$Path ) $sourceKey = if ([string]::IsNullOrWhiteSpace($SourceName)) { 'uv' } else { $SourceName.ToLowerInvariant() } $pathKey = Get-UvCacheKey -Path $Path if ([string]::IsNullOrWhiteSpace($pathKey)) { return $sourceKey } '{0}|{1}' -f $sourceKey, $pathKey } function Get-UvSourceName { param([string]$CommandName) if ([string]::IsNullOrWhiteSpace($CommandName)) { return 'uv' } $leafName = Split-Path -Leaf $CommandName if ($leafName.Equals('uvx', [System.StringComparison]::OrdinalIgnoreCase) -or $leafName.Equals('uvx.exe', [System.StringComparison]::OrdinalIgnoreCase)) { return 'uvx' } 'uv' } function Get-UvSyntheticRootPath { param([string]$SourceName) if ($SourceName -eq 'uvx') { return @('tool', 'run') } @() } function Get-UvExecutablePath { param([string]$CommandName = 'uv') $sourceName = Get-UvSourceName -CommandName $CommandName if ($script:UvCompletionCache.ProbedPaths.ContainsKey($sourceName)) { return $script:UvCompletionCache.ExecutablePaths[$sourceName] } $script:UvCompletionCache.ProbedPaths[$sourceName] = $true $script:UvCompletionCache.ExecutablePaths[$sourceName] = $null $candidateNames = if ($sourceName -eq 'uvx') { @('uvx.exe', 'uvx') } else { @('uv.exe', 'uv') } foreach ($name in $candidateNames) { $command = Get-Command $name -ErrorAction Ignore if ($command) { $script:UvCompletionCache.ExecutablePaths[$sourceName] = $command.Source break } } $script:UvCompletionCache.ExecutablePaths[$sourceName] } function New-UvPathData { param( [string[]]$Commands = @(), [hashtable]$CommandDescriptions = @{}, [string[]]$Options = @(), [System.Collections.IDictionary]$ValuesByOption = @{}, [System.Collections.IDictionary]$MetavarByOption = @{}, [string[]]$PositionalValues = @() ) @{ Commands = @(@($Commands) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) CommandDescriptions = $CommandDescriptions Options = @(@($Options) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) ValuesByOption = $ValuesByOption MetavarByOption = $MetavarByOption PositionalValues = @(@($PositionalValues) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } } function Get-UvTokenText { param([System.Management.Automation.Language.Ast]$Element) if ($Element -is [System.Management.Automation.Language.StringConstantExpressionAst]) { return $Element.Value } if ($Element -is [System.Management.Automation.Language.CommandParameterAst]) { return $Element.Extent.Text } $Element.Extent.Text } function Get-UvUniqueStrings { param( [string[]]$Items, [switch]$CaseSensitive ) # Option tokens stay case-distinct (uv has -V/-v, -C/-c and -P/-p); commands and values fold. $comparer = if ($CaseSensitive) { [System.StringComparer]::Ordinal } else { [System.StringComparer]::OrdinalIgnoreCase } $seen = [System.Collections.Generic.HashSet[string]]::new($comparer) $result = New-Object System.Collections.Generic.List[string] foreach ($item in @($Items)) { if ([string]::IsNullOrWhiteSpace($item)) { continue } if (-not $seen.Add($item)) { continue } [void]$result.Add($item) } @($result.ToArray()) } function Get-UvOptionTokensFromLine { param([string]$Line) if ([string]::IsNullOrWhiteSpace($Line)) { return @() } $tokens = foreach ($match in [regex]::Matches($Line, '(?<!\S)(--?[A-Za-z0-9][A-Za-z0-9\-]*)(?=[\s,<\[]|\.\.\.|$|,)')) { $match.Groups[1].Value } Get-UvUniqueStrings -Items $tokens -CaseSensitive } function Get-UvCanonicalOption { param([string[]]$Tokens) foreach ($token in @($Tokens)) { if ($token.StartsWith('--')) { return $token } } if ($Tokens.Count -gt 0) { return $Tokens[0] } $null } function Add-UvPossibleValues { param( [System.Collections.IDictionary]$ValueMap, [string]$OptionKey, [string]$RawValueText ) if ([string]::IsNullOrWhiteSpace($OptionKey) -or [string]::IsNullOrWhiteSpace($RawValueText)) { return } $valueText = $RawValueText if ($valueText -match '^(.*?)]') { $valueText = $matches[1] } $valueText = $valueText -replace '^(?:possible\s+values:|values:)\s*', '' $values = $valueText -split ',' | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } if (-not $ValueMap.ContainsKey($OptionKey)) { $ValueMap[$OptionKey] = @() } $ValueMap[$OptionKey] = Get-UvUniqueStrings -Items ($ValueMap[$OptionKey] + $values) } function Get-UvParsedHelpData { param([string[]]$HelpLines) $commands = New-Object System.Collections.Generic.List[string] $commandDescriptions = @{} $options = New-Object System.Collections.Generic.List[string] # Ordinal tables: a hashtable would fold -c onto -C and -p onto -P. $valuesByOption = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal) $metavarByOption = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) $positionalValues = New-Object System.Collections.Generic.List[string] $positionalKey = $null $section = '' $currentOption = $null $collectingValuesFor = $null $collectingValueListFor = $null $valueBuffer = '' foreach ($line in @($HelpLines)) { if ($collectingValuesFor) { $trimmed = $line.Trim() if ($trimmed) { if ($valueBuffer) { $valueBuffer += ' ' } $valueBuffer += $trimmed } if ($trimmed -match '\]') { Add-UvPossibleValues -ValueMap $valuesByOption -OptionKey $collectingValuesFor -RawValueText $valueBuffer $collectingValuesFor = $null $valueBuffer = '' } continue } if ($collectingValueListFor) { if ($line -match '^\s*$') { $collectingValueListFor = $null } elseif ($line -match '^\s*-\s+(.+?)(?:\s*:|\s*$)') { Add-UvPossibleValues -ValueMap $valuesByOption -OptionKey $collectingValueListFor -RawValueText $matches[1].Trim() continue } elseif ((@(Get-UvOptionTokensFromLine -Line $line)).Count -gt 0 -or $line -match '^\s*(Commands|Arguments|Examples?|Options|[A-Za-z][A-Za-z ]+ options):\s*$') { $collectingValueListFor = $null } elseif (-not ($line -match '^\s+')) { $collectingValueListFor = $null } else { continue } } if ($line -match '^\s*$') { $currentOption = $null continue } if ($line -match '^\s*(Commands|Arguments|Examples?|Options|[A-Za-z][A-Za-z ]+ options):\s*$') { $section = $matches[1].ToLowerInvariant() $currentOption = $null continue } if ($section -eq 'commands' -and $line -match '^\s{2,}([a-z][a-z0-9\-]*)\s{2,}(.+)$') { $command = $matches[1] [void]$commands.Add($command) $commandDescriptions[$command.ToLowerInvariant()] = $matches[2].Trim() continue } if ($section -eq 'arguments') { # Only the first positional's closed value set is modelled; the # existing wrapped '[possible values: ...]' collector handles the # continuation lines for it. if ($line -match '^\s{2,}(?:<([A-Z_]+)>|\[([A-Z_]+)\])') { $currentOption = $null $positionalKey = if ($positionalValues.Count -eq 0 -and $null -eq $positionalKey) { '<positional>' } else { $null } } if ($positionalKey -and $line -match '\[possible values:\s*(.+)$') { $valueBuffer = $matches[1].Trim() if ($valueBuffer -match '\]') { Add-UvPossibleValues -ValueMap $valuesByOption -OptionKey $positionalKey -RawValueText $valueBuffer $valueBuffer = '' } else { $collectingValuesFor = $positionalKey } } continue } if ($section -like '*options') { $tokens = @(Get-UvOptionTokensFromLine -Line $line) if ($tokens.Count -gt 0) { foreach ($token in $tokens) { [void]$options.Add($token) } $currentOption = Get-UvCanonicalOption -Tokens $tokens # A metavariable after the option names marks a value-bearing # option; its absence marks a switch. The leading name decides # the type even when the metavariable has a suffix # (`--bump <BUMP[=VALUE]>`). $metavar = if ($line -match '^\s*-[^\s]+(?:,\s*-[^\s]+)*(?:\.\.\.)?\s+(?:\[=)?<([A-Z][A-Z0-9_]*)(?:\[[^\]]*\])?>') { $matches[1] } else { '' } foreach ($token in $tokens) { $metavarByOption[$token] = $metavar } } if ($currentOption -and $line -match '\[possible values:\s*(.+)$') { $valueBuffer = $matches[1].Trim() if ($valueBuffer -match '\]') { Add-UvPossibleValues -ValueMap $valuesByOption -OptionKey $currentOption -RawValueText $valueBuffer $valueBuffer = '' } else { $collectingValuesFor = $currentOption } } elseif ($currentOption -and $line -match '\[possible\s*$') { $valueBuffer = '' $collectingValuesFor = $currentOption } elseif ($currentOption -and $line -match '^\s*Possible values:\s*$') { $collectingValueListFor = $currentOption } } } if ($collectingValuesFor -and $valueBuffer) { Add-UvPossibleValues -ValueMap $valuesByOption -OptionKey $collectingValuesFor -RawValueText $valueBuffer } if ($valuesByOption.ContainsKey('<positional>')) { foreach ($value in @($valuesByOption['<positional>'])) { [void]$positionalValues.Add($value) } [void]$valuesByOption.Remove('<positional>') } @{ Commands = Get-UvUniqueStrings -Items $commands.ToArray() CommandDescriptions = $commandDescriptions Options = Get-UvUniqueStrings -Items $options.ToArray() -CaseSensitive ValuesByOption = $valuesByOption MetavarByOption = $metavarByOption PositionalValues = Get-UvUniqueStrings -Items $positionalValues.ToArray() } } function Invoke-UvHelp { param( [string]$CommandName = 'uv', [string[]]$Path ) $pathItems = @($Path) if ($null -eq $Path) { $pathItems = @() } $pathItems = @($pathItems | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) $arguments = @() if (@($pathItems).Count -gt 0) { $arguments += $pathItems } $arguments += '--help' Invoke-UvCommand -CommandName $CommandName -Arguments $arguments } function Invoke-UvCommand { param( [string]$CommandName = 'uv', [string[]]$Arguments ) # Only read-only verbs (`--help`, `tool dir`, `python dir`, `python list # --only-installed`) come through here: stdin closed, both streams drained, # killed after 5 s, ANSI stripped. $uvPath = Get-UvExecutablePath -CommandName $CommandName if (-not $uvPath) { return @() } try { $startInfo = [System.Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $uvPath foreach ($argument in $Arguments) { [void]$startInfo.ArgumentList.Add($argument) } $startInfo.UseShellExecute = $false $startInfo.CreateNoWindow = $true $startInfo.RedirectStandardInput = $true $startInfo.RedirectStandardOutput = $true $startInfo.RedirectStandardError = $true $process = [System.Diagnostics.Process]::Start($startInfo) try { $process.StandardInput.Close() $outputTask = $process.StandardOutput.ReadToEndAsync() [void]$process.StandardError.ReadToEndAsync() if (-not $process.WaitForExit(5000)) { try { $process.Kill($true) } catch { Write-Debug -Message $_.Exception.Message } return @() } $text = ($outputTask.Result -replace '\e\[[0-9;?]*[ -/]*[@-~]', '') if ([string]::IsNullOrEmpty($text)) { return @() } @($text -split '\r?\n') } finally { $process.Dispose() } } catch { @() } } function Get-UvDataDirectory { param([ValidateSet('tool', 'python')][string]$Kind) $uvPath = Get-UvExecutablePath -CommandName 'uv' if (-not $uvPath) { return $null } $override = if ($Kind -eq 'tool') { $env:UV_TOOL_DIR } else { $env:UV_PYTHON_INSTALL_DIR } $key = '{0}|{1}|{2}' -f $uvPath, $Kind, $override if (-not $script:UvCompletionCache.Directories.ContainsKey($key)) { $directory = @(Invoke-UvCommand -CommandName 'uv' -Arguments @($Kind, 'dir') | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1) $script:UvCompletionCache.Directories[$key] = if ($directory.Count -gt 0) { $directory[0].Trim() } else { $null } } $script:UvCompletionCache.Directories[$key] } function Get-UvInstalledTool { param([switch]$Executables) # Every installed tool is a directory holding a uv-receipt.toml; the # directory listing is passive, so it stays current without a cache. # The directory is the package name (upgrade/uninstall); `tool run`/uvx # take a command name, which the receipt lists under `entrypoints`. $toolDirectory = Get-UvDataDirectory -Kind 'tool' if (-not $toolDirectory) { return @() } $receipts = @(Get-ChildItem -LiteralPath $toolDirectory -Directory -ErrorAction Ignore | ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'uv-receipt.toml') -ErrorAction Ignore } | Where-Object { -not $_.PSIsContainer }) if (-not $Executables) { return @($receipts | ForEach-Object { $_.Directory.Name }) } $names = foreach ($receipt in $receipts) { $text = Get-Content -LiteralPath $receipt.FullName -Raw -ErrorAction Ignore $entrypoints = [regex]::Match([string]$text, '(?m)^\s*entrypoints\s*=\s*\[((?:[^\[\]"]|"[^"]*")*)\]') if ($entrypoints.Success) { [regex]::Matches($entrypoints.Groups[1].Value, '(?<=[{,])\s*name\s*=\s*"([^"]+)"') | ForEach-Object { $_.Groups[1].Value } } } Get-UvUniqueStrings -Items $names } function Get-UvPythonVersion { param([switch]$Managed) if ($Managed) { # Only uv-managed interpreters can be uninstalled. Full installs are # <impl>-<x.y.z...>-<os>-...; the minor-version junctions repeat them. $pythonDirectory = Get-UvDataDirectory -Kind 'python' if (-not $pythonDirectory) { return @() } $versions = @(Get-ChildItem -LiteralPath $pythonDirectory -Directory -ErrorAction Ignore | Where-Object { $_.Name -match '^[a-z]+-\d+\.\d+\.\d+[^-]*-' } | ForEach-Object { ($_.Name -split '-')[1] } | Sort-Object -Descending -Property { [version]($_ -replace '^(\d+\.\d+\.\d+).*$', '$1') }) } else { $uvPath = Get-UvExecutablePath -CommandName 'uv' if (-not $uvPath) { return @() } $key = '{0}|{1}' -f $uvPath, $env:UV_PYTHON_INSTALL_DIR $entry = $script:UvCompletionCache.PythonVersions[$key] if ($null -eq $entry -or $entry.Expires -lt [datetime]::UtcNow) { # --only-installed and --offline keep uv off the download index; the # versions are read with a regex so malformed output cannot throw. $json = @(Invoke-UvCommand -CommandName 'uv' -Arguments @('python', 'list', '--only-installed', '--offline', '--output-format', 'json')) -join "`n" $entry = @{ Expires = [datetime]::UtcNow.AddSeconds($script:UvCompletionCache.PythonTtlSeconds) Versions = @([regex]::Matches($json, '"version"\s*:\s*"([^"]+)"') | ForEach-Object { $_.Groups[1].Value }) } $script:UvCompletionCache.PythonVersions[$key] = $entry } $versions = $entry.Versions } # Offer each minor request (3.13) ahead of the exact versions it covers. $requests = foreach ($version in @($versions)) { if ($version -match '^(\d+\.\d+)') { $matches[1] } $version } Get-UvUniqueStrings -Items $requests } function Get-UvTomlString { param([string]$Text) # Inline tables ({ include-group = "lint" }) are not entries of their array. $arrayText = $Text -replace '\{[^{}]*\}', '' foreach ($match in [regex]::Matches($arrayText, '"((?:[^"\\]|\\.)*)"|''([^'']*)''')) { if ($match.Groups[1].Success) { $match.Groups[1].Value } else { $match.Groups[2].Value } } } function Read-UvPyprojectFile { param([string]$Path) $file = Get-Item -LiteralPath $Path -ErrorAction Ignore if (-not $file -or $file.PSIsContainer) { return $null } $stamp = '{0}|{1}' -f $file.LastWriteTimeUtc.Ticks, $file.Length $cached = $script:UvCompletionCache.Pyprojects[$file.FullName] if ($cached -and $cached.Stamp -eq $stamp) { return $cached.Data } # A line-based reader for the few tables uv completion needs; anything it # does not recognise is skipped rather than parsed. $data = @{ Name = $null Requirements = @() Extras = @() Groups = @() Scripts = @() IsWorkspace = $false Members = @() Exclude = @() } $table = '' $key = $null $value = '' foreach ($rawLine in @(Get-Content -LiteralPath $file.FullName -ErrorAction Ignore)) { $line = [regex]::Replace($rawLine, '("(?:[^"\\]|\\.)*"|''[^'']*'')|#.*$', '$1') if ($null -eq $key) { if ($line -match '^\s*\[\[') { $table = '' continue } if ($line -match '^\s*\[([^\]]+)\]\s*$') { $table = $matches[1] -replace '["''\s]', '' if ($table -eq 'tool.uv.workspace') { $data.IsWorkspace = $true } continue } if ($line -notmatch '^\s*(?:"([^"]+)"|''([^'']+)''|([A-Za-z0-9_-]+))\s*=\s*(.*)$') { continue } $key = @($matches[1], $matches[2], $matches[3]) | Where-Object { $_ } | Select-Object -First 1 $value = $matches[4] } else { $value += "`n" + $line } # A multi-line array is complete once its brackets balance outside strings. $bare = $value -replace '"(?:[^"\\]|\\.)*"|''[^'']*''', '' if ([regex]::Matches($bare, '\[').Count -gt [regex]::Matches($bare, '\]').Count) { continue } $strings = @(Get-UvTomlString -Text $value) switch ($table) { 'project' { if ($key -eq 'name' -and $strings.Count -gt 0) { $data.Name = $strings[0] } elseif ($key -eq 'dependencies') { $data.Requirements += $strings } } 'project.optional-dependencies' { $data.Extras += $key $data.Requirements += $strings } 'dependency-groups' { $data.Groups += $key $data.Requirements += $strings } 'tool.uv' { if ($key -eq 'dev-dependencies') { $data.Groups += 'dev' $data.Requirements += $strings } } { $_ -in 'project.scripts', 'project.gui-scripts' } { $data.Scripts += $key } 'tool.uv.workspace' { if ($key -eq 'members') { $data.Members = $strings } elseif ($key -eq 'exclude') { $data.Exclude = $strings } } } $key = $null } $script:UvCompletionCache.Pyprojects[$file.FullName] = @{ Stamp = $stamp; Data = $data } $data } function Get-UvProjectInfo { # uv discovers the project from the nearest pyproject.toml above the # working directory, and its workspace from the nearest ancestor that # declares [tool.uv.workspace]. $directory = $ExecutionContext.SessionState.Path.CurrentFileSystemLocation.ProviderPath $project = $null $projectDirectory = $null $root = $null $rootDirectory = $null while (-not [string]::IsNullOrEmpty($directory)) { $data = Read-UvPyprojectFile -Path (Join-Path $directory 'pyproject.toml') if ($data) { if ($null -eq $project) { $project = $data $projectDirectory = $directory } if ($data.IsWorkspace) { $root = $data $rootDirectory = $directory break } } $directory = Split-Path -Parent $directory } if ($null -eq $project) { return $null } if ($null -eq $root) { $root = $project $rootDirectory = $projectDirectory } @{ Project = $project Root = $root RootDirectory = $rootDirectory } } function Get-UvWorkspaceMember { param([hashtable]$ProjectInfo) # Member globs are expanded one segment at a time with .NET enumeration # (simple * and ? matching, inaccessible directories skipped), which is an # order of magnitude cheaper than provider wildcards on every Tab. $enumerationOptions = [System.IO.EnumerationOptions]::new() $names = @($ProjectInfo.Root.Name) foreach ($pattern in @($ProjectInfo.Root.Members)) { $directories = @($ProjectInfo.RootDirectory) foreach ($segment in @($pattern -split '[\\/]' | Where-Object { $_ -and $_ -ne '.' })) { $directories = @(foreach ($directory in $directories) { if ($segment.IndexOfAny([char[]]'*?') -ge 0) { [System.IO.Directory]::GetDirectories($directory, $segment, $enumerationOptions) } elseif ([System.IO.Directory]::Exists((Join-Path $directory $segment))) { Join-Path $directory $segment } }) } foreach ($directory in $directories) { $relativePath = [System.IO.Path]::GetRelativePath($ProjectInfo.RootDirectory, $directory).Replace('\', '/') if (@($ProjectInfo.Root.Exclude | Where-Object { $relativePath -like $_.Replace('\', '/') }).Count -gt 0) { continue } $member = Read-UvPyprojectFile -Path (Join-Path $directory 'pyproject.toml') if ($member) { $names += $member.Name } } } Get-UvUniqueStrings -Items $names } function Get-UvRunTarget { param([hashtable]$ProjectInfo) # The project's own entry points plus whatever the project environment # already has on its Scripts path (python, pytest, ruff, ...). $environment = if ($env:UV_PROJECT_ENVIRONMENT) { [System.IO.Path]::Combine($ProjectInfo.RootDirectory, $env:UV_PROJECT_ENVIRONMENT) } else { Join-Path $ProjectInfo.RootDirectory '.venv' } $names = @($ProjectInfo.Project.Scripts) + @(Get-ChildItem -LiteralPath (Join-Path $environment 'Scripts') -Filter '*.exe' -File -ErrorAction Ignore | ForEach-Object BaseName) Get-UvUniqueStrings -Items $names } function Get-UvRequirementName { param([string[]]$Requirements) $names = foreach ($requirement in @($Requirements)) { if ($requirement -match '^\s*([A-Za-z0-9][A-Za-z0-9._-]*)') { $matches[1] } } Get-UvUniqueStrings -Items $names } function Select-UvPlainValue { param([string[]]$Items) # Live values are emitted bare, so anything that would need quoting in # argument mode is dropped instead. @(@($Items) | Where-Object { $_ -match '^[A-Za-z0-9][A-Za-z0-9._+-]*$' }) } function Get-UvDynamicOptionValue { param( [string]$Option, [string]$Metavar ) if ($Metavar -ceq 'PYTHON') { return Select-UvPlainValue -Items (Get-UvPythonVersion) } $isExtra = $Metavar -cin 'EXTRA', 'OPTIONAL' $isGroup = $Metavar -cin 'GROUP', 'NO_GROUP', 'ONLY_GROUP' $isMember = $Option -ceq '--package' -and $Metavar -ceq 'PACKAGE' if (-not ($isExtra -or $isGroup -or $isMember)) { return @() } $projectInfo = Get-UvProjectInfo if (-not $projectInfo) { return @() } $values = if ($isMember) { Get-UvWorkspaceMember -ProjectInfo $projectInfo } elseif ($isExtra) { $projectInfo.Project.Extras } else { $projectInfo.Project.Groups } Select-UvPlainValue -Items (Get-UvUniqueStrings -Items $values) } function Get-UvOperandValue { param( [string[]]$Path, [bool]$OperandSeen ) # Single-operand slots stop once an operand is typed; list slots # (`tool uninstall a b`, `remove x y`) keep offering. $values = switch (Get-UvCacheKey -Path $Path) { 'tool run' { if (-not $OperandSeen) { Get-UvInstalledTool -Executables } } 'tool upgrade' { Get-UvInstalledTool } 'tool uninstall' { Get-UvInstalledTool } 'python pin' { if (-not $OperandSeen) { Get-UvPythonVersion } } 'python find' { if (-not $OperandSeen) { Get-UvPythonVersion } } 'python uninstall' { Get-UvPythonVersion -Managed } 'remove' { $projectInfo = Get-UvProjectInfo if ($projectInfo) { Get-UvRequirementName -Requirements $projectInfo.Project.Requirements } } 'run' { if (-not $OperandSeen) { $projectInfo = Get-UvProjectInfo if ($projectInfo) { Get-UvRunTarget -ProjectInfo $projectInfo } } } } Select-UvPlainValue -Items $values } function Get-UvStaticSubcommands { param([string[]]$Path) $key = Get-UvCacheKey -Path $Path if ($script:UvCompletionCache.StaticTree.ContainsKey($key)) { return $script:UvCompletionCache.StaticTree[$key] } @() } function Get-UvPathData { param( [string]$SourceName = 'uv', [string[]]$Path ) $path = @($Path) if ($null -eq $Path) { $path = @() } $path = @($path | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) $key = Get-UvPathDataCacheKey -SourceName $SourceName -Path $path if ($script:UvCompletionCache.PathData.ContainsKey($key)) { return $script:UvCompletionCache.PathData[$key] } $helpLines = @() $pathKey = Get-UvCacheKey -Path $path $uvxRootKey = Get-UvCacheKey -Path @('tool', 'run') if ($SourceName -eq 'uvx' -and $pathKey -eq $uvxRootKey) { $helpLines += @(Invoke-UvHelp -CommandName 'uvx') $helpLines += '' $helpLines += @(Invoke-UvHelp -CommandName 'uv' -Path $path) } else { $helpLines += @(Invoke-UvHelp -CommandName $SourceName -Path $path) } $parsed = if ($helpLines.Count -gt 0) { Get-UvParsedHelpData -HelpLines $helpLines } else { New-UvPathData } $staticCommands = Get-UvStaticSubcommands -Path $path $data = New-UvPathData ` -Commands (Get-UvUniqueStrings -Items ($staticCommands + $parsed.Commands)) ` -CommandDescriptions $parsed.CommandDescriptions ` -Options $parsed.Options ` -ValuesByOption $parsed.ValuesByOption ` -MetavarByOption $parsed.MetavarByOption ` -PositionalValues $parsed.PositionalValues $script:UvCompletionCache.PathData[$key] = $data $data } function Find-UvSubcommand { param( [string]$SourceName = 'uv', [string[]]$Path, [string]$Token ) foreach ($command in (Get-UvPathData -SourceName $SourceName -Path $Path).Commands) { if ($command.Equals($Token, [System.StringComparison]::OrdinalIgnoreCase)) { return $command } } $null } function Get-UvCommandContext { param( [string]$WordToComplete, [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition, [string]$SourceName = 'uv', [string[]]$RootPath = @(), [bool]$SupportsHelpCommand = $true ) $tokens = New-Object System.Collections.Generic.List[string] $lastElementBeforeCursor = $null for ($index = 1; $index -lt $CommandAst.CommandElements.Count; $index++) { $element = $CommandAst.CommandElements[$index] if ($element.Extent.StartOffset -ge $CursorPosition) { continue } $lastElementBeforeCursor = $element $token = Get-UvTokenText -Element $element if ([string]::IsNullOrWhiteSpace($token)) { continue } [void]$tokens.Add($token) } # Trailing whitespace is judged against the last element BEFORE the # cursor, so text after the cursor does not shift the command path. $hasTrailingSpace = $false if ([string]::IsNullOrEmpty($WordToComplete)) { $hasTrailingSpace = if ($null -ne $lastElementBeforeCursor) { $CursorPosition -gt $lastElementBeforeCursor.Extent.EndOffset } else { $CursorPosition -gt $CommandAst.CommandElements[0].Extent.EndOffset } } $previousToken = $null if ($tokens.Count -gt 0) { if ($hasTrailingSpace) { $previousToken = $tokens[$tokens.Count - 1] } elseif ($tokens.Count -gt 1) { $previousToken = $tokens[$tokens.Count - 2] } } $pathTokens = @() if ($hasTrailingSpace) { $pathTokens = $tokens.ToArray() } elseif ($tokens.Count -gt 1) { $pathTokens = $tokens.GetRange(0, $tokens.Count - 1).ToArray() } # An empty synthetic root arrives as $null once unrolled, so filter # blank segments before they can become a bogus path element. $commandPath = New-Object System.Collections.Generic.List[string] foreach ($token in @($RootPath | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })) { [void]$commandPath.Add($token) } $rootPathCount = $commandPath.Count $helpMode = $false $operandSeen = $false $pendingValueOption = $null foreach ($token in @($pathTokens)) { if ($null -ne $pendingValueOption) { $pendingValueOption = $null continue } if ($token.StartsWith('-')) { if (-not $token.Contains('=') -and (Get-UvOptionValueKind -SourceName $SourceName -Path $commandPath.ToArray() -Option $token) -ne 'switch') { $pendingValueOption = $token } continue } if ($operandSeen) { continue } if ($SupportsHelpCommand -and $commandPath.Count -eq $rootPathCount -and -not $helpMode -and $token.Equals('help', [System.StringComparison]::OrdinalIgnoreCase)) { $helpMode = $true continue } $nextCommand = Find-UvSubcommand -SourceName $SourceName -Path $commandPath.ToArray() -Token $token if (-not $nextCommand -and $SourceName -eq 'uv' -and $commandPath.Count -eq 0 -and -not $helpMode -and $token -match '^[a-z][a-z0-9-]*$') { # Hidden root commands (generate-shell-completion) are absent from # `uv --help`; `uv <token> --help` is safe at the root because uv # rejects unknown subcommands instead of running anything. $probe = Get-UvPathData -SourceName $SourceName -Path @($token) if (@($probe.Commands).Count -gt 0 -or @($probe.Options).Count -gt 0) { $nextCommand = $token } } if ($nextCommand) { [void]$commandPath.Add($nextCommand) } else { $operandSeen = $true } } @{ Path = if ($commandPath.Count -gt 0) { @($commandPath.ToArray()) } else { @() } PreviousToken = $previousToken HelpMode = $helpMode HasTrailingSpace = $hasTrailingSpace OperandSeen = $operandSeen } } function Get-UvOptionValues { param( [string]$SourceName = 'uv', [string[]]$Path, [string]$Option ) if ([string]::IsNullOrWhiteSpace($Option) -or -not $Option.StartsWith('-')) { return @() } $optionKey = $Option $values = @() $pathData = Get-UvPathData -SourceName $SourceName -Path $Path if ($pathData.ValuesByOption.ContainsKey($optionKey)) { $values += $pathData.ValuesByOption[$optionKey] } if (@($values).Count -eq 0) { $rootData = Get-UvPathData -SourceName $SourceName -Path @() if ($rootData.ValuesByOption.ContainsKey($optionKey)) { $values += $rootData.ValuesByOption[$optionKey] } } if (@($values).Count -eq 0) { $metavar = Get-UvOptionMetavar -SourceName $SourceName -Path $Path -Option $optionKey $values += @(Get-UvDynamicOptionValue -Option $optionKey -Metavar $metavar) } Get-UvUniqueStrings -Items $values } function Get-UvOptionMetavar { param( [string]$SourceName = 'uv', [string[]]$Path, [string]$Option ) $optionKey = $Option foreach ($data in @((Get-UvPathData -SourceName $SourceName -Path $Path), (Get-UvPathData -SourceName $SourceName -Path @()))) { if ($data.MetavarByOption.ContainsKey($optionKey)) { return [string]$data.MetavarByOption[$optionKey] } } $null } function Get-UvOptionValueKind { param( [string]$SourceName = 'uv', [string[]]$Path, [string]$Option ) # 'switch' for options without a metavariable (or unknown options), # 'path' for file/directory metavariables, otherwise 'value'. if ([string]::IsNullOrWhiteSpace($Option) -or -not $Option.StartsWith('-')) { return 'switch' } $metavar = Get-UvOptionMetavar -SourceName $SourceName -Path $Path -Option $Option if ([string]::IsNullOrEmpty($metavar)) { return 'switch' } if ($metavar -match '(^|_)(DIR|DIRECTORY|FILE|PATH|REQUIREMENTS|CONSTRAINTS|OVERRIDES|SCRIPT|PROJECT|CACHE_DIR|OUTPUT_FILE)$') { return 'path' } 'value' } function Get-UvOptionAssignmentContext { param([string]$WordToComplete) if ($WordToComplete -match '^(--?[A-Za-z0-9][A-Za-z0-9\-]*)=(.*)$') { return @{ Option = $matches[1] ValuePrefix = $matches[2] } } $null } function Get-UvEffectiveWordToComplete { param( [string]$WordToComplete, [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition ) if (-not [string]::IsNullOrEmpty($WordToComplete)) { return $WordToComplete } if ($CommandAst.CommandElements.Count -eq 0) { return $WordToComplete } foreach ($element in @($CommandAst.CommandElements | Select-Object -Skip 1)) { if ($element.Extent.EndOffset -eq $CursorPosition) { return Get-UvTokenText -Element $element } } $WordToComplete } function New-UvCompletionResults { param( [string[]]$Items, [System.Management.Automation.CompletionResultType]$ResultType, [string]$WordToComplete, [hashtable]$Tooltips ) foreach ($item in @($Items | Where-Object { -not [string]::IsNullOrEmpty($_) })) { # A typed short flag must keep its case so -C never completes to -c; long options and # values keep the usual case-insensitive prefix match. $pattern = [System.Management.Automation.WildcardPattern]::Escape($WordToComplete) + '*' $matched = if ($WordToComplete -cmatch '^-[^-]') { $item -clike $pattern } else { $item -like $pattern } if (-not $matched) { continue } $tooltip = if ($Tooltips -and $Tooltips.ContainsKey($item.ToLowerInvariant())) { $Tooltips[$item.ToLowerInvariant()] } else { $item } [System.Management.Automation.CompletionResult]::new($item, $item, $ResultType, $tooltip) } } function New-UvAssignedValueCompletionResults { param( [string]$Option, [string[]]$Items, [string]$ValuePrefix ) foreach ($item in @($Items | Where-Object { -not [string]::IsNullOrEmpty($_) })) { if ($item -notlike ([System.Management.Automation.WildcardPattern]::Escape($ValuePrefix) + '*')) { continue } $completionText = '{0}={1}' -f $Option, $item [System.Management.Automation.CompletionResult]::new( $completionText, $completionText, [System.Management.Automation.CompletionResultType]::ParameterValue, $completionText ) } } function Complete-Uv { param($wordToComplete, $commandAst, $cursorPosition) $sourceName = 'uv' if ($commandAst.CommandElements.Count -gt 0) { $sourceName = Get-UvSourceName -CommandName (Get-UvTokenText -Element $commandAst.CommandElements[0]) } $effectiveWordToComplete = Get-UvEffectiveWordToComplete ` -WordToComplete $wordToComplete ` -CommandAst $commandAst ` -CursorPosition $cursorPosition $context = Get-UvCommandContext ` -WordToComplete $effectiveWordToComplete ` -CommandAst $commandAst ` -CursorPosition $cursorPosition ` -SourceName $sourceName ` -RootPath (Get-UvSyntheticRootPath -SourceName $sourceName) ` -SupportsHelpCommand ($sourceName -ne 'uvx') $path = $context.Path $previousToken = $context.PreviousToken $pathData = Get-UvPathData -SourceName $sourceName -Path $path $assignmentContext = Get-UvOptionAssignmentContext -WordToComplete $effectiveWordToComplete if ($assignmentContext) { New-UvAssignedValueCompletionResults ` -Option $assignmentContext.Option ` -Items (Get-UvOptionValues -SourceName $sourceName -Path $path -Option $assignmentContext.Option) ` -ValuePrefix $assignmentContext.ValuePrefix return } if ($previousToken -and $previousToken.StartsWith('-') -and -not $previousToken.Contains('=')) { $valueKind = Get-UvOptionValueKind -SourceName $sourceName -Path $path -Option $previousToken if ($valueKind -eq 'path') { # A path-typed option: the engine's own filesystem completion is # the intended answer, so return nothing on purpose. return @() } if ($valueKind -eq 'value') { $values = @(Get-UvOptionValues -SourceName $sourceName -Path $path -Option $previousToken) if ($values.Count -eq 0) { $metavar = Get-UvOptionMetavar -SourceName $sourceName -Path $path -Option $previousToken $values = @('<' + $metavar.ToLowerInvariant() + '>') } New-UvCompletionResults -Items $values -ResultType ([System.Management.Automation.CompletionResultType]::ParameterValue) -WordToComplete $wordToComplete -Tooltips @{} return } } if ($context.HelpMode) { New-UvCompletionResults -Items $pathData.Commands -ResultType ([System.Management.Automation.CompletionResultType]::ParameterValue) -WordToComplete $wordToComplete -Tooltips $pathData.CommandDescriptions return } if ($effectiveWordToComplete.StartsWith('-')) { New-UvCompletionResults -Items $pathData.Options -ResultType ([System.Management.Automation.CompletionResultType]::ParameterName) -WordToComplete $effectiveWordToComplete -Tooltips @{} return } $operandValues = @(Get-UvOperandValue -Path $path -OperandSeen $context.OperandSeen) if ($operandValues.Count -gt 0) { New-UvCompletionResults -Items $operandValues -ResultType ([System.Management.Automation.CompletionResultType]::ParameterValue) -WordToComplete $effectiveWordToComplete -Tooltips @{} return } if (-not $context.OperandSeen -and @($pathData.PositionalValues).Count -gt 0) { New-UvCompletionResults -Items $pathData.PositionalValues -ResultType ([System.Management.Automation.CompletionResultType]::ParameterValue) -WordToComplete $effectiveWordToComplete -Tooltips @{} return } if (-not $context.OperandSeen) { New-UvCompletionResults -Items $pathData.Commands -ResultType ([System.Management.Automation.CompletionResultType]::ParameterValue) -WordToComplete $effectiveWordToComplete -Tooltips $pathData.CommandDescriptions } New-UvCompletionResults -Items $pathData.Options -ResultType ([System.Management.Automation.CompletionResultType]::ParameterName) -WordToComplete $effectiveWordToComplete -Tooltips @{} } Register-ArgumentCompleter -Native -CommandName 'uv', 'uv.exe', 'uvx', 'uvx.exe' -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) Complete-Uv -wordToComplete $wordToComplete -commandAst $commandAst -cursorPosition $cursorPosition } |