Asynchronous Code Inspections

Posted on

Problem

Rubberduck code inspections can take quite a while to complete, so I decided to run them on a background thread instead of locking up the IDE. The only drawback is… the IDE isn’t locked-up while inspections are running, so the user can end up with inspection results that go against stale parse results – this could mean stale inspection results (say you add Option Explicit to a module that didn’t have it), or stale token positioning (say you add or remove lines of code; every inspection result refering to a token located below that will be misplaced when navigating).

But the UX is worth it.. I think.

The status label indicates when parsing is happening…

parsing...

…and when inspections are running:

inspecting...

All results are populated at once in the grid:

completed (4 issues)

And there’s a distinct status when no issues are found:

completed (no issues)

Here’s the presenter code:

namespace Rubberduck.UI.CodeInspections
{
    public class CodeInspectionsDockablePresenter : DockablePresenterBase
    {
        private CodeInspectionsWindow Control { get { return UserControl as CodeInspectionsWindow; } }

        private IEnumerable<VBProjectParseResult> _parseResults;
        private IList<ICodeInspectionResult> _results;
        private readonly IInspector _inspector;

        public CodeInspectionsDockablePresenter(IInspector inspector, VBE vbe, AddIn addin, CodeInspectionsWindow window)
            :base(vbe, addin, window)
        {
            _inspector = inspector;
            _inspector.IssuesFound += OnIssuesFound;
            _inspector.Reset += OnReset;
            _inspector.Parsing += OnParsing;
            _inspector.ParseCompleted += OnParseCompleted;

            Control.RefreshCodeInspections += OnRefreshCodeInspections;
            Control.NavigateCodeIssue += OnNavigateCodeIssue;
            Control.QuickFix += OnQuickFix;
            Control.CopyResults += OnCopyResultsToClipboard;
        }

        // indicates that the _parseResults are no longer in sync with the UI
        private bool _needsResync;

        private void OnParseCompleted(object sender, ParseCompletedEventArgs e)
        {
            ToggleParsingStatus(false);
            if (sender == this)
            {
                _needsResync = false;
                _parseResults = e.ParseResults;
                Task.Run(() => RefreshAsync());
            }
            else
            {
                _parseResults = e.ParseResults;
                _needsResync = true;
            }
        }

        private void OnParsing(object sender, EventArgs e)
        {
            ToggleParsingStatus();
            Control.Invoke((MethodInvoker) delegate
            {
                Control.EnableRefresh(false);
            });
        }

        private void ToggleParsingStatus(bool isParsing = true)
        {
            Control.Invoke((MethodInvoker) delegate
            {
                Control.ToggleParsingStatus(isParsing);
            });
        }

        private void OnCopyResultsToClipboard(object sender, EventArgs e)
        {
            var results = string.Join("n", _results.Select(FormatResultForClipboard));
            var text = string.Format("Rubberduck Code Inspections - {0}n{1} issue" + (_results.Count != 1 ? "s" : string.Empty) + " found.n",
                            DateTime.Now, _results.Count) + results;

            Clipboard.SetText(text);
        }

        private string FormatResultForClipboard(ICodeInspectionResult result)
        {
            var module = result.QualifiedSelection.QualifiedName;
            return string.Format(
                "{0}: {1} - {2}.{3}, line {4}",
                result.Severity,
                result.Name,
                module.Project.Name,
                module.Component.Name,
                result.QualifiedSelection.Selection.StartLine);
        }

        private int _issues;
        private void OnIssuesFound(object sender, InspectorIssuesFoundEventArg e)
        {
            Interlocked.Add(ref _issues, e.Issues.Count);
            Control.Invoke((MethodInvoker) delegate
            {
                var newCount = _issues;
                Control.SetIssuesStatus(newCount);
            });
        }

        private void OnQuickFix(object sender, QuickFixEventArgs e)
        {
            e.QuickFix(VBE);
            _needsResync = true;
            OnRefreshCodeInspections(null, EventArgs.Empty);
        }

        public override void Show()
        {
            base.Show();
            Task.Run(() => RefreshAsync());
        }

        private void OnNavigateCodeIssue(object sender, NavigateCodeEventArgs e)
        {
            try
            {
                e.QualifiedName.Component.CodeModule.CodePane.SetSelection(e.Selection);
            }
            catch (COMException)
            {
                // gulp
            }
        }

        private void OnRefreshCodeInspections(object sender, EventArgs e)
        {
            Task.Run(() => RefreshAsync()).ContinueWith(t =>
            {
                Control.SetIssuesStatus(_results.Count, true);
            });
        }

