AppleScriptObjC and ‘do shell script’

The introduction of do shell script was a huge boon to AppleScript, and although these days AppleScriptObjC often offers alternatives — and often faster and better — it’s still indispensable for some things.

But what if it disappeared? Could we replace it using AppleScriptObjC?

I tinkered with the idea quite a while ago, but never got it quite right. Code that works in Objective-C doesn’t always work in AppleScriptObjC. But I recently returned to the project, used a different approach, and finally got there.

First I should point out that doing it this way has one very serious limitation compared with do shell script: you can’t use it with elevated privileges. So you can’t include credentials or prompt for authorisation, or use sudo. Otherwise, the capabilities are the same.

On the other hand, there are a couple of potential advantages: you can choose your own shell, or even call tools directly (which also lets you side-step the quoting gymnastics often required), you can have better control over line-endings (turning linefeeds to returns is mostly pointless these days, but there’s still often merit in stripping the trailing line-ending), and you have more control over what gets returned. It also offers a way to provide standard input, and an easy way to set the environment. It’s just a bit more customizable generally.

Would I use this code instead of do shell script? Probably not, unless I wanted to take advantage of a particular feature (and after doing some more testing). But there’s something reassuring about knowing it’s possible. And as a bonus, in my limited tests it’s even mostly a bit faster (see below).

Even if it you never use it, it might be interesting to look at in terms of how to use NSTask to launch a subprocess.

There’s one main handler with lots of parameters in the code below, plus some convenience handlers to make it simpler to use. Calling the doShellScript: handler mimics the basic do shell script command.

use AppleScript version "2.4"
use scripting additions
use framework "Foundation"

-- simple call
set x1 to my doShellScript:"echo 'Hello world'"
-- calling tool directly instead of via /bin/sh
set x2 to my runTool:"/usr/bin/tr" withArgs:{"a", "o"} stdInput:"bad cat" envDict:(missing value) useLocale:false useReturns:false stripLast:true resultType:(text)
-- an old Nigel Garvey example revisited out of season, showing use of locale
set x3 to my doShellScriptLocalized:("echo 'x-93yß<⌘wß⌘-r7ßßq' | sed 'y?<379-x⌘qß?Np!paHe" & character id 127863 & "Y ?'")
-- do comparison
set thePath to POSIX path of (choose file)
set x4 to do shell script "cat " & quoted form of thePath
set x5 to my doShellScript:("cat " & quoted form of thePath)
return x4 = x5


-- This matches plain 'do shell script'
on doShellScript:theCommand
	return my runTool:"/bin/sh" withArgs:{"-c", theCommand} stdInput:(missing value) envDict:(missing value) useLocale:false useReturns:true stripLast:true resultType:(text)
end doShellScript:

-- This matches 'do shell script without altering line endings'
on doShellScriptNoAltering:theCommand
	return my runTool:"/bin/sh" withArgs:{"-c", theCommand} stdInput:(missing value) envDict:(missing value) useLocale:false useReturns:false stripLast:false resultType:(text)
end doShellScriptNoAltering:

-- This matches 'do shell script without altering line endings' but still strips the trailing linefeed
on doShellScriptWithLFs:theCommand
	return my runTool:"/bin/sh" withArgs:{"-c", theCommand} stdInput:(missing value) envDict:(missing value) useLocale:false useReturns:false stripLast:true resultType:(text)
end doShellScriptWithLFs:

--- This lets you configure line ending behavior and the result type
-- resultType: string = matches osax; data = like osax "as data"; "NSString" = NSString if UTF-8, else unmodified NSData; "NSData" = unmodified NSData; "Base-64" = unmodified data as Base-64 string
on doShellScript:theCommand useReturns:useReturnsFlag stripLast:stripLastFlag resultType:resultType
	return my runTool:"/bin/sh" withArgs:{"-c", theCommand} stdInput:(missing value) envDict:(missing value) useLocale:false useReturns:useReturnsFlag stripLast:stripLastFlag resultType:resultType
end doShellScript:useReturns:stripLast:resultType:

-- This lets you configure most things
-- resultType: string = matches osax; data = like osax "as data"; "NSString" = NSString if UTF-8, else unmodified NSData; "NSData" = unmodified NSData; "Base-64" = unmodified data as Base-64 string
-- useReturns and stripLast are booleans; envDict is a record/NSDictionary or missing value; stdInput is a string/NSString or missing value
-- localeValue: false or 0 = ignore; true or 1 = set LANG, LC_CTYPE, and LC_COLLATE to current locale; 3 = sets LC_ALL to current locale
on doShellScript:theCommand stdInput:theInput envDict:envDict useLocale:localeValue useReturns:useReturnsFlag stripLast:stripLastFlag resultType:resultType
	return my runTool:"/bin/sh" withArgs:{"-c", theCommand} stdInput:theInput envDict:envDict useLocale:localeValue useReturns:useReturnsFlag stripLast:stripLastFlag resultType:resultType
end doShellScript:stdInput:envDict:useLocale:useReturns:stripLast:resultType:

-- This matches plain 'do shell script' but sets LANG, LC_CTYPE, and LC_COLLATE to current locale
on doShellScriptLocalized:theCommand
	return my runTool:"/bin/sh" withArgs:{"-c", theCommand} stdInput:(missing value) envDict:(missing value) useLocale:true useReturns:true stripLast:true resultType:(text)
end doShellScriptLocalized:

-- This matches 'do shell script without altering line endings' but sets LANG, LC_CTYPE, and LC_COLLATE to current locale
on doShellScriptNoAlteringLocalized:theCommand
	return my runTool:"/bin/sh" withArgs:{"-c", theCommand} stdInput:(missing value) envDict:(missing value) useLocale:true useReturns:false stripLast:false resultType:(text)
end doShellScriptNoAlteringLocalized:

-- This matches 'do shell script without altering line endings' but still strips the trailing linefeed and uses sets LANG, LC_CTYPE, and LC_COLLATE to current locale
on doShellScriptWithLFsLocalized:theCommand
	return my runTool:"/bin/sh" withArgs:{"-c", theCommand} stdInput:(missing value) envDict:(missing value) useLocale:true useReturns:false stripLast:true resultType:(text)
end doShellScriptWithLFsLocalized:

-- This lets you configure line ending behavior, and whether to return an NSString or text; sets LANG, LC_CTYPE, and LC_COLLATE to current locale
-- resultType: 1 = string, 2 = NSString, 3 = NSData, 4 = Base-64 string; useReturns and stripLast are booleans
on doShellScriptLocalized:theCommand useReturns:useReturnsFlag stripLast:stripLastFlag resultType:resultType
	return my runTool:"/bin/sh" withArgs:{"-c", theCommand} stdInput:(missing value) envDict:(missing value) useLocale:true useReturns:useReturnsFlag stripLast:stripLastFlag resultType:resultType
end doShellScriptLocalized:useReturns:stripLast:resultType:

-- This is the "master" handler called by the above. You can also call it directly, to address tools directly or to use a different shell. In this case you pass the arguments as a list, and you must use the path to the tool.
-- For example: runTool:"/usr/bin/tr" withArgs:{"a", "o"} stdInput:"bad cat" envDict:(missing value) useLocale:false useReturns:false stripLast:true resultType:text
-- resultType: string = matches osax; data = like osax "as data"; "NSString" = NSString if UTF-8, else unmodified NSData; "NSData" = unmodified NSData; "Base-64" = unmodified data as Base-64 string
-- useReturns and stripLast are booleans; envDict is a record/NSDictionary or missing value; stdInput is a string/NSString or missing value
-- localeValue: false or 0 = ignore; true or 1 = set LANG, LC_CTYPE, and LC_COLLATE to current locale; 3 = sets LC_ALL to current locale
on runTool:thePath withArgs:theArgList stdInput:theInput envDict:envDict useLocale:localeValue useReturns:useReturnsFlag stripLast:stripLastFlag resultType:resultType
	set envMutDict to current application's NSMutableDictionary's dictionary()
	if localeValue as integer > 0 then
		set localeName to current application's NSLocale's currentLocale()'s localeIdentifier()
		if localeValue as integer = 1 then
			envMutDict's addEntriesFromDictionary:{LANG:localeName, LC_CTYPE:localeName, LC_COLLATE:localeName}
		else
			envMutDict's setObject:localeName forKey:"LC_ALL"
		end if
	end if
	if envDict is not missing value then envMutDict's addEntriesFromDictionary:envDict
	-- create a pipe for standard output, then get a file handle for reading from it
	set outPipe to current application's NSPipe's pipe()
	set outFileHandle to outPipe's fileHandleForReading()
	-- create a pipe for standard error
	set errPipe to current application's NSPipe's pipe()
	-- make task, set its properties
	set theTask to current application's NSTask's new()
	set outData to current application's NSMutableData's |data|()
	theTask's setLaunchPath:thePath
	theTask's setArguments:theArgList
	theTask's setStandardOutput:outPipe
	theTask's setStandardError:errPipe
	if envMutDict's |count|() > 0 then theTask's setEnvironment:envMutDict
	if theInput is missing value then
		theTask's |launch|()
	else
		-- convert the input to data
		set theInput to current application's NSString's stringWithString:theInput
		set theInputData to theInput's dataUsingEncoding:(current application's NSUTF8StringEncoding)
		-- create a pipe for standard input, then get a file handle for writing to it
		set inputPipe to current application's NSPipe's pipe()
		set inputFileHandle to inputPipe's fileHandleForWriting()
		-- connect pipe to task and launch it
		theTask's setStandardInput:inputPipe
		theTask's |launch|()
		-- write to standard input
		inputFileHandle's writeData:theInputData
		inputFileHandle's closeFile()
	end if
	-- collect standard output while it's running
	repeat while theTask's isRunning() as boolean
		set newData to outFileHandle's availableData()
		if newData is not missing value then outData's appendData:newData
	end repeat
	-- check if all went OK
	set taskStatus to theTask's |terminationStatus|() as integer
	if taskStatus = 0 then -- all OK
		set newData to outFileHandle's readDataToEndOfFile()
		outFileHandle's closeFile()
		if newData is not missing value then outData's appendData:newData
		if outData's |length|() < 1 then
			-- some tools output to standard error regardless, so check there instead
			set outData to errPipe's fileHandleForReading()'s readDataToEndOfFile()
			errPipe's fileHandleForReading()'s closeFile()
		end if
		if resultType = "NSData" then return outData's |copy|()
		if resultType = data then -- convert to AS data
			set theCode to current application's NSHFSTypeCodeFromFileType("'rdat'")
			return (current application's NSAppleEventDescriptor's descriptorWithDescriptorType:theCode |data|:outData) as data
		end if
		if resultType = "Base-64" then return (outData's base64EncodedStringWithOptions:0) as text
		set theString to current application's NSString's alloc()'s initWithData:outData encoding:(current application's NSUTF8StringEncoding)
		if theString is missing value then -- not valid UTF-8
			if resultType = text then
				-- Output as MacRoman, as 'do shell script' does. Awful, but compatible...
				set theString to current application's NSString's alloc()'s initWithData:outData encoding:(current application's NSMacOSRomanStringEncoding)
				-- if 'altering line endings' is true, 'do shell script' also changes CRLF to LF, so to match...
				if useReturnsFlag then set theString to theString's stringByReplacingOccurrencesOfString:(return & linefeed) withString:return
			else -- resultType "NSString", fall back to raw data
				return outData's |copy|()
			end if
		else if stripLastFlag then -- only if it's UTF-8
			if (theString's hasSuffix:linefeed) as boolean then set theString to theString's substringToIndex:((theString's |length|()) - 1)
		end if
		if useReturnsFlag then set theString to theString's stringByReplacingOccurrencesOfString:linefeed withString:return
		if resultType is "NSString" then return theString
		return theString as text
	else -- there was an error, so get the data from standardError
		set errData to errPipe's fileHandleForReading()'s readDataToEndOfFile()
		errPipe's fileHandleForReading()'s closeFile()
		set theString to current application's NSString's alloc()'s initWithData:errData encoding:(current application's NSUTF8StringEncoding)
		if theString is missing value then
			-- match 'do shell script' fallback
			set theString to current application's NSString's alloc()'s initWithData:outData encoding:(current application's NSUTF8StringEncoding)
		end if
		if theString is missing value then
			error number taskStatus
		else
			error (theString as string) number taskStatus
		end if
	end if
end runTool:withArgs:stdInput:envDict:useLocale:useReturns:stripLast:resultType:

My speed tests, conducted with a simple echo 'hello world!' repeated 1000 times, provided some interesting results. When run in a standard applet, do shell script took about 15-30% longer, with the gap a bit bigger when run from an enhanced applet. (Of course, 15-30% of a couple of milliseconds is a seriously minuscule amount of time.) When run from the FastScripts menu, do shell script took about twice as long. When run from Apple’s Scripts menu, though, the AppleScriptObjC code took many times longer than do shell script. (Timings within an editor are pointless because logging skews things heavily.)

7 Likes

Thanks. I’d tried wrapping NSTask myself some while ago and ran into problems. Don’t recall what now, but this will be useful.