3.3 Code browser with find and grep

by Marko Riedel

3.3.1 Idea

This recipe implements a search and find utility with find and grep. It may be used to search a code base of, say, Objective C code for certain code snippets for use in writing an application. It provides support for coding by “copy-and-paste.”

The basic idea is to run find on a directory in order to locate a set of files that match a shell pattern or a regular expression. We run grep for each file and look for a certain pattern. Matching lines including some context are collected and displayed for viewing. The user may inspect an entire file if she wishes.

We use a subclass of NSWindow to provide these features. Every such window consists of three components: the upper component where the user enters the pattern, the regular expression, the shell pattern, the context length and extra switches for grep into two forms. The lower half of the window contains the second and third component, which are placed in an NSSplitView, namely a scrollview that displays the result of the search and a scrollview for viewing the contents of a match. There is a button between the first and the second component. The user clicks this button to execute the query. It also doubles as a progress indicator during the search, where it fills with blue starting at the left and moving to the right as the search progresses.

The results of a query are displayed with buttons and textviews. The button’s title shows what file matched and what line. The textview shows the match including the context. The program displays the entire file in the lower scrollview when the user clicks the button. The context is selected and scrolled so that it is visible. The query thus produces an alternating sequence of buttons and textviews, one pair for each match.

We need four classes to implement this recipe. The first class is a subclass of NSView called Flipped. It implements a view whose coordinate system has its origin in the upper left corner, with the y-axis extending downwards. Then there is RangeButton, a subclass of NSButton that can store a range. It is used in the view that displays the results of the query and it stores the location and the length of the range of a match. There is a controller for interfacing with the application. Finally, the class BrowseIt implements the query window described above.

3.3.2 Implementation

Start with the usual headers. The class Flipped implents a view with a flipped coordinate system. We’ll be attaching subviews to this view. It does no drawing itself.

 
#include <Foundation/Foundation.h> 
#include <AppKit/AppKit.h> 
 
@interface Flipped : NSView 
 
- (BOOL)isFlipped; 
 
@end 
 
@implementation Flipped 
 
- (BOOL)isFlipped 
{ 
    return YES; 
} 
 
@end

The class RangeButton is trivial as it only adds an instance variable to store the range and a method to retrieve it.

 
@interface RangeButton : NSButton 
{ 
    NSRange range; 
} 
 
- (NSRange)range; 
- setRange:(NSRange)aRange; 
 
@end

The method range reads the instance variable and the method setRange writes it.

 
@implementation RangeButton 
 
- (NSRange)range 
{ 
    return range; 
} 
 
- setRange:(NSRange)aRange 
{ 
    range = aRange; 
    return self; 
} 
 
@end

The headers for the controller class and the custom window are next. The controller reads the location of the find and grep binaries from the user defaults on startup and remembers them during the lifetime of the application. This is the purpose of the two instance variables and the accessors find and grep. There is an important method that runs a command with some arguments and collects its standard output and standard error streams and returns its exit status. We will be using this method to run find and grep. There is a method that opens a new query window in response to a click on the corresponding menu item. It lets the user choose the directory to search in an openpanel.

 
@interface Controller : NSObject 
{ 
    NSString *find, *grep; 
} 
 
- (NSString *)find; 
- (NSString *)grep; 
 
- (int)readFromCommand:(NSString *)cmd 
             arguments:(NSMutableArray *)args 
                result:(NSArray **)rptr; 
 
- (void)applicationDidFinishLaunching:(NSNotification *)notif; 
- open:(id)sender; 
 
@end

The following series of definitions pertains to the user interface of the program and to the behavior of the custom window. We will not be displaying tabs in text views and define TABREP to be a string of spaces that replace a single tab. We define the width and height of the window with DIMENSION. We will be assembling two forms in the upper third of the window and we define an enumerated type that indicates the purpose of a formcell from those two forms. This lets us assemble the forms in a loop instead of coding every cell in turn. We define the titles of the form cells. The last define is for the point size of the fixed pitch font that we will use on buttons and in textviews.

 
#define TABREP @”________” 
 
