Rubberduck “GoTo Anything” Navigation

Posted on

Problem

The next version of is going to include a pretty nifty “goto anything” / find symbol navigation tool that lets the user enter an identifier name in a combobox:

navigate to any identifier in any module


UI

To achieve the templated dropdown I had to introduce some WPF interop. Here’s the markup:

<UserControl.CommandBindings>
    <CommandBinding Command="local:FindSymbolControl.GoCommand" 
                    Executed="CommandBinding_OnExecuted"
                    CanExecute="CommandBinding_OnCanExecute"/>
</UserControl.CommandBindings>

<Grid>
    
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="*" />
        <ColumnDefinition Width="32" />
    </Grid.ColumnDefinitions>
    
    <ComboBox IsEditable="True"
              ItemsSource="{Binding MatchResults}"
              SelectedItem="{Binding SelectedItem, UpdateSourceTrigger=PropertyChanged}"
              IsTextSearchCaseSensitive="False"
              IsTextSearchEnabled="True"
              TextSearch.Text="{Binding SearchString, Mode=OneWayToSource, UpdateSourceTrigger=PropertyChanged}"
              TextSearch.TextPath="IdentifierName">
        
        <ComboBox.ItemsPanel>
            <ItemsPanelTemplate>
                <VirtualizingStackPanel />
            </ItemsPanelTemplate>
        </ComboBox.ItemsPanel>

        <ComboBox.ItemTemplate>
            <DataTemplate DataType="local:SearchResult">
                <StackPanel Orientation="Horizontal" VerticalAlignment="Center">
                    <Image Height="16" Width="16" Margin="2,0,2,0" Source="{Binding Icon}" />
                    <TextBlock Margin="2,0,2,0" Text="{Binding IdentifierName}" FontWeight="Bold" MinWidth="160" VerticalAlignment="Center" />
                    <TextBlock Margin="2,0,2,0" Text="{Binding Location}" VerticalAlignment="Center" />
                </StackPanel>
            </DataTemplate>
        </ComboBox.ItemTemplate>
    </ComboBox>
    
    <Button Grid.Column="1"
            Command="local:FindSymbolControl.GoCommand">
        <Image Height="16" Source="pack://application:,,,/Rubberduck;component/Resources/arrow.png" />
    </Button>
    
</Grid>

…and the code-behind:

namespace Rubberduck.UI.FindSymbol
{
    /// <summary>
    /// Interaction logic for FindSymbolControl.xaml
    /// </summary>
    public partial class FindSymbolControl : UserControl
    {
        public FindSymbolControl()
        {
            InitializeComponent();
        }

        private FindSymbolViewModel ViewModel { get { return (FindSymbolViewModel)DataContext; } }

        private static readonly ICommand _goCommand = new RoutedCommand();
        public static ICommand GoCommand { get { return _goCommand; } }

        private void CommandBinding_OnExecuted(object sender, ExecutedRoutedEventArgs e)
        {
            ViewModel.Execute();
        }

        private void CommandBinding_OnCanExecute(object sender, CanExecuteRoutedEventArgs e)
        {
            if (ViewModel == null)
            {
                return;
            }

            e.CanExecute = ViewModel.CanExecute();
            e.Handled = true;
        }
    }
}

The WPF control is embedded inside a WinForms host, which is basically only responsible for setting the ViewModel:

namespace Rubberduck.UI.FindSymbol
{
    public partial class FindSymbolDialog : Form
    {
        public FindSymbolDialog(FindSymbolViewModel viewModel)
            : this()
        {
            findSymbolControl1.DataContext = viewModel;
        }

        public FindSymbolDialog()
        {
            InitializeComponent();
        }
    }
}

Logic

I implemented the logic inside the ViewModel class:

namespace Rubberduck.UI.FindSymbol
{
    public class FindSymbolViewModel : INotifyPropertyChanged
    {
        private static readonly DeclarationType[] ExcludedTypes =
        {
            DeclarationType.Control, 
            DeclarationType.ModuleOption
        };

