|
| 1 | +using namespace Microsoft.PowerShell.EditorServices |
| 2 | +using namespace Microsoft.PowerShell.EditorServices.Extensions |
| 3 | +using namespace System.Management.Automation.Host |
| 4 | +using namespace System.Management.Automation.Language |
| 5 | + |
| 6 | +function Add-PinvokeMethod { |
| 7 | + <# |
| 8 | + .EXTERNALHELP EditorServicesCommandSuite-help.xml |
| 9 | + #> |
| 10 | + [EditorCommand(DisplayName='Insert Pinvoke Method Definition')] |
| 11 | + [CmdletBinding()] |
| 12 | + param( |
| 13 | + [ValidateNotNullOrEmpty()] |
| 14 | + [string] |
| 15 | + $Function, |
| 16 | + |
| 17 | + [ValidateNotNullOrEmpty()] |
| 18 | + [string] |
| 19 | + $Module |
| 20 | + ) |
| 21 | + begin { |
| 22 | + if (-not $script:PinvokeWebService) { |
| 23 | + # Get the web service async so there isn't a hang before prompting for function name. |
| 24 | + $script:PinvokeWebService = async { |
| 25 | + $newWebServiceProxySplat = @{ |
| 26 | + Namespace = 'PinvokeWebService' |
| 27 | + Class = 'Main' |
| 28 | + Uri = 'http://pinvoke.net/pinvokeservice.asmx?wsdl' |
| 29 | + } |
| 30 | + New-WebServiceProxy @newWebServiceProxySplat |
| 31 | + } |
| 32 | + } |
| 33 | + |
| 34 | + # Return parameters if they exist, otherwise handle user input. |
| 35 | + function GetFunctionInfo([string] $functionName, [string] $moduleName) { |
| 36 | + if ($functionName -and $moduleName) { |
| 37 | + return [PSCustomObject]@{ |
| 38 | + Function = $functionName |
| 39 | + Module = $moduleName |
| 40 | + } |
| 41 | + } |
| 42 | + if (-not $functionName) { |
| 43 | + $functionName = ReadInputPrompt $Strings.PInvokeFunctionNamePrompt |
| 44 | + if (-not $functionName) { return } |
| 45 | + } |
| 46 | + $pinvoke = await $script:PinvokeWebService |
| 47 | + $searchResults = $pinvoke.SearchFunction($functionName, $null) |
| 48 | + |
| 49 | + if (-not $searchResults) { |
| 50 | + ThrowError -Exception ([ArgumentException]::new($Strings.CannotFindPInvokeFunction -f $functionName)) ` |
| 51 | + -Id CannotFindPInvokeFunction ` |
| 52 | + -Category InvalidArgument ` |
| 53 | + -Target $functionName ` |
| 54 | + -Show |
| 55 | + } |
| 56 | + |
| 57 | + $choice = $null |
| 58 | + if ($searchResults.Count -gt 1) { |
| 59 | + $choices = $searchResults.ForEach{ |
| 60 | + [ChoiceDescription]::new( |
| 61 | + $PSItem.Function, |
| 62 | + ('Module: {0} Function: {1}' -f $PSItem.Module, $PSItem.Function)) |
| 63 | + } |
| 64 | + $choice = ReadChoicePrompt $Strings.PInvokeFunctionChoice -Choices $choices |
| 65 | + if ($null -eq $choice) { return } |
| 66 | + } |
| 67 | + $searchResults[[int]$choice] |
| 68 | + } |
| 69 | + |
| 70 | + # Some modules don't always return correctly, commonly structs. This is a last ditch catch |
| 71 | + # all that parses the HTML content directly. |
| 72 | + # TODO: Replace calls to IE COM object with HtmlAgilityPack or similar. |
| 73 | + function GetUnsupportedSignature { |
| 74 | + $url = 'http://pinvoke.net/default.aspx/{0}/{1}.html' -f |
| 75 | + $functionInfo.Module, |
| 76 | + $functionInfo.Function |
| 77 | + try { |
| 78 | + $request = Invoke-WebRequest $url |
| 79 | + } catch { |
| 80 | + return |
| 81 | + } |
| 82 | + |
| 83 | + if ($request.Content -match 'The module <b>([^<]+)</b> does not exist') { |
| 84 | + $PSCmdlet.WriteDebug('Module {0} not found.' -f $matches[1]) |
| 85 | + return |
| 86 | + } |
| 87 | + |
| 88 | + if ($request.Content -match 'You are about to create a new page called <b>([^<]+)</b>') { |
| 89 | + $PSCmdlet.WriteDebug('Function {0} not found' -f $matches[1]) |
| 90 | + return |
| 91 | + } |
| 92 | + |
| 93 | + $nodes = $request.ParsedHtml.body.getElementsByClassName('TopicBody')[0].childNodes |
| 94 | + for ($i = 0; $i -lt $nodes.length; $i++) { |
| 95 | + |
| 96 | + $node = $nodes[$i] |
| 97 | + if ($node.tagName -ne 'H4') { continue } |
| 98 | + if ($node.innerText -notmatch 'C# Definition') { continue } |
| 99 | + |
| 100 | + $sig = $nodes[$i + 1] |
| 101 | + if ($sig.tagName -ne 'P' -or $sig.className -ne 'pre') { continue } |
| 102 | + return [PSCustomObject]@{ |
| 103 | + Signature = $sig.innerText -replace '\r?\n', '|' |
| 104 | + Url = $url |
| 105 | + } |
| 106 | + } |
| 107 | + } |
| 108 | + |
| 109 | + # Get template and insertion extent. If cursor is in a Add-Type command AST that has a member |
| 110 | + # definiton parameter, it will insert the signature into the existing command. Otherwise it |
| 111 | + # will create a new Add-Type command expression at the current cursor position. |
| 112 | + function GetTemplateInfo { |
| 113 | + $defaultAction = { |
| 114 | + [PSCustomObject]@{ |
| 115 | + Template = "# Source: <SourceUri><\n>" + |
| 116 | + "Add-Type -Namespace <Namespace> -Name <Class> -MemberDefinition '<\n><Signature>'" |
| 117 | + Position = [FullScriptExtent]::new( |
| 118 | + $context.CurrentFile, |
| 119 | + [BufferRange]::new( |
| 120 | + $context.CursorPosition.Line, |
| 121 | + $context.CursorPosition.Column, |
| 122 | + $context.CursorPosition.Line, |
| 123 | + $context.CursorPosition.Column)) |
| 124 | + } |
| 125 | + } |
| 126 | + $context = $psEditor.GetEditorContext() |
| 127 | + $commandAst = Find-Ast -AtCursor | Find-Ast -Ancestor -First { $PSItem -is [CommandAst] } |
| 128 | + |
| 129 | + if (-not $commandAst -or $commandAst.GetCommandName() -ne 'Add-Type') { |
| 130 | + return & $defaultAction |
| 131 | + } |
| 132 | + $binding = [StaticParameterBinder]::BindCommand($commandAst, $true) |
| 133 | + |
| 134 | + $memberDefinition = $binding.BoundParameters.MemberDefinition |
| 135 | + |
| 136 | + if (-not $memberDefinition) { return & $defaultAction } |
| 137 | + |
| 138 | + $targetOffset = $memberDefinition.Value.Extent.EndOffset - 1 |
| 139 | + return [PSCustomObject]@{ |
| 140 | + Template = '<\n><\n>// Source: <SourceUri><\n><Signature>' |
| 141 | + Position = [FullScriptExtent]::new($context.CurrentFile, $targetOffset, $targetOffset) |
| 142 | + } |
| 143 | + } |
| 144 | + |
| 145 | + # Get first non-whitespace character location if the line has text, otherwise get the current |
| 146 | + # cursor column. |
| 147 | + function GetIndentLevel { |
| 148 | + try { |
| 149 | + $context = $psEditor.GetEditorContext() |
| 150 | + $lineStart = $context.CursorPosition.GetLineStart() |
| 151 | + $lineEnd = $context.CursorPosition.GetLineEnd() |
| 152 | + $lineText = $context.CurrentFile.GetText( |
| 153 | + [BufferRange]::new( |
| 154 | + $lineStart.Line, |
| 155 | + $lineStart.Column, |
| 156 | + $lineEnd.Line, |
| 157 | + $lineEnd.Column)) |
| 158 | + |
| 159 | + if ($lineText -match '\S') { |
| 160 | + return $lineStart.Column - 1 |
| 161 | + } |
| 162 | + } catch { |
| 163 | + $PSCmdlet.WriteDebug('Exception occurred while getting indent level') |
| 164 | + } |
| 165 | + return $context.CursorPosition.Column - 1 |
| 166 | + } |
| 167 | + } |
| 168 | + end { |
| 169 | + $functionInfo = GetFunctionInfo $Function $Module |
| 170 | + if (-not $functionInfo) { return } |
| 171 | + |
| 172 | + $pinvoke = await $script:PinvokeWebService |
| 173 | + |
| 174 | + # Get signatures from pinvoke.net and filter by C# |
| 175 | + $signatureInfo = $null |
| 176 | + try { |
| 177 | + $signatureInfo = $pinvoke. |
| 178 | + GetResultsForFunction( |
| 179 | + $functionInfo.Function, |
| 180 | + $functionInfo.Module). |
| 181 | + Where{ $PSItem.Language -eq 'C#' } |
| 182 | + } catch [System.Web.Services.Protocols.SoapException] { |
| 183 | + if ($PSItem.Exception.Message -match 'but no signatures could be extracted') { |
| 184 | + $signatureInfo = GetUnsupportedSignature |
| 185 | + } |
| 186 | + } |
| 187 | + |
| 188 | + if (-not $signatureInfo) { |
| 189 | + ThrowError -Exception ([InvalidOperationException]::new($Strings.MissingPInvokeSignature)) ` |
| 190 | + -Id MissingPInvokeSignature ` |
| 191 | + -Category InvalidOperation ` |
| 192 | + -Target $functionInfo ` |
| 193 | + -Show |
| 194 | + } |
| 195 | + |
| 196 | + # - Replace pipes with new lines |
| 197 | + # - Add public modifier |
| 198 | + # - Trim white trailing whitespace |
| 199 | + # - Escape single quotes |
| 200 | + $signature = $signatureInfo.Signature ` |
| 201 | + -split '\|' ` |
| 202 | + -join [Environment]::NewLine ` |
| 203 | + -replace '(?<!public )(?:private )?(static|struct)', 'public $1' ` |
| 204 | + -replace '\s+$' ` |
| 205 | + -replace "'", "''" |
| 206 | + |
| 207 | + # Strip module name of numbers and make PascalCase. |
| 208 | + $formattedModuleName = [regex]::Replace( |
| 209 | + ($functionInfo.Module -replace '\d'), |
| 210 | + '^\w', |
| 211 | + { $args[0].Value.ToUpper() }) |
| 212 | + |
| 213 | + $templateInfo = GetTemplateInfo |
| 214 | + $expression = Invoke-StringTemplate -Definition $templateInfo.Template -Parameters @{ |
| 215 | + Namespace = 'PinvokeMethods' |
| 216 | + Class = $formattedModuleName |
| 217 | + Signature = $signature |
| 218 | + SourceUri = $signatureInfo.Url.Where({ $PSItem }, 'First')[0] |
| 219 | + } |
| 220 | + |
| 221 | + $indentLevel = GetIndentLevel |
| 222 | + $indent = ' ' * ($indentLevel - 1) |
| 223 | + $expression = $expression -split '\r?\n' -join ([Environment]::NewLine + $indent) |
| 224 | + |
| 225 | + Set-ScriptExtent -Extent $templateInfo.Position -Text $expression |
| 226 | + } |
| 227 | +} |
0 commit comments