Files
cappuccino/Tools/XcodeCapp/XcodeCapp/TNXcodeCapp.m
T
Aparajita Fishman 52a53fb8d1 Fixed: if .xcodeproj was missing, it was not created.
Now if either .XcodeSupport or the .xcodeproj is missing, both are recreated to ensure they are in sync.

Also, when trying to open the Xcode project, if it is missing or cannot be opened, an alert is displayed and the user is given the option of regenerating the project.

Some code cleanup as well.
2013-04-26 14:27:47 -04:00

1253 lines
43 KiB
Objective-C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* This file is a part of program XcodeCapp
* Copyright (C) 2011 Antoine Mercadal (<primalmotion@archipelproject.org>)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#import <Growl/Growl.h>
#import "TNXcodeCapp.h"
#import "FSEventCallback.h"
#import "UserDefaults.h"
#include "macros.h"
enum XCCTaskReturnType {
kTaskReturnTypeNone,
kTaskReturnTypeStdOut,
kTaskReturnTypeStdError
};
typedef enum XCCTaskReturnType XCCTaskReturnType;
enum XCCLineSpecifier {
kLineSpecifierNone,
kLineSpecifierColon,
kLineSpecifierMinusL,
kLineSpecifierPlus
};
typedef enum XCCLineSpecifier XCCLineSpecifier;
enum {
XCCStatusCodeError = 1,
XCCStatusCodeWarning = 2
};
const char *XCCCloseProjectScript =
"tell application \"Xcode\"\n"
"set docs to (document of every window)\n"
"repeat with doc in docs\n"
"if class of doc is workspace document then\n"
"set docURL to file of doc\n"
"set docPath to docURL as text\n"
"set docPath to POSIX path of docPath\n"
"if docPath begins with \"%@\" then\n"
"close doc\n"
"return\n"
"end if\n"
"end if\n"
"end repeat\n"
"end tell";
NSString * const XCCDidPopulateProjectNotification = @"XCCDidPopulateProjectNotification";
NSString * const XCCConversionDidStartNotification = @"XCCConversionDidStartNotification";
NSString * const XCCConversionDidStopNotification = @"XCCConversionDidStopNotification";
NSString * const XCCListeningDidStartNotification = @"XCCListeningDidStartNotification";
NSString * const XCCSlashReplacement = @""; // DIVISION SLASH, Unicode: U+2215
NSString * const XCCBashPath = @"/bin/bash";
NSString * const XCCZShPath = @"/bin/zsh";
NSString * const XCCDirectoriesToIgnorePattern = @"^(?:Build|F(?:rameworks|oundation)|AppKit|Objective-J|(?:Browser|CommonJS)\\.environment|Resources|XcodeSupport|.+\\.xcodeproj)$";
NSRegularExpression * XCCDirectoriesToIgnoreRegex = nil;
NSArray *XCCDefaultIgnoredPathRegexes = nil;
@interface TNXcodeCapp ()
@property FSEventStreamRef stream;
@property NSFileManager *fm;
@property NSMutableArray *ignoredPathRegexes;
@property NSNumber *lastEventId;
@property NSString *parserPath;
@property NSString *XcodeSupportPBXPath;
@property NSString *XcodeTemplatePBXPath;
@property NSString *profilePath;
@property NSString *shellPath;
@property NSString *PBXModifierScriptPath;
@property NSString *closeProjectScriptSource;
@property NSString *supportPath;
@property NSDate *appStartedTimestamp;
@property NSMutableDictionary *pathModificationDates;
@property NSMutableDictionary *projectPathsForSourcePaths;
@property NSString *xcodecappIgnorePath;
@end
@implementation TNXcodeCapp
#pragma mark - Initialization
+ (void)initialize
{
if (self != [TNXcodeCapp class])
return;
NSError *error = NULL;
XCCDirectoriesToIgnoreRegex = [NSRegularExpression regularExpressionWithPattern:XCCDirectoriesToIgnorePattern options:0 error:&error];
NSArray *defaultIgnoredPaths = @[
@"*/Frameworks/",
@"!*/Frameworks/Debug/",
@"*/AppKit/",
@"*/Foundation/",
@"*/Objective-J/",
@"*/*.environment/",
@"*/Build/",
@"*/.*/",
@"*/NS_*.j",
@"*/main.j",
@"*/.*",
@"!*/.xcodecapp-ignore"
];
XCCDefaultIgnoredPathRegexes = [self parseIgnorePaths:defaultIgnoredPaths];
}
- (id)init
{
self = [super init];
if (self)
{
self.errorList = [NSMutableArray arrayWithCapacity:10];
self.fm = [NSFileManager defaultManager];
self.ignoredPathRegexes = [NSMutableArray new];
self.parserPath = [[[NSBundle mainBundle] sharedSupportPath] stringByAppendingPathComponent:@"parser.j"];
self.closeProjectScriptSource = [NSString stringWithUTF8String:XCCCloseProjectScript];
self.lastEventId = [[NSUserDefaults standardUserDefaults] objectForKey:kDefaultLastEventId];
self.appStartedTimestamp = [NSDate date];
self.projectPathsForSourcePaths = [NSMutableDictionary new];
self.xcodecappIgnorePath = @"";
self.isListening = NO;
self.isUsingFileLevelAPI = NO;
self.isLoadingProject = NO;
SInt32 versionMajor = 0;
SInt32 versionMinor = 0;
Gestalt(gestaltSystemVersionMajor, &versionMajor);
Gestalt(gestaltSystemVersionMinor, &versionMinor);
self.supportsFileLevelAPI = versionMajor >= 10 && versionMinor >= 7;
// Uncomment to simulate 10.6 mode
// self.supportsFileLevelAPI = NO;
[self configureFileAPI];
[self getShellProfilePath];
NSUserNotificationCenter.defaultUserNotificationCenter.delegate = self;
[GrowlApplicationBridge setGrowlDelegate:self];
}
return self;
}
- (void)start
{
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
if (![defaults boolForKey:kDefaultXCCReopenLastProject])
return;
NSString *lastOpenedPath = [defaults objectForKey:kDefaultLastOpenedPath];
if (lastOpenedPath)
{
if ([self.fm fileExistsAtPath:lastOpenedPath])
[self listenToProjectAtPath:lastOpenedPath];
else
[defaults removeObjectForKey:kDefaultLastOpenedPath];
}
}
/*!
Stops the event listener, clears state
*/
- (void)stop
{
[self stopEventStream];
[self clearErrors:self];
[self updateUserDefaultsWithLastEventId];
[[NSUserDefaults standardUserDefaults] synchronize];
self.currentProjectPath = nil;
[self.ignoredPathRegexes removeAllObjects];
}
#pragma mark - Project Management
/*!
Create Xcode project and .XcodeSupport directory if necessary.
@return YES if both exist
*/
- (BOOL)prepareXcodeSupportProject
{
self.XcodeTemplatePBXPath = [[NSBundle mainBundle] pathForResource:@"project" ofType:@"pbxproj"];
NSString *projectName = [self.currentProjectPath.lastPathComponent stringByAppendingString:@".xcodeproj"];
self.XcodeSupportProjectPath = [self.currentProjectPath stringByAppendingPathComponent:projectName];
self.supportPath = [self.currentProjectPath stringByAppendingPathComponent:@".XcodeSupport"];
self.XcodeSupportPBXPath = [self.XcodeSupportProjectPath stringByAppendingPathComponent:@"project.pbxproj"];
self.PBXModifierScriptPath = [[[NSBundle mainBundle] sharedSupportPath] stringByAppendingPathComponent:@"pbxprojModifier.py"];
// If either the project or the support directory are missing, recreate them both to ensure they are in sync
BOOL projectExists, projectIsDirectory;
projectExists = [self.fm fileExistsAtPath:self.XcodeSupportProjectPath isDirectory:&projectIsDirectory];
BOOL supportExists, supportIsDirectory;
supportExists = [self.fm fileExistsAtPath:self.supportPath isDirectory:&supportIsDirectory];
if (!projectExists || !projectIsDirectory || !supportExists)
{
if (projectExists)
[self.fm removeItemAtPath:self.XcodeSupportProjectPath error:nil];
[self.fm createDirectoryAtPath:self.XcodeSupportProjectPath withIntermediateDirectories:YES attributes:nil error:nil];
NSLog(@"Xcode support folder created at: %@", self.XcodeSupportProjectPath);
DLog(@"Copying project.pbxproj from %@ to %@", self.XcodeTemplatePBXPath, self.XcodeSupportProjectPath);
[self.fm copyItemAtPath:self.XcodeTemplatePBXPath toPath:self.XcodeSupportPBXPath error:nil];
DLog(@"Reading the content of the project.pbxproj");
NSMutableString *PBXContent = [NSMutableString stringWithContentsOfFile:self.XcodeSupportPBXPath encoding:NSUTF8StringEncoding error:nil];
[PBXContent writeToFile:self.XcodeSupportPBXPath atomically:YES encoding:NSUTF8StringEncoding error:nil];
DLog(@"PBX file adapted to the project");
}
// If the project did not exist, reset the XcodeSupport directory to force the new empty project to be populated
if (!supportExists || !supportIsDirectory || !projectExists)
{
if (supportExists)
[self.fm removeItemAtPath:self.supportPath error:nil];
[self.fm createDirectoryAtPath:self.supportPath withIntermediateDirectories:YES attributes:nil error:nil];
DLog(@"Created XcodeSupport directory");
}
return projectExists && supportExists;
}
/*!
Create and initialize the Xcode project.
*/
- (void)populateXcodeProject:(BOOL)shouldNotify
{
// First populate with all non-framework code
[self populateXcodeProjectWithProjectRelativePath:@""];
// Now populate with any user source debug frameworks
[self populateXcodeProjectWithProjectRelativePath:@"Frameworks/Debug"];
// Now populate with any source frameworks
[self populateXcodeProjectWithProjectRelativePath:@"Frameworks/Source"];
// Now populate resources
[self populateXcodeProjectWithProjectRelativePath:@"Resources"];
if (shouldNotify)
[[NSNotificationCenter defaultCenter] postNotificationName:XCCDidPopulateProjectNotification object:self userInfo:nil];
}
- (void)populateXcodeProjectWithProjectRelativePath:(NSString *)aProjectPath
{
NSError *error = NULL;
NSString *projectPath = [self.currentProjectPath stringByAppendingPathComponent:aProjectPath];
NSArray *urls = [self.fm contentsOfDirectoryAtURL:[NSURL fileURLWithPath:projectPath.stringByResolvingSymlinksInPath]
includingPropertiesForKeys:@[NSURLIsDirectoryKey, NSURLIsSymbolicLinkKey]
options:NSDirectoryEnumerationSkipsHiddenFiles | NSDirectoryEnumerationSkipsPackageDescendants | NSDirectoryEnumerationSkipsSubdirectoryDescendants
error:&error];
if (!urls)
return;
for (NSURL *url in urls)
{
NSString *filename = url.lastPathComponent;
NSString *projectRelativePath = [aProjectPath stringByAppendingPathComponent:filename];
NSString *realPath = url.path;
NSURL *resolvedURL = url;
NSNumber *isDirectory, *isSymlink;
[url getResourceValue:&isSymlink forKey:NSURLIsSymbolicLinkKey error:nil];
if (isSymlink.boolValue == YES)
{
resolvedURL = [url URLByResolvingSymlinksInPath];
if ([resolvedURL checkResourceIsReachableAndReturnError:nil])
{
filename = resolvedURL.lastPathComponent;
realPath = resolvedURL.path;
}
else
continue;
}
[resolvedURL getResourceValue:&isDirectory forKey:NSURLIsDirectoryKey error:nil];
if (isDirectory.boolValue == YES)
{
if ([XCCDirectoriesToIgnoreRegex numberOfMatchesInString:filename options:0 range:NSMakeRange(0, filename.length)] > 0)
continue;
// If the resolved path is not within the project directory, add a mapping to it
// so we can map the resolved path back to the project directory later.
if (isSymlink.boolValue == YES)
{
NSString *fullProjectPath = [self.currentProjectPath stringByAppendingPathComponent:projectRelativePath];
if (![realPath hasPrefix:fullProjectPath])
self.projectPathsForSourcePaths[realPath] = fullProjectPath;
}
[self populateXcodeProjectWithProjectRelativePath:projectRelativePath];
continue;
}
if ([self pathMatchesIgnoredPaths:realPath])
continue;
NSString *projectSourcePath = [self.currentProjectPath stringByAppendingPathComponent:projectRelativePath];
if ([self isObjjFile:filename] || [self isXibFile:filename])
{
NSString *processedPath;
if ([self isObjjFile:filename])
processedPath = [[self shadowBasePathForSourcePath:realPath] stringByAppendingPathExtension:@"h"];
else
processedPath = [realPath.stringByDeletingPathExtension stringByAppendingPathExtension:@"cib"];
if (![self.fm fileExistsAtPath:processedPath])
[self processModifiedFileAtPath:realPath projectSourcePath:projectSourcePath notify:YES];
}
}
}
- (void)listenToProjectAtPath:(NSString *)path
{
self.isLoadingProject = YES;
[self notifyUserWithTitle:@"Loading project…" message:path.lastPathComponent];
[self clearErrors:self];
self.currentProjectPath = path;
self.xcodecappIgnorePath = [self.currentProjectPath stringByAppendingPathComponent:@".xcodecapp-ignore"];
self.projectPathsForSourcePaths = [NSMutableDictionary new];
[self computeIgnoredPaths];
BOOL projectNeedsPopulation = [self prepareXcodeSupportProject];
[self populateXcodeProject:projectNeedsPopulation];
self.isLoadingProject = NO;
[self initializeEventStreamWithPath:self.currentProjectPath];
[[NSNotificationCenter defaultCenter] postNotificationName:XCCListeningDidStartNotification object:self userInfo:nil];
[[NSUserDefaults standardUserDefaults] setObject:self.currentProjectPath forKey:kDefaultLastOpenedPath];
[self notifyUserWithTitle:@"Project loaded" message:self.currentProjectPath.lastPathComponent];
}
- (IBAction)openInXcode:(id)aSender
{
BOOL isDirectory, opened = YES;
BOOL exists = [self.fm fileExistsAtPath:self.XcodeSupportProjectPath isDirectory:&isDirectory];
if (exists && isDirectory)
{
DLog(@"Opening Xcode project at: %@", self.XcodeSupportProjectPath);
opened = [[NSWorkspace sharedWorkspace] openFile:self.XcodeSupportProjectPath];
}
if (!exists || !isDirectory || !opened)
{
NSString *text;
if (!opened)
text = @"The project exists, but failed to open.";
else
text = [NSString stringWithFormat:@"%@ %@.", self.XcodeSupportProjectPath, !exists ? @"does not exist" : @"is not an Xcode project"];
[[NSApplication sharedApplication] activateIgnoringOtherApps:YES];
NSInteger response = NSRunAlertPanel(@"The project could not be opened.", @"%@\n\nWould you like to regenerate the project?", @"Yes", @"No", nil, text);
if (response == NSAlertDefaultReturn)
[self resetProject:self];
}
}
- (IBAction)resetProject:(id)aSender
{
NSString *projectPath = self.currentProjectPath;
[self stop];
// Close the project in Xcode if it's open
NSString *source = [NSString stringWithFormat:self.closeProjectScriptSource, projectPath];
NSAppleScript *script = [[NSAppleScript alloc] initWithSource:source];
NSAppleEventDescriptor *descriptor;
descriptor = [script executeAndReturnError:nil];
[self.fm removeItemAtPath:self.XcodeSupportProjectPath error:nil];
[self.fm removeItemAtPath:self.supportPath error:nil];
[self listenToProjectAtPath:projectPath];
}
#pragma mark - Event Stream
/*!
Initializes the FSEvent stream
@param path the path of the folder to watch
*/
- (void)initializeEventStreamWithPath:(NSString *)path
{
if (self.isListening)
return;
[self stopEventStream];
NSMutableArray *pathsToWatch = [NSMutableArray arrayWithObject:path];
FSEventStreamCreateFlags flags = 0;
if (self.supportsFileBasedListening)
{
DLog(@"Initializing the FSEventStream at file level (clean)");
flags = kFSEventStreamCreateFlagUseCFTypes | kFSEventStreamCreateFlagNoDefer | kFSEventStreamCreateFlagFileEvents;
}
else
{
NSLog(@"Initializing the FSEventStream at folder level (dirty)");
flags = kFSEventStreamCreateFlagUseCFTypes;
}
NSArray *directoriesToWatch = @[@"", @"Frameworks/Debug", @"Frameworks/Source"];
for (NSString *directory in directoriesToWatch)
{
NSString *fullPath = [self.currentProjectPath stringByAppendingPathComponent:directory];
BOOL exists, isDirectory;
exists = [self.fm fileExistsAtPath:fullPath isDirectory:&isDirectory];
if (exists && isDirectory)
[self watchSymlinkedDirectoriesAtPath:directory pathsToWatch:pathsToWatch];
}
void *appPointer = (__bridge void *)self;
FSEventStreamContext context = { 0, appPointer, NULL, NULL, NULL };
CFTimeInterval latency = 2.0;
self.stream = FSEventStreamCreate(NULL, &fsevents_callback, &context, (__bridge CFArrayRef) pathsToWatch,
self.lastEventId.unsignedLongLongValue, latency, flags);
FSEventStreamScheduleWithRunLoop(self.stream, CFRunLoopGetCurrent(), kCFRunLoopDefaultMode);
FSEventStreamStart(self.stream);
self.isListening = YES;
}
- (void)watchSymlinkedDirectoriesAtPath:(NSString *)projectPath pathsToWatch:(NSMutableArray *)pathsToWatch
{
NSString *fullProjectPath = [self.currentProjectPath stringByAppendingPathComponent:projectPath];
NSError *error = NULL;
NSArray *urls = [self.fm contentsOfDirectoryAtURL:[NSURL fileURLWithPath:fullProjectPath]
includingPropertiesForKeys:@[NSURLIsDirectoryKey, NSURLIsSymbolicLinkKey]
options:NSDirectoryEnumerationSkipsHiddenFiles | NSDirectoryEnumerationSkipsPackageDescendants | NSDirectoryEnumerationSkipsSubdirectoryDescendants
error:&error];
for (NSURL *url in urls)
{
NSNumber *isSymlink;
[url getResourceValue:&isSymlink forKey:NSURLIsSymbolicLinkKey error:nil];
if (isSymlink.boolValue == YES)
{
NSURL *resolvedURL = [url URLByResolvingSymlinksInPath];
if ([resolvedURL checkResourceIsReachableAndReturnError:nil])
{
NSNumber *isDirectory;
[resolvedURL getResourceValue:&isDirectory forKey:NSURLIsDirectoryKey error:nil];
if (isDirectory.boolValue == YES)
{
NSString *filename = resolvedURL.lastPathComponent;
if ([XCCDirectoriesToIgnoreRegex numberOfMatchesInString:filename options:0 range:NSMakeRange(0, filename.length)] == 0)
[pathsToWatch addObject:resolvedURL.path];
}
}
}
}
}
- (void)stopEventStream
{
if (self.stream)
{
FSEventStreamStop(self.stream);
FSEventStreamUnscheduleFromRunLoop(self.stream, CFRunLoopGetCurrent(), kCFRunLoopDefaultMode);
FSEventStreamInvalidate(self.stream);
FSEventStreamRelease(self.stream);
self.stream = NULL;
}
self.isListening = NO;
}
/*!
Choose the API mode according to default
*/
- (void)configureFileAPI
{
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
if (!self.supportsFileLevelAPI)
{
DLog(@"System doesn't support file level API");
[defaults setObject:[NSNumber numberWithInt:kXCCAPIModeFolder] forKey:kDefaultXCCAPIMode];
}
switch ([defaults integerForKey:kDefaultXCCAPIMode])
{
case kXCCAPIModeAuto:
self.supportsFileBasedListening = self.supportsFileLevelAPI;
break;
case kXCCAPIModeFile:
self.supportsFileBasedListening = YES;
break;
case kXCCAPIModeFolder:
self.supportsFileBasedListening = NO;
break;
}
if (self.supportsFileBasedListening)
{
DLog(@"using 10.7+ mode listening (clean)");
self.currentAPIMode = @"File level (Lion)";
self.isUsingFileLevelAPI = YES;
self.reactToInodeModification = [defaults boolForKey:kDefaultXCCReactMode];
}
else
{
DLog(@"using 10.6 mode listening (dirty)");
self.reactToInodeModification = NO;
self.currentAPIMode = @"Folder level (Snow Leopard)";
self.isUsingFileLevelAPI = NO;
}
}
/*!
Update the last event ID. We use a method because
this is called from outside the class, in the FSEvent callback.
@param eventId the current event ID value
*/
- (void)updateLastEventId:(uint64_t)eventId
{
self.lastEventId = [NSNumber numberWithUnsignedLongLong:eventId];
}
/*!
Updates the user defaults with the last recorded event Id.
*/
- (void)updateUserDefaultsWithLastEventId
{
if (self.lastEventId && self.lastEventId.longLongValue != 0)
[[NSUserDefaults standardUserDefaults] setObject:self.lastEventId forKey:kDefaultLastEventId];
}
#pragma mark - Shell Helpers
/*!
Run an NSTask with the given arguments
@param arguments NSArray containing the NSTask arguments
@return NSarray containing the return status (int) and the response (string)
*/
- (NSDictionary *)runTaskWithLaunchPath:(NSString *)launchPath arguments:(NSArray *)arguments returnType:(XCCTaskReturnType)returnType
{
NSTask *task = [NSTask new];
task.launchPath = launchPath;
task.arguments = arguments;
task.standardOutput = [NSPipe pipe];
task.standardError = [NSPipe pipe];
[task launch];
if (returnType != kTaskReturnTypeNone)
{
[task waitUntilExit];
NSData *data = [[(returnType == kTaskReturnTypeStdOut ? task.standardOutput : task.standardError) fileHandleForReading] availableData];
NSString *response = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
NSNumber *status = [NSNumber numberWithInt:task.terminationStatus];
return @{ @"status":status, @"response":response };
}
else
{
return @{ @"status":@0, @"response":@"" };
}
}
- (void)getShellProfilePath
{
NSString* myShell = [[[NSProcessInfo processInfo] environment] objectForKey:@"SHELL"];
self.shellPath = myShell ? myShell : XCCBashPath;
self.profilePath = @"";
NSArray *profiles;
NSString *path;
if ([self.shellPath hasSuffix:@"/bash"])
{
profiles = @[@"~/.bash_profile", @"~/.bashrc", @"~/.profile"];
}
else if ([self.shellPath hasSuffix:@"/zsh"])
{
profiles = @[@"~/.zshrc", @"~/.profile"];
}
else
{
[[NSApplication sharedApplication] activateIgnoringOtherApps:YES];
NSRunAlertPanel(@"Unsupported shell.", @"You are using %@ as your shell. XcodeCapp requires either bash or zsh to run.", nil, nil, nil, self.shellPath);
[[NSRunningApplication currentApplication] terminate];
return;
}
for (NSString *profile in profiles)
{
path = profile.stringByExpandingTildeInPath;
if ([self.fm fileExistsAtPath:path])
{
self.profilePath = path;
return;
}
}
}
#pragma mark - Event Handlers
/*!
Handle a file modification. If it's a .j or xib/nib,
perform the appropriate conversion. If it's .xcodecapp-ignore, it will
update the list of ignored files.
@param fullPath The full path of the modified file
@param shouldNotify If YES, Growl notifications will be displayed
*/
- (void)handleFileModificationAtPath:(NSString*)path notify:(BOOL)shouldNotify
{
if (![self isXibFile:path] && ![self isObjjFile:path] && ![self isXCCIgnoreFile:path])
return;
if ([self pathMatchesIgnoredPaths:path] || ![self.fm fileExistsAtPath:path])
return;
NSString *projectPath = [self projectPathForSourcePath:path];
[self processModifiedFileAtPath:path projectSourcePath:projectPath notify:shouldNotify];
}
- (BOOL)processModifiedFileAtPath:(NSString *)realSourcePath projectSourcePath:(NSString *)projectSourcePath notify:(BOOL)shouldNotify
{
DLog(@"Processing modified file: %@", realSourcePath);
BOOL success = YES;
NSArray *arguments = nil;
NSArray *pbxArguments = nil;
NSString *response = nil;
NSString *projectRelativePath = [projectSourcePath substringFromIndex:self.currentProjectPath.length + 1];
NSString *notificationTitle = nil;
NSString *notificationMessage = projectRelativePath.lastPathComponent;
[[NSNotificationCenter defaultCenter] postNotificationName:XCCConversionDidStartNotification object:self];
// Remove all errors for the path being processed
NSIndexSet *matchingErrors = [self.errorList indexesOfObjectsPassingTest:^BOOL(id obj, NSUInteger idx, BOOL *stop)
{
return [[obj valueForKey:@"path"] isEqualToString:realSourcePath];
}];
[self.errorListController removeObjectsAtArrangedObjectIndexes:matchingErrors];
if ([self isXibFile:realSourcePath])
{
// nib2cib can take a while to run, show a message while the conversion is happening
[self notifyUserWithTitle:@"Converting xib…" message:projectRelativePath.lastPathComponent];
arguments = @[
@"-c",
[NSString stringWithFormat:@"source '%@'; nib2cib --no-colors '%@'", self.profilePath, realSourcePath],
@""
];
notificationTitle = @"Xib converted";
}
else if ([self isObjjFile:realSourcePath])
{
arguments = @[
@"-c",
[NSString stringWithFormat:@"(source '%@'; objj '%@' '%@' '%@') 2>&1",
self.profilePath,
self.parserPath,
realSourcePath,
self.supportPath]
];
pbxArguments = @[
@"-c",
[NSString stringWithFormat:@"(source '%@'; python '%@' add '%@' '%@') 2>&1",
self.profilePath,
self.PBXModifierScriptPath,
self.currentProjectPath,
projectSourcePath]
];
notificationTitle = @"Objective-J source processed";
}
else if ([self isXCCIgnoreFile:realSourcePath])
{
[self computeIgnoredPaths];
notificationTitle = @"Parsed .xcodecapp-ignore";
notificationMessage = @"Ignored paths updated";
arguments = nil;
}
// Run the task and get the response if needed
if (arguments)
{
DLog(@"Running conversion task...");
NSDictionary *taskResult = [self runTaskWithLaunchPath:self.shellPath
arguments:arguments
returnType:[self isObjjFile:realSourcePath] ? kTaskReturnTypeStdOut : kTaskReturnTypeStdError];
NSInteger status = [taskResult[@"status"] intValue];
response = taskResult[@"response"];
DLog(@"Conversion task result/response: %ld/%@", status, response);
if (status != 0)
{
success = NO;
if (response.length == 0)
response = @"An unspecified error occurred";
if ([self isXibFile:realSourcePath])
{
notificationTitle = @"Error converting xib";
NSString *message = [NSString stringWithFormat:@"%@\n%@", realSourcePath.lastPathComponent, response];
[self.errorListController addObject:@{ @"message":message, @"path":realSourcePath, @"status":taskResult[@"status"] }];
}
else
{
notificationTitle = [(status == XCCStatusCodeError ? @"Error" : @"Warning") stringByAppendingString:@" parsing Objective-J source"];
NSArray *errors = [response propertyList];
for (NSDictionary *error in errors)
{
NSMutableDictionary *newError = [error mutableCopy];
newError[@"message"] = [NSString stringWithFormat:@"%@, line %d\n%@", [error[@"path"] lastPathComponent], [error[@"line"] intValue], error[@"message"]];
newError[@"status"] = taskResult[@"status"];
[self.errorListController addObject:newError];
}
}
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
if ((status == XCCStatusCodeError && [defaults boolForKey:kDefaultXCCAutoOpenErrorsPanelOnErrors]) ||
(status == XCCStatusCodeWarning && [defaults boolForKey:kDefaultXCCAutoOpenErrorsPanelOnWarnings]))
{
[self openErrorsPanel:self];
}
[self notifyUserWithTitle:notificationTitle message:notificationMessage];
}
else if (!self.isLoadingProject)
{
[self notifyUserWithTitle:notificationTitle message:notificationMessage];
}
}
if (pbxArguments)
{
DLog(@"Running update PBX task...");
NSDictionary *taskResult;
taskResult = [self runTaskWithLaunchPath:self.shellPath arguments:pbxArguments returnType:kTaskReturnTypeStdOut];
DLog(@"Update PBX Task result/response: %@/%@", taskResult[@"status"], taskResult[@"response"]);
}
[[NSNotificationCenter defaultCenter] postNotificationName:XCCConversionDidStopNotification object:self];
DLog(@"Processed: %@", realSourcePath);
return success;
}
/*!
Handle a file deletion. If it's a .j, it will
remove the shadowed .h file. If it's .xcodecapp-ignore
it will reset the list of ignored files.
@param fullPath the full path of the modified file
@param shouldNotify if YES, Growl notifications will be displayed
*/
- (void)handleFileRemovalAtPath:(NSString*)path
{
if ([self pathMatchesIgnoredPaths:path] || [self.fm fileExistsAtPath:path])
return;
if ([self isObjjFile:path])
{
[self removeReferencesToSourcePath:path];
[self notifyUserWithTitle:@"Removed Objective-J file" message:path.lastPathComponent];
}
else if ([self isXCCIgnoreFile:path])
[self computeIgnoredPaths];
}
#pragma mark - Source Files Management
- (BOOL)isObjjFile:(NSString *)path
{
return [path.pathExtension.lowercaseString isEqual:@"j"];
}
- (BOOL)isXibFile:(NSString *)path
{
NSString *extension = path.pathExtension.lowercaseString;
if ([extension isEqual:@"xib"] || [extension isEqual:@"nib"])
{
// Xcode creates temp files called <filename>~.xib. Filter those out.
NSString *baseFilename = path.lastPathComponent.stringByDeletingPathExtension;
return [baseFilename characterAtIndex:baseFilename.length - 1] != '~';
}
return NO;
}
- (BOOL)isXCCIgnoreFile:(NSString *)path
{
return [path isEqualToString:self.xcodecappIgnorePath];
}
- (NSString *)projectPathForSourcePath:(NSString *)path
{
NSString *base = path.stringByDeletingLastPathComponent;
NSString *projectPath = self.projectPathsForSourcePaths[base];
return projectPath ? [projectPath stringByAppendingPathComponent:path.lastPathComponent] : path;
}
#pragma mark - Shadow Files Management
- (NSString *)shadowBasePathForSourcePath:(NSString *)path
{
return [self.supportPath stringByAppendingPathComponent:[path.stringByDeletingPathExtension stringByReplacingOccurrencesOfString:@"/" withString:XCCSlashReplacement]];
}
- (NSString *)sourcePathForShadowPath:(NSString *)path
{
path = [path stringByReplacingOccurrencesOfString:XCCSlashReplacement withString:@"/"];
return [path.stringByDeletingPathExtension stringByAppendingPathExtension:@"j"];
}
/*!
Clean up any shadow files and PBX entries related to given the Cappuccino source file path
*/
- (void)removeReferencesToSourcePath:(NSString *)sourcePath
{
NSString *shadowBasePath = [self shadowBasePathForSourcePath:sourcePath];
NSString *shadowHeaderPath = [shadowBasePath stringByAppendingPathExtension:@"h"];
NSString *shadowImplementationPath = [shadowBasePath stringByAppendingPathExtension:@"m"];
DLog(@"Removing shadow header file: %@", shadowHeaderPath);
[self.fm removeItemAtPath:shadowHeaderPath error:nil];
DLog(@"Removing shadow implementation file: %@", shadowImplementationPath);
[self.fm removeItemAtPath:shadowImplementationPath error:nil];
DLog(@"Removing PBX reference");
NSString *projectSourcePath = [self projectPathForSourcePath:sourcePath];
NSArray *pbxArguments = @[
@"-c",
[NSString stringWithFormat:@"(source %@; python %@ remove '%@' '%@') 2>&1",
self.profilePath,
self.PBXModifierScriptPath,
self.currentProjectPath,
projectSourcePath]
];
NSDictionary *taskResult;
taskResult = [self runTaskWithLaunchPath:self.shellPath arguments:pbxArguments returnType:kTaskReturnTypeStdOut];
DLog(@"PBX Reference removal status/response: %@/%@", taskResult[@"status"], taskResult[@"response"]);
}
/*!
Clean the support folder according to files present in given path
*/
- (void)tidyShadowedFiles
{
NSArray *subpaths = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:self.supportPath error:nil];
for (NSString *path in subpaths)
{
if ([path.pathExtension isEqual:@"m"] || [path.lastPathComponent isEqualToString:@"xcc_general_include.h"])
continue;
NSString *sourcePath = [self sourcePathForShadowPath:path];
if (![self.fm fileExistsAtPath:sourcePath])
{
[self removeReferencesToSourcePath:sourcePath];
if (!self.supportsFileLevelAPI && [self respondsToSelector:@selector(updateLastModificationDate:forPath:)])
[self updateLastModificationDate:nil forPath:sourcePath];
}
}
}
#pragma mark - XCC Ignore management
+ (NSString *)globToRegexPattern:(NSString *)glob
{
NSMutableString *regex = [glob mutableCopy];
if ([regex characterAtIndex:0] == '!')
[regex deleteCharactersInRange:NSMakeRange(0, 1)];
[regex replaceOccurrencesOfString:@"."
withString:@"\\."
options:0
range:NSMakeRange(0, [regex length])];
[regex replaceOccurrencesOfString:@"*"
withString:@".*"
options:0
range:NSMakeRange(0, [regex length])];
// If the glob ends with "/", match that directory and anything below it.
if ([regex characterAtIndex:regex.length - 1] == '/')
[regex replaceCharactersInRange:NSMakeRange(regex.length - 1, 1) withString:@"(?:/.*)?"];
return [NSString stringWithFormat:@"^%@$", regex];
}
+ (NSArray *)parseIgnorePaths:(NSArray *)paths
{
NSMutableArray *parsedPaths = [NSMutableArray array];
NSError *error = NULL;
NSCharacterSet *whitespace = [NSCharacterSet whitespaceCharacterSet];
for (NSString *pattern in paths)
{
if ([pattern stringByTrimmingCharactersInSet:whitespace].length == 0)
continue;
NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:[self globToRegexPattern:pattern] options:0 error:&error];
[parsedPaths addObject:@{ @"regex": regex, @"exclude": @([pattern characterAtIndex:0] != '!') }];
}
return parsedPaths;
}
/*!
Compute the ignored paths according to any existing .xcodecapp-ignore file
*/
- (void)computeIgnoredPaths
{
self.ignoredPathRegexes = [XCCDefaultIgnoredPathRegexes mutableCopy];
NSString *ignorePath = [self.currentProjectPath stringByAppendingPathComponent:@".xcodecapp-ignore"];
if ([self.fm fileExistsAtPath:ignorePath])
{
NSString *ignoreFileContent = [NSString stringWithContentsOfFile:ignorePath encoding:NSUTF8StringEncoding error:nil];
NSArray *ignoredPatterns = [ignoreFileContent componentsSeparatedByString:@"\n"];
NSArray *parsedPaths = [[self class] parseIgnorePaths:ignoredPatterns];
[self.ignoredPathRegexes addObjectsFromArray:parsedPaths];
}
DLog(@"Ignoring file paths: %@", self.ignoredPathRegexes);
}
- (BOOL)pathMatchesIgnoredPaths:(NSString*)aPath
{
BOOL ignore = NO;
NSRange range = NSMakeRange(0, aPath.length);
for (NSDictionary *ignoreInfo in self.ignoredPathRegexes)
{
BOOL matches = [ignoreInfo[@"regex"] numberOfMatchesInString:aPath options:0 range:range] > 0;
if (matches)
ignore = [ignoreInfo[@"exclude"] boolValue];
}
return ignore;
}
#pragma mark - Errors
- (IBAction)openErrorsPanel:(id)aSender
{
[[NSApplication sharedApplication] activateIgnoringOtherApps:YES];
[self.errorsPanel makeKeyAndOrderFront:nil];
}
- (IBAction)openErrorInEditor:(id)sender
{
id info = self.errorListController.selection;
NSString *path = [info valueForKey:@"path"];
if (path == NSNoSelectionMarker)
return;
if ([self isObjjFile:path])
{
[self openObjjFile:path line:[[info valueForKey:@"line"] intValue]];
}
else // xib
{
NSWorkspace *workspace = [NSWorkspace sharedWorkspace];
[workspace openFile:path];
}
[self.errorsPanel orderOut:self];
}
- (void)openObjjFile:(NSString *)path line:(NSInteger)line
{
NSWorkspace *workspace = [NSWorkspace sharedWorkspace];
NSString *app, *type;
BOOL success = [workspace getInfoForFile:path application:&app type:&type];
if (!success)
{
NSBeep();
return;
}
NSBundle *bundle = [NSBundle bundleWithPath:app];
NSString *identifier = bundle.bundleIdentifier;
NSString *executablePath = nil;
XCCLineSpecifier lineSpecifier = kLineSpecifierNone;
if ([identifier hasPrefix:@"com.sublimetext."])
{
lineSpecifier = kLineSpecifierColon;
executablePath = [[bundle sharedSupportPath] stringByAppendingPathComponent:@"bin/subl"];
}
else if ([identifier isEqualToString:@"com.barebones.textwrangler"])
{
lineSpecifier = kLineSpecifierColon;
executablePath = [[bundle bundlePath] stringByAppendingPathComponent:@"Contents/Helpers/edit"];
}
else if ([identifier isEqualToString:@"com.barebones.bbedit"])
{
lineSpecifier = kLineSpecifierColon;
executablePath = [[bundle bundlePath] stringByAppendingPathComponent:@"Contents/Helpers/bbedit"];
}
else if ([identifier isEqualToString:@"com.macromates.textmate"]) // TextMate 1.x
{
lineSpecifier = kLineSpecifierMinusL;
executablePath = [[bundle sharedSupportPath] stringByAppendingPathComponent:@"Support/bin/mate"];
}
else if ([identifier hasPrefix:@"com.macromates.TextMate"]) // TextMate 2.x
{
lineSpecifier = kLineSpecifierMinusL;
executablePath = [bundle pathForResource:@"mate" ofType:@""];
}
else if ([identifier isEqualToString:@"com.chocolatapp.Chocolat"])
{
lineSpecifier = kLineSpecifierMinusL;
executablePath = [[bundle sharedSupportPath] stringByAppendingPathComponent:@"choc"];
}
else if ([identifier isEqualToString:@"org.vim.MacVim"])
{
lineSpecifier = kLineSpecifierPlus;
executablePath = @"/usr/local/bin/mvim";
}
else if ([identifier isEqualToString:@"org.gnu.Aquamacs"])
{
if ([self.fm isExecutableFileAtPath:@"/usr/bin/aquamacs"])
executablePath = @"/usr/bin/aquamacs";
else if ([self.fm isExecutableFileAtPath:@"/usr/local/bin/aquamacs"])
executablePath = @"/usr/local/bin/aquamacs";
}
else if ([identifier isEqualToString:@"com.apple.dt.Xcode"])
{
executablePath = [[bundle bundlePath] stringByAppendingPathComponent:@"Contents/Developer/usr/bin/xed"];
}
if (!executablePath || ![self.fm isExecutableFileAtPath:executablePath])
{
[workspace openFile:path];
return;
}
NSArray *args;
switch (lineSpecifier)
{
case kLineSpecifierNone:
args = @[path];
break;
case kLineSpecifierColon:
args = @[[NSString stringWithFormat:@"%1$@:%2$ld", path, line]];
break;
case kLineSpecifierMinusL:
args = @[@"-l", [NSString stringWithFormat:@"%ld", line], path];
break;
case kLineSpecifierPlus:
args = @[[NSString stringWithFormat:@"+%ld", line], path];
break;
}
[self runTaskWithLaunchPath:executablePath arguments:args returnType:kTaskReturnTypeNone];
}
- (IBAction)clearErrors:(id)sender
{
[self.errorList removeAllObjects];
self.errorListController.content = self.errorList;
}
- (BOOL)hasErrors
{
NSInteger index = [self.errorList indexOfObjectPassingTest:^BOOL(id obj, NSUInteger idx, BOOL *stop)
{
return [[obj valueForKey:@"status"] intValue] == XCCStatusCodeError;
}];
return index != NSNotFound;
}
#pragma mark - User notifications
- (NSString *)applicationNameForGrowl
{
return @"XcodeCapp";
}
- (void)notifyUserWithTitle:(NSString *)aTitle message:(NSString *)aMessage
{
if ([NSUserNotificationCenter class])
{
NSUserNotification *note = [NSUserNotification new];
note.title = aTitle;
note.informativeText = aMessage;
[[NSUserNotificationCenter defaultUserNotificationCenter] deliverNotification:note];
}
else
{
[GrowlApplicationBridge notifyWithTitle:aTitle
description:aMessage
notificationName:GROWL_NOTIFICATIONS_DEFAULT
iconData:nil
priority:0
isSticky:NO
clickContext:nil];
}
}
- (BOOL)userNotificationCenter:(NSUserNotificationCenter *)center shouldPresentNotification:(NSUserNotification *)notification
{
// Notification Center may decide not to show a notification. We always want them to show.
return YES;
}
@end
@implementation TNXcodeCapp (SnowLeopard)
- (void)updateLastModificationDate:(NSDate *)date forPath:(NSString *)path
{
if (!self.pathModificationDates)
{
self.pathModificationDates = [[[NSUserDefaults standardUserDefaults] dictionaryForKey:kDefaultPathModificationDates] mutableCopy];
if (!self.pathModificationDates)
self.pathModificationDates = [NSMutableDictionary new];
}
if (date)
[self.pathModificationDates setObject:date forKey:path];
else
[self.pathModificationDates removeObjectForKey:path];
[[NSUserDefaults standardUserDefaults] setObject:self.pathModificationDates forKey:kDefaultPathModificationDates];
}
- (NSDate *)lastModificationDateForPath:(NSString *)path
{
if (!self.pathModificationDates)
{
self.pathModificationDates = [[[NSUserDefaults standardUserDefaults] dictionaryForKey:kDefaultPathModificationDates] mutableCopy];
if (!self.pathModificationDates)
self.pathModificationDates = [NSMutableDictionary new];
}
if ([self.pathModificationDates valueForKey:path] != nil)
return [self.pathModificationDates valueForKey:path];
else
return self.appStartedTimestamp;
}
@end