Reverse engineering Xcode with dtrace

Both OS X and iOS tend to have a love-hate relationship with Xcode. Crashing, lacking solid refactoring tools, some UX failures, and so on.

For me, it is/was the lack of a good autocomplete that really ticks me off. Xcode’s autocomplete is prefix-based, so Xcode will only show completions where the start of the completion matches your incomplete word. This is rather frustrating, as the prefix for completion items tends to be the same.

For example, if you wanted to complete NSAccessibilityRoleDescriptionForUIElement, you’d probably go:

  • nsacc<TAB> to get NSAccessibility
  • ro<TAB> to get NSAccessibilityRole
  • des<TAB> to get NSAccessibilityRoleDescription
  • and finally f<TAB> to get NSAccessibilityRoleDescriptionForUIElement

Of course, you can always use arrow keys to scroll through the list at some point to select it, but using arrow keys isn’t a great solution.

It would be great if Xcode supported fuzzy autocompletion (like Sublime Text, AppCode, etc etc). I mentioned this to @alanjrogers one Friday afternoon and he told me I should write an Xcode plugin for it.

Why not?

Step 1: Making a plugin for Xcode 5

I found BlackDog Foundry’s Creating an Xcode 4 Plugin which got me off to a good start where I was able to get code loaded into Xcode 5, with a couple of Xcode 5-specific Info.plist UUID tweaks from KFCocoaPodsPlugin.

Step 2: Figure out what to hook into

With class-dump, I dumped Xcode.app’s classes and started searching for likely places to hook into. I stumbled across the DVTTextCompletion* class cluster which was a good place to start. Before you can use the dumped headers, you need to remove the - (void).cxx_destruct method declaration if it exists. You also need to remove some #import statements that cause the build to fail, like #import <objc/NSObject.h> and AppKit imports.

I added JRSwizzle with CocoaPods and started to poke at some classes I thought would be a good start, like DVTTextCompletionSession.

Unfortunately, this didn’t get me very far. Picking random methods to swizzle was a time consuming process which involved lots of restarting of Xcode. I needed a better way to figure out what I should hook into.

Some googling turned up the NSObjCMessageLoggingEnabled environment variable, where it would log every message send to /tmp/msgSend-<pid>. Unfortunately, starting up Xcode generated about 15 million lines of data (a whopping 659MB!!), so that clearly wasn’t what I was after.

dtrace to the rescue

I found a little dtrace snippet on Stackoverflow which would let you probe message sends of a certain class, which gave me a much better indication of what I should be looking at (after tracing DVTTextCompletionSession), however it only output the method calls without any context.

Further googling turned up an article by Jon Haslam on DTrace and Visualisation, where he described using the flowindent option on dtrace to show a call tree. This was better, but it was limited to showing the method name only unless you used printf to show the class. It was messy and not ideal.

I eventually found this script that essentially reimplemented flowindent but with a better visualisatino of Objective-C method calls.

I modified it to provide better indentation and allow scoping down to a certain class. Script is below:

trace_msg_send.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#!/usr/sbin/dtrace -s
#pragma D option quiet

unsigned long long indention;
int indentation_amount;

BEGIN {
  indentation_amount = 4;
}

objc$target:$1::entry
{
    method = (string)&probefunc[1];
    type = probefunc[0];
    class = probemod;
    printf("%*s%s %c[%s %s]\n", indention * indentation_amount, "", "->", type, class, method);
    indention++;
}
objc$target:$1::return
{
    indention--;
    method = (string)&probefunc[1];
    type = probefunc[0];
    class = probemod;
    printf("%*s%s %c[%s %s]\n", indention * indentation_amount, "", "<-", type, class, method);
}

Usage: sudo ./trace_msg_send.sh -p <pid of app> <objc class>