#define DIMENSION 500 
 
typedef enum { 
    ARG_PATTERN = 0, 
    ARG_NAME, 
    ARG_REGEX, 
    ARG_CONTEXT, 
    ARG_SWITCHES, 
    ARG_COUNT 
} ARG; 
 
static NSString *argTitles[ARG_COUNT] = { 
    @”Pattern”, 
    @”Name”, 
    @”Regex”, 
    @”Context”, 
    @”Switches” 
}; 
 
#define DISPSIZE 12

The custom window contains many instance variables that make it easy to reference the objects in its view hierarchy. It stores the controller because it will use it to find files and grep for patterns. It also remembers what directory it is supposed to process. It stores the formcells that contain the values of the switches for find and grep. It stores the search button because it will lock focus on it to draw the progress indicator. It also stores the split view and the upper scrollview, which will hold the query results, and the lower scrollview, which is used to view entire files. The query results will be attached to the document view of the upper scrollview as they come in and the variables attachAtY and maxwidth tell us where they go (what height) and what the maximum width of a result is, respectively. We’ll be computing the necessary widths and heights of text views and what rectangle we need to scroll to for display of a match in the file viewer. That’s why we store the fixed pitch font and its bounding box.

 
@interface BrowseIt : NSWindow 
{ 
    Controller *con; 
 
    NSString *directory; 
 
    NSFormCell *args[ARG_COUNT]; 
    NSButton *sbutton; 
    NSSplitView *split; 
    NSScrollView *scrollUpper, *scrollLower; 
 
    float attachAtY, maxwidth; 
 
    NSFont *sfont; 
    NSSize bbox; 
}

A custom window (we will also refer to it by the term “browse window”) is initialized with the value of the directory that we will search and the controller that provides the facility to run commands and collect the output. There are two shorthand methods to set the document view of the upper and lower scrollviews.

 
- initWithDirectory:(NSString *)dir 
         controller:(Controller *)theController; 
 
- setUpper:(id)uv; 
- setLower:(id)lv;

We store the parameters of the last successful query in the user defaults, and read them back in for use as the initial values when we open a new browse window. This is the purpose of readArgs and writeArgs.

 
- readArgs; 
- writeArgs;

The browse window will be the delegate of the split view that it contains. We implement two methods that prevent the user from completely miniaturizing the upper or the lower scrollview.

 
- (float)splitView:(NSSplitView *)sender 
constrainMinCoordinate:(float)proposedMin ofSubviewAt:(int)offset; 
- (float)splitView:(NSSplitView *)sender 
constrainMaxCoordinate:(float)proposedMax ofSubviewAt:(int)offset;

We have a method that takes the output lines of a single successful grep on a single file and assembles the corresponding series of buttons and textviews.

 
- processMatchFor:(NSString *)fname data:(NSArray *)lines;

There is a method to display the contents of the file represented by a button in the lower scrollview.

 
- loadIt:(id)sender;

We will respond to a certain number of error conditions. The method errView returns a view that holds the error message and can be placed in one of the two scrollviews.

 
- (NSTextView *)errView:(NSString *)msg;

The method search: is the action of the search button. It runs find and iterates over the files that it outputs, invoking first grep and then processMatchFor:data: for each file in turn.

 
- search:(id)sender;

There is a deallocation method that frees the directory string. Deallocation of views will be handled by placing them in an autorelease pool upon creation, so that they are freed when they are removed from the view hierarchy or when the window is closed.

 
- (void)dealloc; 
 
@end

We discuss the implementation of the controller before the implementation of the browse window. There are two accessors that retrieve the location of the find and grep commands, which was set on startup.

 
@implementation Controller 
 
- (NSString *)find 
{ 
    return find; 
} 
 
- (NSString *)grep 
{ 
    return grep; 
}

