2011年12月18日星期日

Command Line Tools Tutorial (1)

Command Line Tools Tutorial (1):
Honestly I was very much excited when I found that I can use my current knowledge of Objective-C and Foundation classes like NSString to also build nifty little tools. Previously I had to resort to bash script to perform one-off operations on files that where too tedious to do manually. But knowing what I am going to show you in this article will enable you to also write these littler helpers.

I believe that beginners should rather start with writing a couple of command line tools before diving into building user interface driven apps. Commend line tools are linear and thus their function is easier to grasp. They are more akin to “functional programming” then “object oriented programming” if you will.

I am going to show you what goes into building a simple command line tool and you be the judge whether you agree with my assessment.

Readability

First we need a purpose for our tool. It just so happens that I have a great idea!

Purpose: Join Several Files in a Structure


Somebody handed me a set of all the Localizable.strings files from Apple’s built-in apps. Those are divided by app and there you have one lproj sub-folder per language. I want to join all of these together in a big file so that I want to look up one translation I get all languages at the same time.



The strings files themselves are binary property lists, basically a big NSDictionary each. That’s the same format that they get converted to if you build your app. Xcode takes each text strings file, omits the comment and packs them into the bplist format for faster loading.

Before you set out to coding anything it is a good idea to reflect on which steps we think we require to achieve our goal:


  1. Produce a command line utility that can be called from the terminal and passed one directory as parameters.
  2. Iterate through the directories and get the contained files
  3. Open each file ending in .strings as dictionary
  4. Assemble in memory the tokens contained in each dictionary in a way that will facilitate generating the output we require

Ok, let’s get to it.

Step 1: Command Line Tool with Parameters


We create a new project with the “Command Line Tool” from the Mac OS X/Application category. Under “Type” we see a couple of different templates. We want to use the one that says “Foundation” because this gives us all the Objective-C classes we know and love, like NSArray, NSString etc. Let’s also make it an ARC project so we don’t have to worry about memory management. I called my tool stringsextract.

As with all C-based applications the starting point for the program is in the main function which you can find in main.m.


int main (int argc, const char * argv[])
{
@autoreleasepool {
// insert code here...
NSLog(@"Hello, World!");
}
return 0;
}


If it weren’t for the autorelease pool this would look like a very normal C-function. In objc methods generally are either class (+) or instance methods (-). In c functions are standing alone.

This function main has an int as return value. If the program runs without error you want it to return 0, otherwise you want it to return something else, like 1.

It has two parameters argc and argv. The first (argument count) is the number of parameters contained in the second (argument values). Int is just a number, but the second looks a bit more completed to the untrained eye. It is an array of char pointers. Since in c arrays are same as pointers you sometimes see this also written as char **argv, because in essence it is a pointer to a pointer to a char.

Arrays in C are not secured against being overrun which is why we need the argc. Otherwise we would have no way of knowing how many entries this array has. C-style arrays are just some memory where the elements are in sequential order. To access the nth item, you use array[n-1] and the compiler will convert that into the correct memory address. (Note: in c indexes begin with 0)

Another thing to know about argc/argv is that there is always at least one parameter at index 0: the name and path of the executable. So even if you call this program without any parameters on the command line argc will be 1 and there will be a value in argv[0].

The next tricky thing besides having to deal with a c-style array is that we also have to know c-style strings to make sense of the argument values. I told you above that an array is just a bunch of items. C-strings are just a bunch of characters where the last one is a binary zero, aka ‘\0′, to delimit the string. So whenever you see char * this could either be a pointer to one character or it could be a pointer to the first character of a string.

Since we want to use Foundation constructs for our program we want to convert all c-stuff to the more intelligent variants like NSString, by means of stringWithUTF8String.

As a first experiment let’s output all the arguments. We do a normal for loop beginning with 0, convert each argument to an NSString and log it.


for (int i=0; i<argc; i++)
{
NSString *str = [NSString stringWithUTF8String:argv[i]];
NSLog(@"argv[%d] = '%@'", i, str);
}


If you hit Run now you will see the following output on the console:


2011-12-11 09:21:58.636 stringsextract[18003:707] argv[0] = '/Users/oliver/Library/Developer/Xcode/DerivedData/stringsextract-dlkdghopgfoghoanjhmahhrlotsp/Build/Products/Debug/stringsextract'


So really what I told you is true, the parameter at index 0 has the path of our binary.

How can we test our program while we are still working on it? Of course you could open a terminal and run it by hand:


iair:~ oliver$ cd /Users/oliver/Library/Developer/Xcode/DerivedData/stringsextract-dlkdghopgfoghoanjhmahhrlotsp/Build/Products/Debug/
iair:Debug oliver$ ./stringsextract bla
2011-12-11 09:25:03.357 stringsextract[18017:707] argv[0] = './stringsextract'
2011-12-11 09:25:03.363 stringsextract[18017:707] argv[1] = 'bla'


You see how I changed into the Debug output directory and executed the program there passing one extra parameter. The dot slash is necessary to tell the shell that it should take the stringsextract binary right there in the current directory. Otherwise it would go off searching the set up system paths. You also see that argv[0] does not actually contain the absolute path, but always how it was called.

Now this works, but it might be a bit tedious to work with while we are still testing and debugging our tool. Fortunately Xcode allows us to specify command line arguments in the schema. If one argument contains whitespace you need to put it in quotes.



If we hit Run like this then we can also debug and step through our code as if we had passed this parameter on the command line ourselves.

As a final step in this chapter let us add a usage text which is to be output if somebody calls this program of ours without the required one parameter.