This produced a much better looking and easier to understand call tree:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
-> -[DVTTextCompletionSession initWithTextView:atLocation:cursorLocation:]
<- -[DVTTextCompletionSession initWithTextView:atLocation:cursorLocation:]
-> -[DVTTextCompletionSession showCompletionsExplicitly:]
    -> -[DVTTextCompletionSession isShowingCompletions]
    <- -[DVTTextCompletionSession isShowingCompletions]
    -> -[DVTTextCompletionSession _ensureCompletionsUpToDate]
        -> -[DVTTextCompletionSession textView]
        <- -[DVTTextCompletionSession textView]
        -> -[DVTTextCompletionSession textView]
        <- -[DVTTextCompletionSession textView]
    <- -[DVTTextCompletionSession _ensureCompletionsUpToDate]
    -> -[DVTTextCompletionSession textView]
    <- -[DVTTextCompletionSession textView]
    -> -[DVTTextCompletionSession setPendingRequestState:]
        -> -[DVTTextCompletionSession readyToShowCompletions]
            -> -[DVTTextCompletionSession filteredCompletionsAlpha]
            <- -[DVTTextCompletionSession filteredCompletionsAlpha]
        <- -[DVTTextCompletionSession readyToShowCompletions]
    <- -[DVTTextCompletionSession showCompletionsExplicitly:]
    -> -[DVTTextCompletionSession isShowingCompletions]
    <- -[DVTTextCompletionSession isShowingCompletions]
    -> -[DVTTextCompletionSession setAllCompletions:]
    <- -[DVTTextCompletionSession setAllCompletions:]
    -> -[DVTTextCompletionSession _prefixForCurrentLocation]
        -> -[DVTTextCompletionSession textView]
        <- -[DVTTextCompletionSession textView]
    <- -[DVTTextCompletionSession _prefixForCurrentLocation]
    -> -[DVTTextCompletionSession _setFilteringPrefix:forceFilter:]
        -> -[DVTTextCompletionSession allCompletions]
        <- -[DVTTextCompletionSession allCompletions]
        -> -[DVTTextCompletionSession _bestMatchInSortedArray:usingPrefix:]
        <- -[DVTTextCompletionSession _bestMatchInSortedArray:usingPrefix:]
        -> -[DVTTextCompletionSession _usefulPartialCompletionPrefixForItems:selectedIndex:filteringPrefix:]
            -> -[DVTTextCompletionSession _commonPrefixForItems:]
                -> -[DVTTextCompletionSession rangeOfFirstWordInString:]
                <- -[DVTTextCompletionSession rangeOfFirstWordInString:]

A quick breakdown of a dtrace probe

A dtrace probe is defined by provider:module:function:name. In the above script, I use objc$target:$1::entry. $target is a macro variable which is filled in by pid passed in by the command line via the -p flag. $1, $2 and so forth are the arguments passed into dtrace.

Example: sudo ./trace_msg_send.sh -p 12345 DVTTextCompletionSession

  • Provider: objc12345
  • Module: DVTTextCompletionSession
  • Function: [everything]
  • Name: entry

This will match all method entry events within DVTTextCompletionSession for pid 12345.

dtrace supports wildcards: * for multiple characters, ? for a single character.

For a more detailed rundown on dtrace, see Hooked on DTrace

Step 3: Find out what Xcode is doing