We now discuss the very important method that runs tasks and collects their output and error streams. It returns two arrays of lines by reference in the variable rptr. The first step is to create a task object and set its launch path and its arguments. (The call to NSLog can aid in debugging.)

 
- (int)readFromCommand:(NSString *)cmd 
             arguments:(NSMutableArray *)args 
                result:(NSArray **)rptr 
{ 
    NSTask *aTask = [NSTask new]; 
 
    [aTask setLaunchPath:cmd]; 
    [aTask setArguments:args]; 
 
    // NSLog(@”%@ %@”, cmd, args);

We need a pipe to the output and error streams, so we create the appropriate objects. We retrieve two file handles for reading from the two pipes.

 
    NSPipe 
        *outPipe = [NSPipe pipe], 
        *errPipe = [NSPipe pipe]; 
    NSFileHandle 
        *outReader = [outPipe fileHandleForReading], 
        *errReader = [errPipe fileHandleForReading];

We are now almost ready to lauch the task. We connect the two pipes to the two streams and declare the variables that will hold the data that they produce.

 
    [aTask setStandardOutput:outPipe]; 
    [aTask setStandardError:errPipe]; 
 
    NSData *outData, *errData;

The actual run of the task takes place inside an exception handler. We try to run the task and read the data from the two output streams. We wait until the task exits and close the file descriptors that we used to read data from the task.

 
    NS_DURING 
 
    [aTask launch]; 
    outData = [outReader readDataToEndOfFile]; 
    errData = [errReader readDataToEndOfFile]; 
    [aTask waitUntilExit]; 
 
    close([outReader fileDescriptor]); 
    close([errReader fileDescriptor]);

The error handler terminates the task if something went wrong and the task is still running. It sets the two arrays of output and error lines to contain the reason for the exception and returns -1.

 
    NS_HANDLER 
    if([aTask isRunning]==YES){ 
        [aTask terminate]; 
    } 
 
    close([outReader fileDescriptor]); 
    close([errReader fileDescriptor]); 
 
    NSString *reason = [localException reason]; 
    NSArray *ret = [NSArray arrayWithObject:reason]; 
 
    rptr[0] = ret; 
    rptr[1] = ret; 
    return -1; 
    NS_ENDHANDLER

What remains will only be executed if we were successful in launching the task and reading from its two data streams. We check the two data objects for the presence of data. If there are data, then we convert them into a C string and split this string into lines. We do not include the last return character because it would produce an empty string at the end of the array.

 
    int p; 
    for(p = 0; p<2; p++){ 
        NSData *data = (p>0 ? errData : outData); 
        rptr[p] = 
            (![data length] ? [NSArray array] : 
             [[NSString stringWithCString:[data bytes] 
                         length:[data length]-1] 
                 componentsSeparatedByString:@\n”]); 
    }

The method returns the exit status of the task, which is an important value that can tell us whether the task succeeded or not and what problems there were, if any.

 
    return [aTask terminationStatus]; 
}

This almost completes the implementation of the controller. The penultimate method is invoked when the application finishes launching and it reads the location of the two binaries for finding and grepping from the user defaults. It requests the defaults object and the file manager for this purpose.

 
- (void)applicationDidFinishLaunching:(NSNotification *)notif 
{ 
    NSUserDefaults *ud = 
        [NSUserDefaults standardUserDefaults]; 
    NSFileManager *fm = 
        [NSFileManager defaultManager];

It looks for the find binary in the defaults and sets it to a default value if there was no entry. It raises an exception if the binary is not executable.

 
    if((find = [ud stringForKey:@”find”])==nil){ 
        find = @”/usr/bin/find”; 
    } 
    if([fm isExecutableFileAtPath:find]==NO){ 
        [NSException raise:NSGenericException 
                      format:@”bad_find_binary:_%@”, find]; 
    } 
    [find retain];

The grep binary is handled the same way: look for it among the defaults, assign a default value if it is not found, and check that it is executable.

 
    if((grep = [ud stringForKey:@”grep”])==nil){ 
        grep = @”/usr/bin/grep”; 
    } 
    if([fm isExecutableFileAtPath:grep]==NO){ 
        [NSException raise:NSGenericException 
                      format:@”bad_grep_binary:_%@”, grep]; 
    } 
    [grep retain]; 
}