        public FindSymbolViewModel(IEnumerable<Declaration> declarations, SearchResultIconCache cache)
        {
            _declarations = declarations;
            _cache = cache;
            var initialResults = _declarations
                .Where(declaration => !ExcludedTypes.Contains(declaration.DeclarationType))
                .OrderBy(declaration => declaration.IdentifierName.ToLowerInvariant())
                .Select(declaration => new SearchResult(declaration, cache[declaration]))
                .ToList();

            MatchResults = new ObservableCollection<SearchResult>(initialResults);
        }

        public event EventHandler<NavigateCodeEventArgs> Navigate;

        public bool CanExecute()
        {
            return _selectedItem != null;
        }

        public void Execute()
        {
            OnNavigate();
        }

        public void OnNavigate()
        {
            var handler = Navigate;
            if (handler != null && _selectedItem != null)
            {
                var arg = new NavigateCodeEventArgs(_selectedItem.Declaration);
                handler(this, arg);
            }
        }

        private readonly IEnumerable<Declaration> _declarations;
        private readonly SearchResultIconCache _cache;

        private void Search(string value)
        {
            var lower = value.ToLowerInvariant();
            var results = _declarations
                .Where(declaration => !ExcludedTypes.Contains(declaration.DeclarationType)
                                        && (string.IsNullOrEmpty(value) || declaration.IdentifierName.ToLowerInvariant().Contains(lower)))
                .OrderBy(declaration => declaration.IdentifierName.ToLowerInvariant())
                .Select(declaration => new SearchResult(declaration, _cache[declaration]))
                .ToList();

            MatchResults = new ObservableCollection<SearchResult>(results);
        }

        private string _searchString;

        public string SearchString
        {
            get { return _searchString; }
            set
            {
                _searchString = value; 
                Search(value);
            }
        }

        private SearchResult _selectedItem;

        public SearchResult SelectedItem
        {
            get { return _selectedItem; }
            set 
            { 
                _selectedItem = value; 
                OnPropertyChanged();
            }
        }

        private ObservableCollection<SearchResult> _matchResults;

        public ObservableCollection<SearchResult> MatchResults
        {
            get { return _matchResults; }
            set { _matchResults = value; OnPropertyChanged(); }
        }

        public event PropertyChangedEventHandler PropertyChanged;