        private async Task RefreshAsync()
        {
            Control.Invoke((MethodInvoker) delegate
            {
                Control.EnableRefresh(false);
                Control.Cursor = Cursors.WaitCursor;
            });

            try
            {
                if (VBE != null)
                {
                    if (_parseResults == null || _needsResync)
                    {
                        _inspector.Parse(VBE, this);
                        return;
                    }

                    var parseResults = _parseResults.SingleOrDefault(p => p.Project == VBE.ActiveVBProject);
                    if (parseResults == null || _needsResync)
                    {
                        _inspector.Parse(VBE, this);
                        return;
                    }

                    _results = await _inspector.FindIssuesAsync(parseResults);

                    Control.Invoke((MethodInvoker) delegate
                    {
                        Control.SetContent(_results.Select(item => new CodeInspectionResultGridViewItem(item))
                            .OrderBy(item => item.Component)
                            .ThenBy(item => item.Line));
                    });
                }
            }
            catch (COMException exception)
            {
                // swallow
            }
            finally
            {
                Control.Invoke((MethodInvoker) delegate
                {
                    Control.Cursor = Cursors.Default;
                    Control.SetIssuesStatus(_issues, true);
                    Control.EnableRefresh();
                });
            }
        }

        private void OnReset(object sender, EventArgs e)
        {
            _issues = 0;
            Control.Invoke((MethodInvoker) delegate
            {
                Control.SetIssuesStatus(_issues);
                Control.InspectionResults.Clear();
            });
        }
    }
}

Here’s the code-behind for the window:

namespace Rubberduck.UI.CodeInspections
{
    public partial class CodeInspectionsWindow : UserControl, IDockableUserControl
    {
        private const string ClassId = "D3B2A683-9856-4246-BDC8-6B0795DC875B";
        string IDockableUserControl.ClassId { get { return ClassId; } }
        string IDockableUserControl.Caption { get { return "Code Inspections"; } }

        public BindingList<CodeInspectionResultGridViewItem> InspectionResults 
        {
            get { return CodeIssuesGridView.DataSource as BindingList<CodeInspectionResultGridViewItem>; }
            set { CodeIssuesGridView.DataSource = value; }
        }

        public CodeInspectionsWindow()
        {
            InitializeComponent();
            RefreshButton.Click += RefreshButtonClicked;
            QuickFixButton.ButtonClick += QuickFixButton_Click;
            GoButton.Click += GoButton_Click;
            PreviousButton.Click += PreviousButton_Click;
            NextButton.Click += NextButton_Click;
            CopyButton.Click += CopyButton_Click;

            var items = new List<CodeInspectionResultGridViewItem>();
            CodeIssuesGridView.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
            CodeIssuesGridView.DataSource = new BindingList<CodeInspectionResultGridViewItem>(items);

            CodeIssuesGridView.AutoResizeColumns();
            CodeIssuesGridView.Columns["Issue"].AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill;

            CodeIssuesGridView.SelectionChanged += CodeIssuesGridView_SelectionChanged;
            CodeIssuesGridView.CellDoubleClick += CodeIssuesGridView_CellDoubleClick;
        }

        public void ToggleParsingStatus(bool enabled = true)
        {
            StatusLabel.Image = enabled
                ? Resources.hourglass
                : Resources.exclamation_diamond;
            StatusLabel.Text = enabled
                ? RubberduckUI.Parsing
                : RubberduckUI.CodeInspections_Inspecting;
        }

        public void SetIssuesStatus(int issueCount, bool completed = false)
        {
            _issueCount = issueCount;
            if (issueCount == 0)
            {
                if (completed)
                {
                    StatusLabel.Image = Resources.tick_circle;
                    StatusLabel.Text = RubberduckUI.CodeInspections_NoIssues;
                }
                else
                {
                    StatusLabel.Image = Resources.hourglass;
                    StatusLabel.Text = RubberduckUI.CodeInspections_Inspecting;
                }
            }
            else
            {
                if (completed)
                {
                    StatusLabel.Image = Resources.exclamation_diamond;
                    StatusLabel.Text = string.Format("{0} issue" + (issueCount != 1 ? "s" : string.Empty), issueCount);
                }
                else
                {
                    StatusLabel.Image = Resources.hourglass;
                    StatusLabel.Text = string.Format("{0} ({1} issue" + (issueCount != 1 ? "s" : string.Empty) + ")", RubberduckUI.CodeInspections_Inspecting, issueCount);
                }
            }
        }

        private int _issueCount;
        public void EnableRefresh(bool enabled = true)
        {
            RefreshButton.Enabled = enabled;
            QuickFixButton.Enabled = enabled && _issueCount > 0;
        }