The last method responds to an entry on the application’s main menu and lets the user choose the directory that she wants to search. It runs a pretty standard openpanel dialogue to get this value. It obtains the openpanel and sets the title. The user may choose directories but not files and no multiple selections are allowed.

 
- open:(id)sender 
{ 
    NSOpenPanel *openPanel = [NSOpenPanel openPanel]; 
 
    [openPanel setTitle:@”Open_directory”]; 
    [openPanel setAllowsMultipleSelection:NO]; 
    [openPanel setCanChooseDirectories:YES]; 
    [openPanel setCanChooseFiles:NO];

Should there be an entry for the key “Directory” among the user defaults, then we try to open this directory. (The value for this key is updated after successful queries.) We use the current directory if there was no entry.

 
    NSFileManager *fm = [NSFileManager defaultManager]; 
    NSString *dir = 
        [[NSUserDefaults standardUserDefaults] 
            stringForKey:@”Directory”]; 
    if(dir==nil){ 
        dir = [fm currentDirectoryPath]; 
    }

We only set the openpanel’s directory if it is indeed a directory of the file system.

 
    BOOL isDir; 
    if([fm fileExistsAtPath:dir isDirectory:&isDir]==YES && 
       isDir==YES){ 
        [openPanel setDirectory:dir]; 
    }

The last step is to run the panel. We create a new browse window for the chosen directory if the user clicked the okay button. We center the window on the screen and order it to the front.

 
    if([openPanel runModalForTypes:nil]==NSOKButton){ 
        BrowseIt *bwin = 
            [[BrowseIt alloc] 
                initWithDirectory:[openPanel filename] 
                controller:self]; 
        [bwin center]; 
        [bwin makeKeyAndOrderFront:self]; 
    } 
 
    return self; 
} 
 
@end

We may now discuss the implementation of the browse window. The initializer is first. It is very simple conceptually, since it only needs to assemble the views that go into the content view of the window. The first step is to store and retain the directory. The controller is also stored but does not need to be retained. Next we declare and set the frame of the new window and its style mask, making it closable, titled, and resizable.

 
@implementation BrowseIt 
 
- initWithDirectory:(NSString *)dir 
         controller:(Controller *)theController 
{ 
    directory = dir; 
    [directory retain]; 
 
    con = theController; 
 
    NSRect wframe = 
        NSMakeRect(0, 0, DIMENSION, DIMENSION); 
    int mask = NSTitledWindowMask  NSClosableWindowMask  
        NSResizableWindowMask; 
    [super 
        initWithContentRect:wframe 
        styleMask:mask 
        backing:NSBackingStoreBuffered 
        defer:NO];

We set the minimum size of the window and its title, which contains the name of the directory being searched.

 
    [self setMinSize:wframe.size]; 
    [self setTitle: 
              [NSString 
                   stringWithFormat:@”browse_%@”, 
                   directory]];

We must assemble the subviews that make up the interface. Start with the two forms in the upper third. We declare the frame for the forms and initialize it to be the right width; the height and the origin are set later. We declare arg to iterate over the form entries (the parameters) that we declared earlier. The variable f will be used later to iterate over the two forms, left and right, that is.

 
    NSRect fframe = 
        NSMakeRect(0, 0, DIMENSION, 0); 
    NSForm *form[2]; 
 
    form[0] = [[NSForm alloc] initWithFrame:wframe]; 
    form[1] = [[NSForm alloc] initWithFrame:wframe]; 
 
    int arg, f;

We iterate over the arguments that we require and place them in the two forms, reading the statically declared titles and choosing the left form for the first half and the right form for the second.

 
    for(arg=0; arg<ARG_COUNT; arg++){ 
        args[arg] =