        [NotifyPropertyChangedInvocator]
        protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
        {
            var handler = PropertyChanged;
            if (handler != null) handler(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}

A SearchResult is simply wrapping up a Declaration for presentation purposes:

namespace Rubberduck.UI.FindSymbol
{
    public class SearchResult
    {
        private readonly Declaration _declaration;
        private readonly BitmapImage _icon;

        public SearchResult(Declaration declaration, BitmapImage icon)
        {
            _declaration = declaration;
            _icon = icon;
        }

        public Declaration Declaration { get { return _declaration; } }
        public string IdentifierName { get { return _declaration.IdentifierName; } }
        public string Location { get { return _declaration.Scope; } }

        public BitmapImage Icon { get { return _icon; } }

    }
}

I originally had an IValueConverter to convert a Declaration into a BitmapImage at the XAML level, but that meant creating an instance of a bitmap for every single search result, and felt inefficient.

So I implemented this “cache” instead:

namespace Rubberduck.UI.FindSymbol
{
    public class SearchResultIconCache
    {
        private readonly IDictionary<Tuple<DeclarationType, Accessibility>, BitmapImage> _images;

        public SearchResultIconCache()
        {
            var types = Enum.GetValues(typeof (DeclarationType)).Cast<DeclarationType>();
            var accessibilities = Enum.GetValues(typeof (Accessibility)).Cast<Accessibility>();

            _images = types.SelectMany(t => accessibilities.Select(a => Tuple.Create(t, a)))
                .ToDictionary(key => key, key => new BitmapImage(GetIconUri(key.Item1, key.Item2)));
        }

        public BitmapImage this[Declaration declaration]
        {
            get
            {
                var key = Tuple.Create(declaration.DeclarationType, declaration.Accessibility);
                return _images[key];
            }
        }

        private Uri GetIconUri(DeclarationType declarationType, Accessibility accessibility)
        {
            const string baseUri = @"../../Resources/Microsoft/PNG/";

            string path;
            switch (declarationType)
            {
                case DeclarationType.Module:
                    path = "VSObject_Module.png";
                    break;

                case DeclarationType.Document | DeclarationType.Class: 
                    path = "document.png";
                    break;
                
                case DeclarationType.UserForm | DeclarationType.Class | DeclarationType.Control:
                    path = "VSProject_Form.png";
                    break;

                case DeclarationType.Class | DeclarationType.Module:
                    path = "VSProject_Class.png";
                    break;

                case DeclarationType.Procedure | DeclarationType.Member:
                case DeclarationType.Function | DeclarationType.Member:
                    if (accessibility == Accessibility.Private)
                    {
                        path = "VSObject_Method_Private.png";
                        break;
                    }
                    if (accessibility == Accessibility.Friend)
                    {
                        path = "VSObject_Method_Friend.png";
                        break;
                    }

                    path = "VSObject_Method.png";
                    break;

                case DeclarationType.PropertyGet | DeclarationType.Property | DeclarationType.Function:
                case DeclarationType.PropertyLet | DeclarationType.Property | DeclarationType.Procedure:
                case DeclarationType.PropertySet | DeclarationType.Property | DeclarationType.Procedure:
                    if (accessibility == Accessibility.Private)
                    {
                        path = "VSObject_Properties_Private.png";
                        break;
                    }
                    if (accessibility == Accessibility.Friend)
                    {
                        path = "VSObject_Properties_Friend.png";
                        break;
                    }

                    path = "VSObject_Properties.png";
                    break;

                case DeclarationType.Parameter:
                    path = "VSObject_Field_Shortcut.png";
                    break;

                case DeclarationType.Variable:
                    if (accessibility == Accessibility.Private)
                    {
                        path = "VSObject_Field_Private.png";
                        break;
                    }
                    if (accessibility == Accessibility.Friend)
                    {
                        path = "VSObject_Field_Friend.png";
                        break;
                    }

                    path = "VSObject_Field.png";
                    break;

                case DeclarationType.Constant:
                    if (accessibility == Accessibility.Private)
                    {
                        path = "VSObject_Constant_Private.png";
                        break;
                    }
                    if (accessibility == Accessibility.Friend)
                    {
                        path = "VSObject_Constant_Friend.png";
                        break;
                    }

                    path = "VSObject_Constant.png";
                    break;

                case DeclarationType.Enumeration:
                    if (accessibility == Accessibility.Private)
                    {
                        path = "VSObject_Enum_Private.png";
                        break;
                    }
                    if (accessibility == Accessibility.Friend)
                    {
                        path = "VSObject_Enum_Friend.png";
                        break;
                    }

                    path = "VSObject_Enum.png";
                    break;

                case DeclarationType.EnumerationMember | DeclarationType.Constant:
                    path = "VSObject_EnumItem.png";
                    break;

                case DeclarationType.Event:
                    if (accessibility == Accessibility.Private)
                    {
                        path = "VSObject_Event_Private.png";
                        break;
                    }
                    if (accessibility == Accessibility.Friend)
                    {
                        path = "VSObject_Event_Friend.png";
                        break;
                    }

                    path = "VSObject_Event.png";
                    break;

                case DeclarationType.UserDefinedType:
                    if (accessibility == Accessibility.Private)
                    {
                        path = "VSObject_ValueTypePrivate.png";
                        break;
                    }
                    if (accessibility == Accessibility.Friend)
                    {
                        path = "VSObject_ValueType_Friend.png";
                        break;
                    }

                    path = "VSObject_ValueType.png";
                    break;

                case DeclarationType.UserDefinedTypeMember | DeclarationType.Variable:
                    path = "VSObject_Field.png";
                    break;

                case DeclarationType.LibraryProcedure | DeclarationType.Procedure:
                case DeclarationType.LibraryFunction | DeclarationType.Function:
                    path = "VSObject_Method_Shortcut.png";
                    break;

                case DeclarationType.LineLabel:
                    path = "VSObject_Constant_Shortcut.png";
                    break;

                case DeclarationType.Project:
                    path = "VSObject_Library.png";
                    break;

                default:
                    path = "VSObject_Structure.png";
                    break;
            }

            return new Uri(baseUri + path, UriKind.Relative);
        }

    }
}

Usage

The calling code (a context menu button) uses it like this:

private void FindSymbolContextMenuClick(CommandBarButton Ctrl, ref bool CancelDefault)
{
    var declarations = _parser.Parse(IDE.ActiveVBProject, this).Declarations;
    var vm = new FindSymbolViewModel(declarations.Items.Where(item => !item.IsBuiltIn), _iconCache);
    vm.Navigate += vm_Navigate;
    using (var view = new FindSymbolDialog(vm))
    {
        view.ShowDialog();
    }

    vm.Navigate -= vm_Navigate;
}

private void vm_Navigate(object sender, NavigateCodeEventArgs e)
{
    if (e.QualifiedName.Component == null)
    {
        return;
    }

    try
    {
        e.QualifiedName.Component.CodeModule.CodePane.SetSelection(e.Selection);
    }
    catch (COMException)
    {
    }
}

For these bitwise-or operations to work I had to modify the DeclarationType enum a …bit (pun not intended), by making it a [Flags] enum:

namespace Rubberduck.Parsing.Symbols
{
    [Flags]
    public enum DeclarationType
    {
        Project = 1 << 0,
        Module = 1 << 1,
        Class = 1 << 2,
        Control = 1 << 3,
        UserForm = 1 << 4,
        Document = 1 << 5,
        ModuleOption = 1 << 6,
        Member = 1 << 7,
        Procedure = 1 << 8 | Member,
        Function = 1 << 9 | Member,
        Property = 1 << 10 | Member,
        PropertyGet = 1 << 11 | Property | Function,
        PropertyLet = 1 << 12 | Property | Procedure,
        PropertySet = 1 << 13 | Property | Procedure,
        Parameter = 1 << 14,
        Variable = 1 << 15,
        Constant = 1 << 16,
        Enumeration = 1 << 17,
        EnumerationMember = 1 << 18 | Constant,
        Event = 1 << 19,
        UserDefinedType = 1 << 20,
        UserDefinedTypeMember = 1 << 21 | Variable,
        LibraryFunction = 1 << 22 | Function,
        LibraryProcedure = 1 << 23 | Procedure,
        LineLabel = 1 << 24
    }
}

Solution

It looks like you can do the filtering and sorting of _declarations just once in the view-model constructor (parse results don’t change, and ExcludedTypes doesn’t change):

_declarations = declarations
    .Where(declaration => !ExcludedTypes.Contains(declaration.DeclarationType))
    .OrderBy(declaration => declaration.IdentifierName.ToLowerInvariant())
    .ToList();

While we’re at it, it should be more efficient to use the overload of OrderBy that takes an IComparer<T>, so we’re not making lots of throw-away strings:

_declarations = declarations
    .Where(declaration => !ExcludedTypes.Contains(declaration.DeclarationType))
    .OrderBy(declaration => declaration.IdentifierName, StringComparer.OrdinalIgnoreCase)
    .ToList();

This also lets us simplify Search:

private void Search(string value)
{
    var declarations = _declarations;
    if (!string.IsNullOrEmpty(value))
    {
        declarations = declarations
            .Where(declaration => declaration.IdentifierName.IndexOf(value, StringComparison.OrdinalIgnoreCase) != -1);
    }

    var results = declarations
        .Select(declaration => new SearchResult(declaration, _cache[declaration]));

    MatchResults = new ObservableCollection<SearchResult>(results);
}

Here I’ve used IndexOf instead of Contains so we can pass it a StringComparer, again saving on the creation of string objects.

(Consider using string.IsNullOrWhiteSpace instead of string.IsNullOrEmpty depending on the behaviour you want.)

We can save a call to Search if _searchString hasn’t changed. Not sure if it will help much in this case, but it’s something to consider.

set
{
    if (_searchString == value)
    {
        return;
    }

    _searchString = value; 
    Search(value);
}

Leave a Reply

Your email address will not be published. Required fields are marked *