Skip to content

Commit 1a0f6df

Browse files
Add-PinvokeMethod function (#15)
* Add-PinvokeMethod function This change adds the Add-PinvokeMethod function that inserts PInvoke method implementations using the pinvoke.net web service. - Add a private function for sending ShowChoicePrompt requests to VSCode through EditorServices.
1 parent 617c4d2 commit 1a0f6df

File tree

5 files changed

+358
-0
lines changed

5 files changed

+358
-0
lines changed

docs/en-US/Add-PinvokeMethod.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
---
2+
external help file: EditorServicesCommandSuite-help.xml
3+
online version: https://github.com/SeeminglyScience/EditorServicesCommandSuite/blob/master/docs/en-US/Add-PinvokeMethod.md
4+
schema: 2.0.0
5+
---
6+
7+
# Add-PinvokeMethod
8+
9+
## SYNOPSIS
10+
11+
Find and insert a PInvoke function signature into the current file.
12+
13+
## SYNTAX
14+
15+
```powershell
16+
Add-PinvokeMethod [[-Function] <String>] [[-Module] <String>]
17+
```
18+
19+
## DESCRIPTION
20+
21+
The Add-PinvokeMethod function searches pinvoke.net for the requested function name and provides a list of matches to select from. Once selected, this function will get the signature and create a expression that uses the Add-Type cmdlet to create a type with the PInvoke method.
22+
23+
## EXAMPLES
24+
25+
### -------------------------- EXAMPLE 1 --------------------------
26+
27+
```powershell
28+
Add-PinvokeMethod -Function SetConsoleTitle -Module Kernel32
29+
30+
# Inserts the following into the file currently open in the editor.
31+
32+
# Source: http://pinvoke.net/jump.aspx/kernel32.setconsoletitle
33+
Add-Type -Namespace PinvokeMethods -Name Kernel -MemberDefinition '
34+
[DllImport("kernel32.dll")]
35+
public static extern bool SetConsoleTitle(string lpConsoleTitle);'
36+
```
37+
38+
Adds code to use the SetConsoleTitle function from the kernel32 DLL.
39+
40+
## PARAMETERS
41+
42+
### -Function
43+
44+
Specifies the function name to search for. If omitted, a prompt will be displayed within the editor.
45+
46+
```yaml
47+
Type: String
48+
Parameter Sets: (All)
49+
Aliases:
50+
51+
Required: False
52+
Position: 1
53+
Default value: None
54+
Accept pipeline input: False
55+
Accept wildcard characters: False
56+
```
57+
58+
### -Module
59+
60+
Specifies the module or dll the function resides in. If omitted, and multiple matching functions exist, a choice prompt will be displayed within the editor.
61+
62+
```yaml
63+
Type: String
64+
Parameter Sets: (All)
65+
Aliases:
66+
67+
Required: False
68+
Position: 2
69+
Default value: None
70+
Accept pipeline input: False
71+
Accept wildcard characters: False
72+
```
73+
74+
## INPUTS
75+
76+
### None
77+
78+
This function does not accept input from the pipeline.
79+
80+
## OUTPUTS
81+
82+
### None
83+
84+
This function does not output to the pipeline.
85+
86+
## NOTES
87+
88+
## RELATED LINKS
89+
90+
[pinvoke.net](http://pinvoke.net/)

module/EditorServicesCommandSuite.psd1

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ RequiredModules = 'PSStringTemplate'
4747
# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export.
4848
FunctionsToExport = 'Add-CommandToManifest',
4949
'Add-ModuleQualification',
50+
'Add-PinvokeMethod',
5051
'ConvertTo-LocalizationString',
5152
'ConvertTo-MarkdownHelp',
5253
'ConvertTo-SplatExpression',
@@ -73,6 +74,7 @@ FileList = 'EditorServicesCommandSuite.psd1',
7374
'EditorServicesCommandSuite.psm1',
7475
'Public\Add-CommandToManifest.ps1',
7576
'Public\Add-ModuleQualification.ps1',
77+
'Public\Add-PinvokeMethod.ps1',
7678
'Public\ConvertTo-LocalizationString.ps1',
7779
'Public\ConvertTo-MarkdownHelp.ps1',
7880
'Public\ConvertTo-SplatExpression.ps1',
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using namespace Microsoft.PowerShell.EditorServices.Protocol.MessageProtocol
2+
using namespace Microsoft.PowerShell.EditorServices.Protocol.Messages
3+
using namespace Microsoft.PowerShell.EditorServices
4+
5+
function ReadChoicePrompt {
6+
param([string]$Prompt, [System.Management.Automation.Host.ChoiceDescription[]]$Choices)
7+
end {
8+
$choiceIndex = 0
9+
$convertedChoices = $Choices.ForEach{
10+
$newLabel = '{0} - {1}' -f ($choiceIndex + 1), $PSItem.Label
11+
[ChoiceDetails]::new($newLabel, $PSItem.HelpMessage)
12+
$choiceIndex++
13+
} -as [ChoiceDetails[]]
14+
15+
$result = $psEditor.
16+
Components.
17+
Get([IMessageSender]).SendRequest(
18+
[ShowChoicePromptRequest]::Type,
19+
[ShowChoicePromptRequest]@{
20+
Caption = $Prompt
21+
Message = $Prompt
22+
Choices = $convertedChoices
23+
DefaultChoices = 0
24+
},
25+
$true).
26+
Result
27+
28+
if (-not $result.PromptCanceled) {
29+
# yield
30+
$result.ResponseText |
31+
Select-String '^(\d+) - ' |
32+
ForEach-Object { $PSItem.Matches.Groups[1].Value - 1 }
33+
}
34+
}
35+
}
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
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+
}

module/en-US/Strings.psd1

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,8 @@ VerboseInvalidManifest=Unable to retrieve module manifest for current workspace.
3636
CannotInferModule=Unable to infer module information for the selected command.
3737
CommandNotInModule=The selected command does not belong to a module.
3838
StringNamePromptFail=You must supply a string name for it to be added to the localization table. Please try the command again.
39+
CannotFindPInvokeFunction=Unable to find a PInvoke function that starts with '{0}'
40+
PInvokeFunctionChoice=Multiple matches found, please select below
41+
PInvokeFunctionNamePrompt=PInvoke Function Name
42+
MissingPInvokeSignature=The function was found but pinvoke.net did not return signature information.
3943
'@

0 commit comments

Comments
 (0)