        public event EventHandler CopyResults;
        private void CopyButton_Click(object sender, EventArgs e)
        {
            var handler = CopyResults;
            if (handler != null)
            {
                handler(this, EventArgs.Empty);
            }
        }

        private void QuickFixButton_Click(object sender, EventArgs e)
        {
            QuickFixItemClick(QuickFixButton.DropDownItems.Cast<ToolStripMenuItem>().First(item => item.Checked), EventArgs.Empty);
        }

        private void PreviousButton_Click(object sender, EventArgs e)
        {
            var previousIssueIndex = (CodeIssuesGridView.SelectedRows[0].Index == 0)
                ? CodeIssuesGridView.Rows.Count - 1
                : CodeIssuesGridView.SelectedRows[0].Index - 1;

            CodeIssuesGridView.Rows[previousIssueIndex].Selected = true;
            var item = CodeIssuesGridView.Rows[previousIssueIndex].DataBoundItem as CodeInspectionResultGridViewItem;
            OnNavigateCodeIssue(item);
        }

        private void NextButton_Click(object sender, EventArgs e)
        {
            if (CodeIssuesGridView.Rows.Count == 0)
            {
                return;
            }

            var nextIssueIndex = (CodeIssuesGridView.SelectedRows[0].Index == CodeIssuesGridView.Rows.Count - 1)
                ? 0
                : CodeIssuesGridView.SelectedRows[0].Index + 1;

            CodeIssuesGridView.Rows[nextIssueIndex].Selected = true;
            var item = CodeIssuesGridView.Rows[nextIssueIndex].DataBoundItem as CodeInspectionResultGridViewItem;
            OnNavigateCodeIssue(item);
        }

        private IDictionary<string, Action<VBE>> _availableQuickFixes;
        private void CodeIssuesGridView_SelectionChanged(object sender, EventArgs e)
        {
            var enableNavigation = (CodeIssuesGridView.SelectedRows.Count != 0);
            NextButton.Enabled = enableNavigation;
            PreviousButton.Enabled = enableNavigation;
            GoButton.Enabled = enableNavigation;
            CopyButton.Enabled = enableNavigation;

            var quickFixMenu = QuickFixButton.DropDownItems;
            if (quickFixMenu.Count > 0)
            {
                foreach (var quickFixButton in quickFixMenu.Cast<ToolStripMenuItem>())
                {
                    quickFixButton.Click -= QuickFixItemClick;
                }
            }

            if (CodeIssuesGridView.SelectedRows.Count > 0)
            {
                var issue = (CodeInspectionResultGridViewItem) CodeIssuesGridView.SelectedRows[0].DataBoundItem;
                _availableQuickFixes = issue.GetInspectionResultItem()
                    .GetQuickFixes();
                var descriptions = _availableQuickFixes.Keys.ToList();

                quickFixMenu.Clear();
                foreach (var caption in descriptions)
                {
                    var item = (ToolStripMenuItem) quickFixMenu.Add(caption);
                    if (quickFixMenu.Count > 0)
                    {
                        item.CheckOnClick = false;
                        item.Checked = quickFixMenu.Count == 1;
                        item.Click += QuickFixItemClick;
                    }
                }
            }

            QuickFixButton.Enabled = QuickFixButton.HasDropDownItems;
        }

        public event EventHandler<QuickFixEventArgs> QuickFix;
        private void QuickFixItemClick(object sender, EventArgs e)
        {
            var quickFixButton = (ToolStripMenuItem)sender;
            if (QuickFix == null)
            {
                return;
            }

            var args = new QuickFixEventArgs(_availableQuickFixes[quickFixButton.Text]);
            QuickFix(this, args);
        }

        public void SetContent(IEnumerable<CodeInspectionResultGridViewItem> inspectionResults)
        {
            var results = inspectionResults.ToList();

            CodeIssuesGridView.DataSource = new BindingList<CodeInspectionResultGridViewItem>(results);
            CodeIssuesGridView.Refresh();
        }

        private void GoButton_Click(object sender, EventArgs e)
        {
            var issue = CodeIssuesGridView.SelectedRows[0].DataBoundItem as CodeInspectionResultGridViewItem;
            OnNavigateCodeIssue(issue);
        }

        public event EventHandler<NavigateCodeEventArgs> NavigateCodeIssue;
        private void CodeIssuesGridView_CellDoubleClick(object sender, DataGridViewCellEventArgs e)
        {
            if (e.RowIndex < 0)
            {
                return;
            }
            var issue = CodeIssuesGridView.Rows[e.RowIndex].DataBoundItem as CodeInspectionResultGridViewItem;
            OnNavigateCodeIssue(issue);
        }

