Context
The Visual Basic Editor (VBE) does a good job at completing member scopes, for example when you type this and hit ENTER...
Private Sub DoSomething
You get this for free:
Private Sub DoSomething()
End Sub
The problem is that VBA is a very, uhm, blocky language, and the VBE doesn't automatically complete any of the blocks. For example when you type this and hit ENTER...
With Something
All you get is this:
With Something
And what you'd like is this:
With Something
End With
The next release of Rubberduck will fix this, and more. Here's how.
Interface & Wiring
I started with an IAutoComplete
interface that looks like this:
namespace Rubberduck.AutoComplete
{
public interface IAutoComplete
{
string InputToken { get; }
string OutputToken { get; }
bool Execute(AutoCompleteEventArgs e, AutoCompleteSettings settings);
bool IsInlineCharCompletion { get; }
bool IsEnabled { get; set; }
}
}
This interface is flexible enough to allow toggling each individual implementation on/off using Rubberduck settings, and the InputToken
/ OutputToken
properties are flexible enough to support both inline and block autocompletion.
The AutoCompleteEventArgs
class is defined in the Rubberduck.VBEditor
project, which is responsible for the lower-level VBE messenging hooks and COM wrappers:
namespace Rubberduck.VBEditor.Events
{
public class AutoCompleteEventArgs : EventArgs
{
public AutoCompleteEventArgs(ICodeModule module, WindowsApi.KeyPressEventArgs e)
{
if (e.Key == Keys.Delete ||
e.Key == Keys.Back ||
e.Key == Keys.Enter ||
e.Key == Keys.Tab)
{
Keys = e.Key;
}
else
{
Character = e.Character;
}
CodeModule = module;
CurrentSelection = module.GetQualifiedSelection().Value.Selection;
CurrentLine = module.GetLines(CurrentSelection);
}
/// <summary>
/// <c>true</c> if the character has been handled, i.e. written to the code pane.
/// Set to <c>true</c> to swallow the character and prevent the WM message from reaching the code pane.
/// </summary>
public bool Handled { get; set; }
/// <summary>
/// The CodeModule wrapper for the module being edited.
/// </summary>
public ICodeModule CodeModule { get; }
/// <summary>
/// <c>true</c> if the event is originating from a <c>WM_CHAR</c> message.
/// <c>false</c> if the event is originating from a <c>WM_KEYDOWN</c> message.
/// </summary>
/// <remarks>
/// Inline completion is handled on WM_CHAR; deletions and block completion on WM_KEYDOWN.
/// </remarks>
public bool IsCharacter => Keys == default;
/// <summary>
/// The character whose key was pressed. Undefined value if <see cref="Keys"/> isn't `<see cref="Keys.None"/>.
/// </summary>
public char Character { get; }
/// <summary>
/// The actionnable key that was pressed. Value is <see cref="Keys.None"/> when <see cref="IsCharacter"/> is <c>true</c>.
/// </summary>
public Keys Keys { get; }
/// <summary>
/// The current location of the caret.
/// </summary>
public Selection CurrentSelection { get; }
/// <summary>
/// The contents of the current line of code.
/// </summary>
public string CurrentLine { get; }
}
}
The EventArgs
object is created by VBENativeServices
in a static
handler (which needs to be static
for various reasons) that is invoked when a key is pressed in the editor's code pane:
public static event EventHandler<AutoCompleteEventArgs> KeyDown;
private static void OnKeyDown(KeyPressEventArgs e)
{
using (var pane = GetCodePaneFromHwnd(e.Hwnd))
{
using (var module = pane.CodeModule)
{
var args = new AutoCompleteEventArgs(module, e);
KeyDown?.Invoke(_vbe, args);
e.Handled = args.Handled;
}
}
}
The value of the Handled
property is carried to the invoking native handler, which then uses this value to determine whether to "swallow" the keypress or let it through to the code pane - this all happens in the rather low-level CodePaneSubClass
class:
namespace Rubberduck.VBEditor.WindowsApi
{
public class KeyPressEventArgs
{
public KeyPressEventArgs(IntPtr hwnd, IntPtr wParam, IntPtr lParam, char character = default)
{
Hwnd = hwnd;
WParam = wParam;
LParam = lParam;
Character = character;
if (character == default(char))
{
Key = (Keys)wParam;
}
else
{
IsCharacter = true;
}
}
public bool IsCharacter { get; }
public IntPtr Hwnd { get; }
public IntPtr WParam { get; }
public IntPtr LParam { get; }
public bool Handled { get; set; }
public char Character { get; }
public Keys Key { get; }
}
//Stub for code pane replacement. :-)
internal class CodePaneSubclass : FocusSource
{
public ICodePane CodePane { get; }
internal CodePaneSubclass(IntPtr hwnd, ICodePane pane) : base(hwnd)
{
CodePane = pane;
}
public override int SubClassProc(IntPtr hWnd, IntPtr msg, IntPtr wParam, IntPtr lParam, IntPtr uIdSubclass, IntPtr dwRefData)
{
KeyPressEventArgs args;
switch ((WM)msg)
{
case WM.CHAR:
args = new KeyPressEventArgs(hWnd, wParam, lParam, (char)wParam);
OnKeyDown(args);
if (args.Handled) { return 0; }
break;
case WM.KEYDOWN:
args = new KeyPressEventArgs(hWnd, wParam, lParam);
OnKeyDown(args);
if (args.Handled) { return 0; }
break;
}
return base.SubClassProc(hWnd, msg, wParam, lParam, uIdSubclass, dwRefData);
}
protected override void DispatchFocusEvent(FocusType type)
{
var window = VBENativeServices.GetWindowInfoFromHwnd(Hwnd);
if (!window.HasValue)
{
return;
}
OnFocusChange(new WindowChangedEventArgs(window.Value.Hwnd, window.Value.Window, CodePane, type));
}
}
}
One concern is that a decision was taken to handle both WM_CHAR
and WM_KEYDOWN
, because CHAR
conveniently gives us the exact char
to use, but only KEYDOWN
can tell us whether the DELETE or <kbd>BACKSPACE</kbd>
key was pressed - so the KeyPressEventArgs
contain information that tells us whether we need to look at the Character
or the Keys
enum value when we handle the keypress... and this makes the name KeyPress
rather all-encompassing.
AutoCompleteService
The component responsible for handling the low-level VBENativeServices.KeyDown
event gets the settings service and the IAutoComplete
provider constructor-injected - the class is needs to handle changes in configuration settings, which could conceivably be toggling one or more autocompletes' IsEnabled
state at any point in time.. but we don't want to re-load the settings file at every keypress either:
namespace Rubberduck.AutoComplete
{
public class AutoCompleteService : SubclassingWindow, IDisposable
{
private readonly IGeneralConfigService _configService;
private readonly List<IAutoComplete> _autoCompletes;
private AutoCompleteSettings _settings;
public AutoCompleteService(IGeneralConfigService configService, IAutoCompleteProvider provider)
{
_configService = configService;
_autoCompletes = provider.AutoCompletes.ToList();
ApplyAutoCompleteSettings(configService.LoadConfiguration());
_configService.SettingsChanged += ConfigServiceSettingsChanged;
VBENativeServices.KeyDown += HandleKeyDown;
}
private void ConfigServiceSettingsChanged(object sender, ConfigurationChangedEventArgs e)
{
var config = _configService.LoadConfiguration();
ApplyAutoCompleteSettings(config);
}
private void ApplyAutoCompleteSettings(Configuration config)
{
_settings = config.UserSettings.AutoCompleteSettings;
foreach (var autoComplete in _autoCompletes)
{
var setting = config.UserSettings.AutoCompleteSettings.AutoCompletes.FirstOrDefault(s => s.Key == autoComplete.GetType().Name);
if (setting != null && autoComplete.IsEnabled != setting.IsEnabled)
{
autoComplete.IsEnabled = setting.IsEnabled;
continue;
}
}
}
private void HandleKeyDown(object sender, AutoCompleteEventArgs e)
{
var module = e.CodeModule;
var qualifiedSelection = module.GetQualifiedSelection();
var selection = qualifiedSelection.Value.Selection;
if (e.Keys != Keys.None && selection.LineCount > 1 || selection.StartColumn != selection.EndColumn)
{
return;
}
var currentContent = module.GetLines(selection);
var handleDelete = e.Keys == Keys.Delete && selection.EndColumn <= currentContent.Length;
var handleBackspace = e.Keys == Keys.Back && selection.StartColumn > 1;
foreach (var autoComplete in _autoCompletes.Where(auto => auto.IsEnabled))
{
if (handleDelete || handleBackspace)
{
if (DeleteAroundCaret(e, autoComplete))
{
break;
}
}
else
{
if (autoComplete.Execute(e, _settings))
{
break;
}
}
}
}
private bool DeleteAroundCaret(AutoCompleteEventArgs e, IAutoComplete autoComplete)
{
if (autoComplete.IsInlineCharCompletion)
{
var code = e.CurrentLine;
// If caret LHS is the AC input token and RHS is the AC output token, we can remove both.
// Substring index is 0-based. Selection from code pane is 1-based.
// LHS should be at StartColumn - 2, RHS at StartColumn - 1.
var caretLHS = code.Substring(Math.Max(0, e.CurrentSelection.StartColumn - 2), 1);
var caretRHS = code.Length >= e.CurrentSelection.StartColumn
? code.Substring(e.CurrentSelection.StartColumn - 1, 1)
: string.Empty;
if (caretLHS == autoComplete.InputToken && caretRHS == autoComplete.OutputToken)
{
var left = code.Substring(0, e.CurrentSelection.StartColumn - 2);
var right = code.Substring(e.CurrentSelection.StartColumn);
using (var pane = e.CodeModule.CodePane)
{
e.CodeModule.ReplaceLine(e.CurrentSelection.StartLine, left + right);
pane.Selection = new Selection(e.CurrentSelection.StartLine, e.CurrentSelection.StartColumn - 1);
e.Handled = true;
}
return true;
}
}
return false;
}
public void Dispose()
{
VBENativeServices.KeyDown -= HandleKeyDown;
if (_configService != null)
{
_configService.SettingsChanged -= ConfigServiceSettingsChanged;
}
_autoCompletes.Clear();
}
}
}
So the service handles deleting & backspacing. Two classes handle inline and block completion, respectively.
Inline Autocompletion
The simplest form of autocompletion is the "inline" kind. Since all inline autocompletes work the same way, the execution logic is implemented in a base class:
namespace Rubberduck.AutoComplete
{
public abstract class AutoCompleteBase : IAutoComplete
{
protected AutoCompleteBase(string inputToken, string outputToken)
{
InputToken = inputToken;
OutputToken = outputToken;
}
public bool IsInlineCharCompletion => InputToken.Length == 1 && OutputToken.Length == 1;
public bool IsEnabled { get; set; }
public string InputToken { get; }
public string OutputToken { get; }
public virtual bool Execute(AutoCompleteEventArgs e, AutoCompleteSettings settings)
{
if (!e.IsCharacter || !IsInlineCharCompletion)
{
return false;
}
var module = e.CodeModule;
using (var pane = module.CodePane)
{
var selection = pane.Selection;
if (e.Character.ToString() == InputToken)
{
var code = module.GetLines(selection).Insert(Math.Max(0, selection.StartColumn - 1), InputToken + OutputToken);
module.ReplaceLine(selection.StartLine, code);
pane.Selection = new Selection(selection.StartLine, selection.StartColumn + 1);
e.Handled = true;
return true;
}
return false;
}
}
}
}
Classes derived from the above base class include:
public class AutoCompleteClosingBrace : AutoCompleteBase
{
public AutoCompleteClosingBrace()
: base("{", "}") { }
}
And:
public class AutoCompleteClosingParenthese : AutoCompleteBase
{
public AutoCompleteClosingParenthese()
:base("(", ")") { }
}
And also:
public class AutoCompleteClosingString : AutoCompleteBase
{
public AutoCompleteClosingString()
: base("\"", "\"") { }
}
IMO it does smell that each class has exactly nothing but constructor arguments. OTOH it does make it very easy to use reflection to automagically have the AutoCompleteProvider
know about all supported autocompletions. Any better ideas?
Block Autocompletion
Auto-completing code blocks is a tad more complicated: since Rubberduck has a Smart Indenter with a bunch of settings, not honoring the indenter settings wouldn't feel right, so we want the derived types to be able to override some predicate that determines whether the block body should be indented. But we also want to avoid completing a block that's actually a comment, and then If
and #If
tokens need to be treated differently (i.e. precompiler directives). The result is a little bit hairy, but the derived classes are just as neat (/smelly?) as the inline autocompletes:
namespace Rubberduck.AutoComplete
{
public abstract class AutoCompleteBlockBase : AutoCompleteBase
{
/// <param name="indenterSettings">Used for auto-indenting blocks as per indenter settings.</param>
/// <param name="inputToken">The token that starts the block, i.e. what to detect.</param>
/// <param name="outputToken">The token that closes the block, i.e. what to insert.</param>
protected AutoCompleteBlockBase(IConfigProvider<IndenterSettings> indenterSettings, string inputToken, string outputToken)
:base(inputToken, outputToken)
{
IndenterSettings = indenterSettings;
}
protected virtual bool FindInputTokenAtBeginningOfCurrentLine => false;
protected virtual bool SkipPreCompilerDirective => true;
protected readonly IConfigProvider<IndenterSettings> IndenterSettings;
protected virtual bool ExecuteOnCommittedInputOnly => true;
protected virtual bool MatchInputTokenAtEndOfLineOnly => false;
protected virtual bool IndentBody => true;
public override bool Execute(AutoCompleteEventArgs e, AutoCompleteSettings settings)
{
var ignoreTab = e.Keys == Keys.Tab && !settings.CompleteBlockOnTab;
if (IsInlineCharCompletion || e.Keys == Keys.None || ignoreTab)
{
return false;
}
var module = e.CodeModule;
using (var pane = module.CodePane)
{
var selection = pane.Selection;
var code = module.GetLines(selection);
if (SkipPreCompilerDirective && code.Trim().StartsWith("#"))
{
return false;
}
var pattern = SkipPreCompilerDirective
? $"\\b{InputToken}\\b"
: $"{InputToken}\\b"; // word boundary marker (\b) would prevent matching the # character
var isMatch = MatchInputTokenAtEndOfLineOnly
? code.EndsWith(InputToken, System.StringComparison.OrdinalIgnoreCase)
: Regex.IsMatch(code.Trim(), pattern, RegexOptions.IgnoreCase);
if (isMatch && !code.HasComment(out _) && !IsBlockCompleted(module, selection))
{
var indent = code.TakeWhile(c => char.IsWhiteSpace(c)).Count();
var newCode = OutputToken.PadLeft(OutputToken.Length + indent, ' ');
var stdIndent = IndentBody ? IndenterSettings.Create().IndentSpaces : 0;
module.InsertLines(selection.NextLine.StartLine, "\n" + newCode);
module.ReplaceLine(selection.NextLine.StartLine, new string(' ', indent + stdIndent));
pane.Selection = new Selection(selection.NextLine.StartLine, indent + stdIndent + 1);
e.Handled = true;
return true;
}
return false;
}
}
private bool IsBlockCompleted(ICodeModule module, Selection selection)
{
string content;
var proc = module.GetProcOfLine(selection.StartLine);
if (proc == null)
{
content = module.GetLines(1, module.CountOfDeclarationLines);
}
else
{
var procKind = module.GetProcKindOfLine(selection.StartLine);
var startLine = module.GetProcStartLine(proc, procKind);
var lineCount = module.GetProcCountLines(proc, procKind);
content = module.GetLines(startLine, lineCount);
}
var options = RegexOptions.IgnoreCase;
var inputPattern = $"(?<!{OutputToken.Replace(InputToken, string.Empty)})\\b{InputToken}\\b";
var inputMatches = Regex.Matches(content, inputPattern, options).Count;
var outputMatches = Regex.Matches(content, $"\\b{OutputToken}\\b", options).Count;
return inputMatches > 0 && inputMatches == outputMatches;
}
}
}
The matching logic allows this single AutoCompleteDoBlock
class to work with Do...Loop
, Do Until...Loop
, and Do While...Loop
constructs:
public class AutoCompleteDoBlock : AutoCompleteBlockBase
{
public AutoCompleteDoBlock(IIndenterSettings indenterSettings)
: base(indenterSettings, $"{Tokens.Do}", Tokens.Loop) { }
}
And Enum
blocks may or may not be indented, depending on indenter settings:
public class AutoCompleteEnumBlock : AutoCompleteBlockBase
{
public AutoCompleteEnumBlock(IIndenterSettings indenterSettings)
: base(indenterSettings, $"{Tokens.Enum}", $"{Tokens.End} {Tokens.Enum}") { }
protected override bool IndentBody => IndenterSettings.IndentEnumTypeAsProcedure;
}
Everything works pretty well (see it in action on YouTube), with one known edge case I'm not sure I want to address: rule of thumb, in order for it to break your VBA code, you pretty much need to be actively trying to break the feature.
Does anything look off?
These two implementations in particular, kind of smell funny:
public class AutoCompleteIfBlock : AutoCompleteBlockBase
{
public AutoCompleteIfBlock(IIndenterSettings indenterSettings)
: base(indenterSettings, $"{Tokens.Then}", $"{Tokens.End} {Tokens.If}") { }
protected override bool MatchInputTokenAtEndOfLineOnly => true;
}
public class AutoCompleteOnErrorResumeNextBlock : AutoCompleteBlockBase
{
public AutoCompleteOnErrorResumeNextBlock(IIndenterSettings indenterSettings)
: base(indenterSettings, $"{Tokens.On} {Tokens.Error} {Tokens.Resume} {Tokens.Next}", $"{Tokens.On} {Tokens.Error} {Tokens.GoTo} 0") { }
protected override bool ExecuteOnCommittedInputOnly => false;
}
Feels like the base class is going to be growing with members as per some specific derived type's needs - like the If...End If
autocomplete that needs to account for VBA's totally legal single-line If {condition} Then {statement}
syntax, which Rubberduck shouldn't try to complete... On the other hand most implementations do have extremely similar mechanisms, so the base implementation makes a rather sensible default.
TwoDim = Array( _ Array(1,2,3), _ Array(4,5,6) _ )
might have a hope of aligning nicely? \$\endgroup\$var = iif(test, true result, false result)
\$\endgroup\$