A quick swizzle of setAllCompletions: showed that Xcode sets the completion list to whatever is autocompletable at a particular scope. On a new line, it would set all the constants, C methods, macros, etc etc, which ended to be about 40,000. When autocompleting on [self on one of my plugin classes, it set the completion list to a much more managable 278.

After some digging, I found that _setFilteringPrefix:forceFilter: did what I expected and was called every time the user typed. A few hours of trial and error later, I managed to hook up Xcode’s own IDEOpenQuicklyPattern class to perform fuzzy filtering of the completions, but I still relied on calling the original fuzzy matching method because otherwise the autocompletion list window would not update correctly. This was adding 30-50ms of execution time on the main thread, which is not ideal, so the next step is to figure out what Xcode is doing behind the scenes to update this.

DVTTextCompletionListWindowController is probably what I wanted to look at, but using the same script wasn’t useful as it was filled with -[DVTTextCompletionListWindowController tableView:objectValueForTableColumn:row:] calls, and it was missing a return call so the indentation kept growing.

I wrote another script that let me filter out the method calls I didn’t want polluting my trace, and added the ability to only trace message sends within a certain method.

trace_within_method_and_filter.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#!/usr/sbin/dtrace -s
#pragma D option quiet

unsigned long long indention;
int indentation_amount;

BEGIN {
  indentation_amount = 4;
}

/* the : in method selectors must be replaced with ? */
objc$target:DVTTextCompletionSession:-_setFilteringPrefix?forceFilter?:entry
{
    tracing++;
}

objc$target:DVTTextCompletionSession:-_setFilteringPrefix?forceFilter?:return
{
    tracing--;
}

objc$target:DVTTextCompletionList*::entry
/
    tracing > 0 &&
    &probefunc[1] != "tableView:willDisplayCell:forTableColumn:row:" &&
    &probefunc[1] != "tableView:objectValueForTableColumn:row:"
/
{
    method = (string)&probefunc[1];
    type = probefunc[0];
    class = probemod;
    printf("%lu %*s%s %c[%s %s]\n", timestamp, indention * indentation_amount, "", "->", type, class, method);
    indention++;
}

objc$target:DVTTextCompletionList*::return
/
    tracing > 0 &&
    &probefunc[1] != "tableView:willDisplayCell:forTableColumn:row:" &&
    &probefunc[1] != "tableView:objectValueForTableColumn:row:"
/
{
    indention--;
    method = (string)&probefunc[1];
    type = probefunc[0];
    class = probemod;
    printf("%lu %*s%s %c[%s %s]\n", timestamp, indention * indentation_amount, "", "<-", type, class, method);
}

I added timestamps to the result because the output kept ending out of order.

I ran with sudo ./trace_within_method_and_filter.sh -ppidof xcode> output.txt, then fixed the order and removed timestamps with cat output.txt | sort -n | cut -c 17-200.

Output with calls to stuff to properties (window, session, and showingWindow) with no inner method calls removed for brevity:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
-> -[DVTTextCompletionListWindowController initWithSession:]
<- -[DVTTextCompletionListWindowController initWithSession:]
-> -[DVTTextCompletionListWindowController showWindowForTextFrame:explicitAnimation:]
    -> -[DVTTextCompletionListWindowController window]
        -> -[DVTTextCompletionListWindowController windowDidLoad]
            -> -[DVTTextCompletionListWindowController _loadColorsFromCurrentTheme]
                -> -[DVTTextCompletionListWindowController _iconShadow]
                <- -[DVTTextCompletionListWindowController _iconShadow]
                -> -[DVTTextCompletionListWindowController numberOfRowsInTableView:]
                <- -[DVTTextCompletionListWindowController numberOfRowsInTableView:]
            <- -[DVTTextCompletionListWindowController windowDidLoad]
        <- -[DVTTextCompletionListWindowController window]
        -> -[DVTTextCompletionListWindowController numberOfRowsInTableView:]
        <- -[DVTTextCompletionListWindowController numberOfRowsInTableView:]
        -> -[DVTTextCompletionListWindowController _updateSelectedRow]
            -> -[DVTTextCompletionListWindowController tableViewSelectionDidChange:]
            <- -[DVTTextCompletionListWindowController _updateSelectedRow]
            -> -[DVTTextCompletionListWindowController _updateCurrentDisplayState]
                -> -[DVTTextCompletionListWindowController _getTitleColumnWidth:typeColumnWidth:]
                    -> -[DVTTextCompletionListWindowController _preferredWindowFrameForTextFrame:columnsWidth:titleColumnX:]
                    <- -[DVTTextCompletionListWindowController _preferredWindowFrameForTextFrame:columnsWidth:titleColumnX:]
                <- -[DVTTextCompletionListWindowController _updateCurrentDisplayState]
                <- -[DVTTextCompletionListWindowController _updateSelectedRow]
                -> -[DVTTextCompletionListWindowController setHideReason:]
                    -> -[DVTTextCompletionListWindowController _usefulPrefixAttributes]
                    <- -[DVTTextCompletionListWindowController _usefulPrefixAttributes]
                    -> -[DVTTextCompletionListWindowController _updateInfoNewSelection]
                        -> -[DVTTextCompletionListWindowController showInfoForSelectedCompletionItem]
                            -> -[DVTTextCompletionListWindowController _selectedCompletionItem]
                            <- -[DVTTextCompletionListWindowController _selectedCompletionItem]
                            -> -[DVTTextCompletionListWindowController showInfoPaneForCompletionItem:]
                                -> -[DVTTextCompletionListWindowController _selectedCompletionItem]
                                <- -[DVTTextCompletionListWindowController _selectedCompletionItem]
                                -> -[DVTTextCompletionListWindowController _updateCurrentDisplayStateForQuickHelp]
                                    -> -[DVTTextCompletionListWindowController _usefulPrefixAttributes]
                                    <- -[DVTTextCompletionListWindowController _usefulPrefixAttributes]
                                <- -[DVTTextCompletionListWindowController showInfoPaneForCompletionItem:]
                            <- -[DVTTextCompletionListWindowController showInfoForSelectedCompletionItem]
                        <- -[DVTTextCompletionListWindowController showWindowForTextFrame:explicitAnimation:]

The call stack is missing some return traces and I’m not sure why yet, but we’ve found some relevant methods: _updateCurrentDisplayState and _updateSelectedRow. I wasn’t having issues with the row updating, so I grabbed a reference to DVTTextCompletionListWindowController with [self _listWindowController] and call -_updateCurrentDisplayState. This solved the display issue!

dtrace is pretty awesome, but editing dtrace scripts was annoying. If I have to do more of this, I’d probably write a wrapper using ruby-dtrace to help automate filtering etc.

FuzzyAutocomplete Plugin for Xcode

The end product is FuzzyAutocomplete (github.com/chendo/FuzzyAutocompletePlugin), which works in Xcode 5. It shouldn’t conflict with any existing plugins like KSImageNamed as most plugins expose additional completion items rather than change the filtering.

You can install it with Alcatraz or by cloning it and building it yourself. See the project on Github for more info.

Comments