Using AppleScript to enable projects in SubEthaEdit

A lot of people on OS X use TextMate, and I respect that. It’s a full featured editor which everyone loves due to it’s ‘project drawer’ which allows you to browse a directory of files right in the sidebar of the window. I have experienced a lot of pain over the years so my likes and dislikes are more at a ‘file’ level than a ‘project’ level. I want an editor, that will read mixed line endings and, if I add a line, will not reformat the line endings in a file. It must be able to switch character encodings at will. It must be stable, it can’t consume a large memory footprint over time and it must open large files. All of these things have led me to SubEthaEdit, which I have been using since the days it was known as ‘Hydra’ It’s a fantastic editor which, through applescript, may be customized as you see fit.

I like SEE’s file-oriented flow and think ‘project managers’ in a text editor is cruft. That being said, when it comes time to compile, this is a *project* oriented task. I’d like, for example, to be able to compile the entire java source to run the class I was just working on.

I realized that, each file is a resident of whatever the source root is, and that the root directory is, by convention, one level above that. Using this knowledge, we can create a set of Applescript targets to work with a build script or to work via the command line on the whole project.

First, we’ll need some support functions:

getParentPath takes a path and climbs up one directory then returns the resulting path, we use this function to walk up directories without using expensive finder operations

on getParentPath(myPath)
	(* Andy Bachorski <andyb@APPLE.COM>, with a small modification by Abbey Hawk Sparrow *)
	set oldDelimiters to AppleScript's text item delimiters -- always preserve original delimiters
	set AppleScript's text item delimiters to {"/"}
	set pathItems to text items of (myPath as text)
	if last item of pathItems is "" then set pathItems to items 1 thru -2 of pathItems -- its a folder
	set parentPath to ((reverse of the rest of reverse of pathItems) as string) & "/"
	set AppleScript's text item delimiters to oldDelimiters -- always restore original delimiters
	return parentPath
end getParentPath

executeInShell is a wrapper for shell execution that allows output display and logging, etc.

on executeInShell(commandString)
	tell application "System Events" to set terminalRunning to ((application processes whose (name is equal to "Terminal")) count)
	if outputMode is equal to "verbose" then
		if terminalRunning is equal to 0 then
			do shell script "open -a Terminal"
		end if
		tell application "Terminal"
			return do script commandString
		end tell
	else
		if outputMode is equal to "logged" then
			if terminalRunning is equal to 0 then
				do shell script "open -a Terminal"
			end if
			--empty the last log
			try
				set log_file_name to rootProjectPath & "run.log"
				set log_file to open for access POSIX file log_file_name with write permission
				write "" to log_file starting at 0
				close access log_file
			end try
			--open the terminal and tail the file
			tell application "Terminal"
				do script "tail -c+0 -f " & rootProjectPath & "run.log"
			end tell
			--do the actual command and trap it's errors
			try
				set commandResult to do shell script commandString
			on error errorString number errorNumber
				set commandResult to errorString
			end try
			--log the results of the command
			try
				set log_file_name to rootProjectPath & "run.log"
				set log_file to open for access POSIX file log_file_name with write permission
				write convertMacToUnixLineEndings(commandResult) to log_file starting at 0
				close access log_file
			end try
			return commandResult
		else
			return do shell script commandString
		end if
	end if
end executeInShell

convertMacToUnixLineEndings is simply a passthrough conversion