        private void OnNavigateCodeIssue(CodeInspectionResultGridViewItem item)
        {
            var handler = NavigateCodeIssue;
            if (handler == null)
            {
                return;
            }

            var result = item.GetInspectionResultItem();
            handler(this, new NavigateCodeEventArgs(result.QualifiedSelection));
        }

        public event EventHandler RefreshCodeInspections;
        private void RefreshButtonClicked(object sender, EventArgs e)
        {
            var handler = RefreshCodeInspections;
            if (handler == null)
            {
                return;
            }

            toolStrip1.Refresh();

            handler(this, EventArgs.Empty);
        }
    }
}

The thing works… but something’s definitely off – RefreshAsync is running more often than it should.

Solution

Your event handler naming confused me at first. The standard naming convention is that OnEventName raises the event. And the standard handler looks like ObjectName_EventName.

public class ClassWithEvent : IDisposable
{
    public event EventHandler SomeEvent;
    protected virtual void OnSomeEvent()
    {
        var e = SomeEvent;
        if (e != null)
            e(this, EventArgs.Empty);
    }

    public void Dispose()
    {
        SomeEvent = null;
    }
}

public class ClassListensToEvent
{
    private ClassWithEvent myClass;

    public ClassListensToEvent()
    {
        myClass.SomeEvent += myClass_SomeEvent;
    }

    private void myClass_SomeEvent(object sender, EventArgs eventArgs)
    {
    }
}

I would do some heavy refactoring to RefreshAsync and how it’s used. I don’t completely understand your workflow but I would expect that RefreshAsync could be cancelled. No point in refreshing with out of date results. I would use a CancellationTokenSource and pass a CancellationToken to both RefreshAsync and Task.Run like this:

Task.Run(() => RefreshAsync(token), token);

If you get updated results the first thing you would do is call CancellationTokenSource.Cancel() to stop any existing refresh. Inside RefreshAsync you can catch and swallow TaskCancelledException gracefully.

Finally, RefreshAsync is not very asynchronous. You’re using Invoke several times which is blocking on the UI thread. Ideally RefreshAsync would be asynchronous until completion and then update the GUI once at the end (via BeginInvoke).

With these changes you can cancel RefreshAsync so you’re not wasting cycles on old data or painting old results on the GUI.

Just a few quick shoots..

To reduce the small code duplication here

private void OnParseCompleted(object sender, ParseCompletedEventArgs e)
{
    ToggleParsingStatus(false);
    if (sender == this)
    {
        _needsResync = false;
        _parseResults = e.ParseResults;
        Task.Run(() => RefreshAsync());
    }
    else
    {
        _parseResults = e.ParseResults;
        _needsResync = true;
    }
}  

you can refactor this to

private void OnParseCompleted(object sender, ParseCompletedEventArgs e)
{
    ToggleParsingStatus(false);
    _parseResults = e.ParseResults;
    _needsResync = sender != this
    if (!needsResync)
    {
        Task.Run(() => RefreshAsync());
    }
}

Inside the private async Task RefreshAsync() method you have some more “problems”. First you should reduce the horizontal spacing by using a guard clause on if (VBE == null) and return early.

The next problem is here

 var parseResults = _parseResults.SingleOrDefault(p => p.Project == VBE.ActiveVBProject);  

can you see it ?? A call to SingleOrDefault() will based on the name return either a single item or a default item. A single item != plural ! So rename the local variable to parseResult.

At a closer look at these two if statements

    if (_parseResults == null || _needsResync)
    {
        _inspector.Parse(VBE, this);
        return;
    }

    var parseResults = _parseResults.SingleOrDefault(p => p.Project == VBE.ActiveVBProject);
    if (parseResults == null || _needsResync)
    {
        _inspector.Parse(VBE, this);
        return;
    }  

we see that at the second if condition we can omit the || _needsResync because this won’t ever be true.


Just seen the nice way how you write tenary expressions.

Small comment, I would refactor OnCopyResultsToClipboard like this:

    private void OnCopyResultsToClipboard(object sender, EventArgs e)
    {
        var results = string.Join(Environment.NewLine, _results.Select(FormatResultForClipboard));
        var plural = _results.Count != 1 ? "s" : string.Empty;
        var text = string.Format("Rubberduck Code Inspections - {0}{3}{1} issue{2} found.{3} {4}",
                        DateTime.Now, _results.Count, plural, Environment.NewLine, results);

        Clipboard.SetText(text);
    }

You were using string concatenations alongside string format which struck me as odd.

Leave a Reply

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