// one parameter is required
if (argc!=2)
{
// output usage
printf("Usage: stringsextract <dir>\n");

// leave with code 1
exit(1);
}


Note the use of the c-function printf which unlike NSLog just outputs the stuff we want and no extra stuff.

Now we’re set to move to Step 2.

Step 2: Getting the Files


Foundation gives us some awesome utilities when working with the file system, most notably NSFileManager which groups together most of them. We have our parameter specifying an absolute path. So next we need to list the .app bundles contained in there.

Usually you would use [NSFileManager defaultManager] to get a file manager instance. Only if you need to set a call-back delegate you would go about alloc/init’ing it.

So following our check for the existence of at least one parameter we add:


// convert path to NSString
NSString *path = [NSString stringWithUTF8String:argv[1]];

// get file manager
NSFileManager *fileManager = [NSFileManager defaultManager];

// get directory contents
NSError *error;
NSArray *contents = [fileManager contentsOfDirectoryAtPath:path
error:&error];

// handle error
if (!contents)
{
printf("%s\n",[[error localizedDescription] UTF8String]);
exit(1);
}

NSLog(@"%@", contents);


This converts the parameter to an NSString, sets up a file manager and then gets the contents of the specified path. If this is successful then there is a non-nil value in contents. If not then error will point to a new NSError instance which gives us a nice localized description to tell the user. Again we use printf because we want the direct output. UTF8String is the method to get from an objective-C NSString to back to a regular C-string which is required by the %s print format.

The NSLog at the bottom is just temporary so that we can see if we indeed got a result. And so we did.


2011-12-11 10:08:47.023 stringsextract[18231:707] (
".DS_Store",
"AppStore.app",
"Calculator.app",
"Camera.app",
"Compass.app",
"Contacts~iphone.app",
"Game Center~iphone.app",
"Maps~iphone.app",
"MobileCal.app",
"MobileMail.app",
"MobileNotes.app",
"MobilePhone.app",
"MobileSafari.app",
"MobileSlideShow.app",
"MobileSMS.app",
"MobileStore.app",
"Music~iphone.app",
"Nike.app",
"Preferences.app",
"Reminders.app",
"Setup.app",
"Stocks.app",
"Videos.app",
"Weather.app",
"YouTube.app"
)
Program ended with exit code: 0


We also see that there is one entry that we want to ignore later on, the “.DS_Store”. So let’s filter out everything that does not end in .app as I showed in a previous post.

Now we need to iterate through the array of app paths and get the contents of these, filtering for lproj. Since the contents are without the full path we need to concatenate the individual names with the path.


// filter
NSArray *filter = [NSArray arrayWithObject:@"app"];

// execute filter in place
contents = [contents pathsMatchingExtensions:filter];

// iterate through .app
for (NSString *oneApp in contents)
{
// make full path
NSString *appPath = [path stringByAppendingPathComponent:oneApp];

// get contents, ignore error
NSArray *appContents = [fileManager contentsOfDirectoryAtPath:appPath
error:NULL];

NSLog(@"app: %@, %@", oneApp, appContents);

}


Again we have a bunch of files that we one and one that we don’t, in this case I have have a _CodeResources. Granted I could have removed these by hand but we’re writing this program here to be smart and save us manual labor. So we code around that just the same.

Rinse and repeat. We end up with three loops, one for the apps, one for the lprojs and one for the strings.


int main (int argc, const char * argv[])
{
@autoreleasepool
{
// one parameter is required
if (argc!=2)
{
// output usage
printf("Usage: stringsextract <dir>\n");

// leave with code 1
exit(1);
}

// convert path to NSString
NSString *path = [NSString stringWithUTF8String:argv[1]];

// get file manager
NSFileManager *fileManager = [NSFileManager defaultManager];

// get directory contents
NSError *error;
NSArray *contents = [fileManager contentsOfDirectoryAtPath:path
error:&error];

// handle error
if (!contents)
{
printf("%s\n",[[error localizedDescription] UTF8String]);
exit(1);
}

// filter
NSArray *filter = [NSArray arrayWithObject:@"app"];

// execute filter in place
contents = [contents pathsMatchingExtensions:filter];

// iterate through .app
for (NSString *oneApp in contents)
{
// make full path
NSString *appPath = [path stringByAppendingPathComponent:oneApp];

// get contents, ignore error
NSArray *appContents = [fileManager contentsOfDirectoryAtPath:appPath
error:NULL];

// new filter
filter = [NSArray arrayWithObject:@"lproj"];

// execute filter in place
appContents = [appContents pathsMatchingExtensions:filter];

// iterate through .lproj = one language
for (NSString *oneLang in appContents)
{
// make full path
NSString *langPath = [appPath stringByAppendingPathComponent:oneLang];

// get contents, ignore error
NSArray *langContents = [fileManager contentsOfDirectoryAtPath:langPath
error:NULL];

// new filter
filter = [NSArray arrayWithObject:@"strings"];

// execute filter in place
langContents = [langContents pathsMatchingExtensions:filter];


for (NSString *oneStrings in langContents)
{
// make full path
NSString *stringsPath = [langPath stringByAppendingPathComponent:oneStrings];

// do work here
NSLog(@"%@", stringsPath);
}
}
}
}
return 0;
}


If this code makes your stomach churn then this is because this more and more turns into spaghetti code. This can still be fine for a one-off tool, but should be avoided if you plan to hand out this tool for others to use.

For one thing I would add a category to NSFileManager that does the listing, filtering and full-path-assembling in one step. Or possibly even go one further to have the working code in a block and have this category iterate over the strings files.

This we will be exploring in the next installment of this two part tutorial.

没有评论:

发表评论