on convertMacToUnixLineEndings(theText)
	return replace_string(theText, "
", "
")
end convertMacToUnixLineEndings

statusUpdate This is an alert passthrough which supports growl

on statusUpdate(statusHeader, statusText)
	tell application "System Events" to set GrowlRunning to ((application processes whose (name is equal to "GrowlHelperApp")) count)
	if GrowlRunning is equal to 0 then
		tell application "SubEthaEdit"
			display dialog "Project Built." buttons "OK" default button 1 giving up after 10
		end tell
	else
		tell application "GrowlHelperApp"
			set the allNotificationsList to {"Build Notification", "Error"}
			set the enabledNotificationsList to {"Build Notification"}
			register as application "SEE Build Tools" all notifications allNotificationsList default notifications enabledNotificationsList icon of application "SubEthaEdit"
			notify with name "Build Notification" title statusHeader description statusText application name "SEE Build Tools"
		end tell
	end if
end statusUpdate

appendTextToFileis a text aggregator we use for logging

on appendTextToFile(theText, filePath)
	try
		set log_file_name to filePath
		set log_file to open for access POSIX file log_file_name with write permission
		write theText to log_file starting at eof
		close access log_file
	end try
end appendTextToFile

We also need some string management functions, luckily some scripts by Bill Hernandez fit the bill

(* code by Bill Hernandez *)
on implode(aInputArray, delim)
	-- join elements in an array --> string
	local aInputArray, delim, result_string
	--if delim is "" then set delim to ","

	set {ASTID, AppleScript's text item delimiters} to {AppleScript's text item delimiters, delim}
	try
		set result_string to "" & aInputArray
	end try
	set AppleScript's text item delimiters to ASTID
	return result_string --> text
end implode

(* code by Bill Hernandez *)
on explode(str2split, delim)
	-- split a string --> elements in an array
	local aResult
	if delim is "" then set delim to ","

	set {ASTID, AppleScript's text item delimiters} to {AppleScript's text item delimiters, delim}
	try
		set aResult to text items of str2split
	end try
	set AppleScript's text item delimiters to ASTID
	return aResult --> list
end explode

(* code by Bill Hernandez *)
on replace_string(str, str2find, str2replace)
	local input_string, delim, aArray, result_string

	set cf to class of str2find
	set cr to class of str2replace
	set delim to str2find
	set aArray to my explode(str, delim)

	set delim to str2replace
	set result_string to my implode(aArray, delim)
	return result_string
end replace_string

getFilesOfTypeWithContent acts as a file filter

on getFilesOfTypeWithContent(fileType, content, root)
	--here we search for all files that have a 'main' function
	set scanCommand to "grep -R " & quote & content & quote & " Source|sed -e " & quote & "s/^.*\\.svn.*$//g" & quote & "|sed '/^$/d'|sed " & quote & "s/:.*$//g" & quote
	set scanResult to (do shell script "cd " & root & "; " & scanCommand)
	set AppleScript's text item delimiters to "
"
	set scanResults to text items of scanResult
	set AppleScript's text item delimiters to {""}
	return scanResults
end getFilesOfTypeWithContent

fileExists allows us to check file existence without involving the finder

on fileExists(filePath)
	try
		set result to do shell script "ls " & filePath
		if result is equal to "" then
			return false
		else
			return true
		end if
	on error
		return false
	end try
end fileExists

Next we need to get the path naming conventions for the project so we can figure out what is root

on getSourcePathConvention(filePath)
	if "/Source/" is in (the POSIX path of filePath) then
		return "Source"
	end if
	if "/source/" is in (the POSIX path of filePath) then
		return "source"
	end if
	if "/src/" is in (the POSIX path of filePath) then
		return "src"
	end if
end getSourcePathConvention

on getLibraryPathConvention(rootProjectPath)
	if (fileExists(rootProjectPath & "Library") is equal to true) then
		return "Library"
	end if
	if (fileExists(rootProjectPath & "Lib") is equal to true) then
		return "Lib"
	end if
	if (fileExists(rootProjectPath & "lib") is equal to true) then
		return "lib"
	end if
end getLibraryPathConvention

on getCompilePathConvention(rootProjectPath)
	if (fileExists(rootProjectPath & "Classes") is equal to true) then
		return "Classes"
	end if
	if (fileExists(rootProjectPath & "classes") is equal to true) then
		return "classes"
	end if
	if (fileExists(rootProjectPath & "cls") is equal to true) then
		return "cls"
	end if
	return ""
end getCompilePathConvention

getProjectRoot actually walks up the path and determines what is root by keeping going until the source root is not in the path

on getProjectRoot(filePath, sourceFolderName)
	repeat until "/" & sourceFolderName & "/" is not in (the POSIX path of filePath)
		if filePath is equal to "" then return ""
		if filePath is equal to "/" then return ""
		set filePath to getParentPath(filePath)
	end repeat
	return filePath
end getProjectRoot

derivePackagereturns the package for a given source file based on the directory layout

on derivePackage(sourceFolder, filePath)
	--set pathString to filePath & ""
	set pathString to replace_string(filePath, "!@#@#@!", "") -- replace nothing
	set pathString to replace_string(pathString, "Source/", "")
	--set pathString to characters (length of sourceFolder) thru 15 of pathString
	set pathString to replace_string(pathString, ".java", "")
	set pathString to replace_string(pathString, "/", ".")
	return pathString
end derivePackage

buildClasspathFromLibDirectory uses the contents of the lib directory to make a classpath for commandline execution

on buildClasspathFromLibDirectory(libraryPath, ClassDirectory)
	set directoryItems to directoryListing(libraryPath)
	set classpath to ClassDirectory & ":" & libraryPath & "/" & implode(directoryItems, ":" & libraryPath & "/")
	return classpath
end buildClasspathFromLibDirectory

directoryListing returns the list of files in a directory.. you guessed it, without interacting with the finder

on directoryListing(directoryPath)
	return explode(do shell script "ls " & directoryPath, "
")
end directoryListing

Last we need our actual compile and run wrappers

on compileJavaSourceDirectory(rootDirectory, sourceDirectory, destinationDirectory, sourceFile, classpath)
	set compileCommand to "javac -cp " & quote & classpath & quote & " -sourcepath " & quote & sourceDirectory & quote & " -d " & quote & destinationDirectory & quote & " " & sourceFile
	--appendTextToFile(compileCommand, rootDirectory & "compile_command.txt")
	try
		set resultText to executeInShell(compileCommand)
	on error errorString number errorNumber
		set resultText to errorString
		return resultText
	end try
	return resultText
end compileJavaSourceDirectory

on runJavaSourceDirectory(rootDirectory, sourceDirectory, destinationDirectory, mainClass, classpath)
	set compileCommand to "java -cp " & quote & classpath & quote & " " & mainClass
	--appendTextToFile(compileCommand, rootDirectory & "compile_command.txt")
	try
		set resultText to executeInShell(compileCommand)
	on error errorString number errorNumber
		set resultText to errorString
	end try
	return resultText
end runJavaSourceDirectory

This allows us to (at long last) build a straightforward script to power this action in SEE

global rootProjectPath
global outputMode
set outputMode to "silent"

--make sure we have a valid SubEthaEdit context within which to work
set theSourcePath to getValidSEEContext()
--get the name of the folder the source is stored in (which can only occur once in it's path)
set sourceFolderName to getSourcePathConvention(theSourcePath) as string
if sourceFolderName is not equal to "" then
	--set a bunch of paths and folder names
	set rootProjectPath to getProjectRoot(theSourcePath, sourceFolderName)
	set compileFolderName to getCompilePathConvention(rootProjectPath)
	set libraryFolderName to getLibraryPathConvention(rootProjectPath)
	set compileFolderPath to rootProjectPath & compileFolderName
	set sourceFolderPath to rootProjectPath & sourceFolderName
	--find all the files in the source directory which have a main class
	set fileList to getFilesOfTypeWithContent("java", "public static void main", rootProjectPath)
	--allow the user to select the class
	set selectedFile to item 1 of {choose from list fileList with prompt "Pick your Main Class:" without multiple selections allowed}
	--set selectedFile to item 1 of selectedFiles

	if selectedFile is not equal to false then
		set mainClass to derivePackage(sourceFolderName, selectedFile)
		set classpath to buildClasspathFromLibDirectory(rootProjectPath & libraryFolderName, rootProjectPath & compileFolderName)
		set compileResults to compileJavaSourceDirectory(rootProjectPath, sourceFolderPath, compileFolderPath, theSourcePath, classpath)
		appendTextToFile(compileResults, rootProjectPath & "build.log")
		if compileResults is not equal to "" then
			do shell script "open " & rootProjectPath & "build.log"
			statusUpdate("Build Failure", "Compile Failed")
		else
			statusUpdate("Build Successful", "Compile Complete")
			set outputMode to "logged"
			set compileResults to runJavaSourceDirectory(rootProjectPath, sourceFolderPath, compileFolderPath, mainClass, classpath)
		end if
	end if
else
	statusUpdate("Build Failure", "Nothing to do")
end if

-- SEE Configuration
on seescriptsettings()
	return {displayName:"Build and Run", shortDisplayName:"Build/Run", keyboardShortcut:"^@r", toolbarIcon:"ToolbarIconBuildAndRun", inDefaultToolbar:"yes", toolbarTooltip:"Build and Run", inContextMenu:"yes"}
end seescriptsettings

That should give you a pretty good idea of how to do that for *any* language. Cheers.