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.
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:
- Produce a command line utility that can be called from the terminal and passed one directory as parameters.
- Iterate through the directories and get the contained files
- Open each file ending in .strings as dictionary
- 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.
Categories: Recipes
A little mistake:
“To access the nth item, you use array[n] and the compiler will convert that into the correct memory address.”
Arrays are 0-based, so to access the nth item, you use array[n – 1].
Otherwise great post, I’ve also found that couple weeks ago 😉
Cheers,
Johann
Thanks for spotting my mistake. Fixed it!
Actually, array[n] really means *(array+n). Pointers and arrays are the same things, and that’s why arrays are zero-based. array[0] = *array. BTW, strings in C are also a kind of array (a nil-terminated array of char, actually). That’s why we write things like char* s = “hello”;
For an example of how to combine Objective-C classes with the main function of C-based apps have a look at a nifty little command line tool called setvol to set the system sound value (… even remotely via ssh – which may be difficult to achieve using an AppleScript / osascript command solution since that normally requires a connection to the Window Server).
http://www.cocoadev.com/index.pl?SoundVolume
For an example of a non-linear command line tool see asynctask.m that shows how to implement asynchronous stdin, stdout & stderr streams for processing data with NSTask.
http://www.cocoadev.com/index.pl?NSTask
Great post, thanks, and great idea. All in all we’re all used with scripting language and we never try to consider Obj-C command line utilities. Typically my rule of thumb is that as soon as I need to repeat a process more than 5 times in a row then this operation it is worth a script that automatizes it. Typically I use perl instead of basic Unix scripting tools such as shell scripting, awk, sed or just piping them (apart a few cases). Infact the small overhead needed by perl to do basic stuff is well compensated by the power of the language.
But the issue with all scripting languages is that they don’t come with a corresponding GUI so when you want to embed a script in a GUI you must rely on Automator or use some terrible stuff like Tk…
Now I think that the extra effort of writing Obj-C command line tools is well compensated by the power of the Cocoa framework, which gives you high level to some system features – just consider file system access but even GCD if the task is complicated – but at the same time as soon as you’re happy with your command line tool it is easy to add the native OSX GUI on the existing Obj-C code just giving you the possibility to have a powerful command line tool + a native GUI (with access to a subset of the command line functionalities).
An example of this was my recent need to customize a set of pictures to be added to an iOS app. These pictures had different sizes and aspect ratios so managing them and fitting for the iOS app required several resize and crop tasks, not easy to manage with Automator only. So I wrote a command line tool (which required as input the source directory and a few resizing parameters) to speed up the whole job. Later I found that adding a GUI that allowed me to interact with the process (pick photo, show on screen, show resized/cropped version, accept/reject) was easy by re-using the existing source code. So using native Obj-C instead of perl or python was a great choice (and all in all the overhead is limited to running Xcode instead of vim…)
Hi.. quick question.. Can I run Xcode command line program executable on mac server. I have a program that does file transfer job using NSFileManager and I wanted to put it on Mac Server. so just curious if do you know is it possible?