Find variables that are declared but never used?

I expect that this has been asked many times before, but I haven’t been able to find an answer.

Some of my scripts are now a dozen years old or more, and they’ve been changing with each new OS version or when I learn something that about AppleScript that I should have known from the beginning. Many of these scripts have variables that the script no longer uses, but which are still declared as globals or properties because I was too lazy or too incompetent to remove them when I modified the script.

Is there a way to test a script for variables that are declared but never used? If so, I’ll be very glad to learn about it.

PS It seems that Satimage’s Smile tries to do this, and I was able to get it to run under Mojave and display lists of unused or undeclared variables; the lists weren’t entirely accurate, but they were useful in tracking down some variables that really weren’t used at all. If this feature is already in SD, then apologies for wasting bandwidth by asking for it.

1 Like

There isn’t such a feature. For something quick and dirty, you could use this to get a list of all terms used once in a script and work from there:

use framework "Foundation"
use scripting additions

tell application id "com.latenightsw.ScriptDebugger7" -- Script Debugger.app
	set theText to source text of document 1
end tell
set theSet to current application's NSCountedSet's alloc()'s initWithArray:(words of theText)
set oneOffWords to {}
repeat with aWord in theSet's allObjects()
	if (theSet's countForObject:aWord) is 1 then set end of oneOffWords to (aWord as text)
end repeat
return oneOffWords

Or perhaps a tad more elegantly:

use framework "Foundation"
use scripting additions

tell application id "com.latenightsw.ScriptDebugger7" -- Script Debugger.app
	set theText to source text of document 1
end tell
set countedSet to current application's NSCountedSet's setWithArray:(words of theText)
set plainSet to current application's NSMutableSet's setWithSet:countedSet
countedSet's minusSet:plainSet
plainSet's minusSet:countedSet
return plainSet's allObjects() as list

Thank you! Those are a very good start. They also return all the words in dialogs that aren’t repeated, so it takes a bit of time to sort out variables, but I’ve already found some unused ones, thanks to these scripts.

If there’s any hope of adding this feature to SD, that would be very good news. As I wrote earlier, Smile does a fairly good job of this, but not good enough to be reliable, and Smile will scarcely run at all under Mojave.

If it’s just globals and variables you’re concerned with, a little bit of regex should get you close. Here’s an example using my RegexAndStuffLib:

use framework "Foundation"
use scripting additions
use script "RegexAndStuffLib" version "1.0.4"

tell application id "com.latenightsw.ScriptDebugger7" -- Script Debugger.app
	set theText to source text of document 1
end tell
set countedSet to current application's NSCountedSet's setWithArray:(words of theText)
set plainSet to current application's NSMutableSet's setWithSet:countedSet
countedSet's minusSet:plainSet
plainSet's minusSet:countedSet
set globalsAndLocals to regex search theText search pattern "\\s(global|local) ([^-#\\r\\n]+)" capture groups 2
set theProps to regex search theText search pattern "\\sproperty (\\S+) : " capture groups 1
set wordSet to current application's NSMutableSet's |set|()
repeat with val in globalsAndLocals
	(wordSet's addObjectsFromArray:(words of val))
end repeat
wordSet's addObjectsFromArray:theProps
plainSet's intersectSet:wordSet
return plainSet's allObjects() as list

That is extremely useful! Thank you! One of my scripts is a reduced version of a much longer one, and your script showed me an embarrassingly large number of variables that I was still declaring because the longer version of the script used them.

If this becomes the basis of a feature, it might be worth noting that the script returns unused variables that are commented out, like this:

-- global Catalina

This is a trivial issue, of course.

The other Smile feature that I hope SD can add someday is one that finds variables that are set but not used or tested anywhere else in the script. I found that one of my scripts sets a variable three times because an older version of the script used it, but that the current version doesn’t use it at all.

I know that it’s too much to expect SD to make up for my own incompetence, but this would be a nice feature to have. I suppose the logic would look for every line that set a variable, and then would check whether that variable is used in any line that does NOT set a variable.

Thanks again for this very helpful script.

@ShaneStanleyThank you for the two scripts.
I tested both and here, the second fails to return the local declared in handlers (I never declare some of there elsewhere).
I guess that I made something wrong.

At least I learnt one thing.
As I am curious, running the scripts from “Script Editor”, I tried to replace
tell application id "com.latenightsw.ScriptDebugger7"
by
tell application “Script Debugger”
and it worked.
So I made an other attempt with

tell application "Script Editor"
	set theText to (text of document "Sans titre")
end tell

and this time the instructions below failed to compile.
Other attempt with

tell application id "com.apple.ScriptEditor2"
	set theText to (text of document "Sans titre")
end tell 

and this time it compiles and work, always forgetting the declared locales.

Yvan KOENIG running High Sierra 10.13.6 in French (VALLAURIS, France) dimanche 18 aout 2019 11:46:38

I’ve been working on a version for my own amusement. :slight_smile: It needs refinement and more testing, but the only thing it’s known not to handle at the moment is variables at the beginnings of lines (ie. implicit gets or returns).

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

on main()
	tell application id "com.latenightsw.ScriptDebugger7" -- Script Debugger.app
		tell document 1
			set oldView to show raw syntax
			set show raw syntax to true
			set theText to source text
			set show raw syntax to oldView
		end tell
	end tell
	
	set |⌘| to current application
	set theText to |⌘|'s class "NSMutableString"'s stringWithString:(theText)
	set regex to |⌘|'s NSRegularExpressionSearch
	-- Zap the contents of literal strings in the code (except in barred variable labels!).
	tell theText to replaceOccurrencesOfString:("((?:[^|\"]|\\|[^|]*+\\|)++)\"(?:\\\\\"|[^\"])*+\"") withString:("$1\"\"") options:(regex) range:({0, its |length|()})
	# Zap end-of-line comments (ditto).
	tell theText to replaceOccurrencesOfString:("((?:[^|#-]|\\|[^|]*+\\||-(?!-))*+).*+") withString:("$1") options:(regex) range:({0, its |length|()})
	(* Zap block comments. The regex matches either complete |barred variables| or complete (* block comments *), so if one type contains the other, the match is the outer one. Comments have their own capture group for easy identification. (* The search repeats if necessary to remove (* nested blocks *) from the inside out. *) *)
	set barNBlockSearch to |⌘|'s class "NSRegularExpression"'s regularExpressionWithPattern:("(?:\\|[^|]*+\\|)|(\\(\\*(?:[^*|(]|\\*(?!\\))|\\((?!\\*)|\\|[^|]*+\\|)*+\\*\\))") options:(0) |error|:(missing value)
	set noCuts to false
	repeat until (noCuts)
		set noCuts to true
		set searchHits to barNBlockSearch's matchesInString:(theText) options:(0) range:({0, theText's |length|()})
		repeat with i from (count searchHits) to 1 by -1
			set blockRange to ((item i of searchHits)'s rangeAtIndex:(1))
			if (blockRange's |length|() > 0) then
				set noCuts to false
				tell theText to deleteCharactersInRange:(blockRange)
			end if
		end repeat
	end repeat
	
	-- Join up continuity-charactered lines.
	tell theText to replaceOccurrencesOfString:("¬\\s++") withString:("") options:(regex) range:({0, its |length|()})
	-- Reduce what's left to just lines possibly mentioning variables.
	tell theText to replaceOccurrencesOfString:("(?m)^\\s*+(?!property|script|local|global|set|copy|tell|repeat|return|get|if|with timeout) .*+") withString:("") options:(regex) range:({0, its |length|()})
	tell theText to replaceOccurrencesOfString:("\\s{2,}+") withString:(linefeed) options:(regex) range:({0, its |length|()})
	
	-- Collect all suspected variable labels into a counted set.
	set countedSet to |⌘|'s class "NSCountedSet"'s new()
	-- Firstly individual ones.
	set varSearch to |⌘|'s class "NSRegularExpression"'s regularExpressionWithPattern:("(?:(?<!«)property |script |set |copy |to |tell |with |of |return |get )(?:my |its )?(?:«[^»]++» (?!to ))?([a-zA-Z_][A-Za-z0-9_]*+|\\|[^|]*+\\|)(?![:(])") options:(0) |error|:(missing value)
	set searchHits to varSearch's matchesInString:(theText) options:(0) range:({0, theText's |length|()})
	repeat with thisHit in searchHits
		set varRange to (thisHit's rangeAtIndex:(1))
		tell countedSet to addObject:(theText's substringWithRange:(varRange))
	end repeat
	-- Then any that are potentially sequenced (as in scope declarations, lists, records, or ordered parameters).
	set varSearch to |⌘|'s class "NSRegularExpression"'s regularExpressionWithPattern:("(?m)(?:^local |^global |, |[({:])(?:my |its )?(?:«[^»]++» )?([a-zA-Z_][A-Za-z0-9_]*+|\\|[^|]*+\\|)(?![:(])") options:(0) |error|:(missing value)
	set searchHits to varSearch's matchesInString:(theText) options:(0) range:({0, theText's |length|()})
	repeat with thisHit in searchHits
		set varRange to (thisHit's rangeAtIndex:(1))
		tell countedSet to addObject:(theText's substringWithRange:(varRange))
	end repeat
	
	-- Shane's take-away-the-set-you-first-thought-of filter.
	set plainSet to |⌘|'s class "NSMutableSet"'s setWithSet:(countedSet)
	tell countedSet to minusSet:(plainSet)
	tell plainSet to minusSet:(countedSet)
	
	-- Explicitly filter out a few AS terms which may have slipped in.
	set termFilter to |⌘|'s class "NSPredicate"'s predicateWithFormat:("NOT (self IN {'true','false','as','of','timeout'})")
	tell plainSet to filterUsingPredicate:(termFilter)
	(* Or:
		set exclusionSet to |⌘|'s class "NSSet"'s setWithArray:({"true", "false", "as", "of", "timeout"})
		tell plainSet to minusSet:(exclusionSet)
	*)
	
	-- Sort and return what's left.
	return (plainSet's allObjects()'s sortedArrayUsingSelector:("localizedCompare:")) as list
end main

main()
1 Like

I can’t reproduce that.

The original script was a pretty quick-and-dirty effort. It looks like @NigelGarvey has beefed it up nicely.

As an exercice, I decided to try to do the job with plain old AppleScript.

script o
	property alist : {}
	property theGlobalsOrProperties : {}
	property theLocales : {}
end script

set theScript to ((path to desktop as text) & "draft library.scpt") as «class furl»

(*
tell application id "com.latenightsw.ScriptDebugger7" -- Script Debugger.app
	set theDoc to open theScript
	set theText to source text of theDoc
end tell
*)
tell application "Script Editor"
	set theDoc to open theScript
	tell theDoc
		set theText to its text as «class utf8»
	end tell
end tell

# To be sure that there is only a single line separator
set theText to my recolle(paragraphs of theText, return)
#To be sure that declarations of globals, locales and properties are at the beginning of a line
set theText to my supprime(theText, tab)

# Extract Globals and Properties

set o's alist to rest of my decoupe(theText, {return & "global ", return & "property "})
repeat with aBlock in o's alist
	set maybe to item 1 of my decoupe(paragraph 1 of aBlock, " ")
	if maybe is not in o's theGlobalsOrProperties then set end of o's theGlobalsOrProperties to maybe
end repeat

# Extract the locales
set o's alist to rest of my decoupe(theText, {return & "local "})
repeat with aBlock in o's alist
	set someLocales to my decoupe(paragraph 1 of aBlock, {", "}) --, " #", " --", " "})
	repeat with aLocale in someLocales
		set maybe to item 1 of my decoupe(aLocale, space)
		if maybe is not in o's theLocales then set end of o's theLocales to maybe
	end repeat
end repeat


return {globalesAndProperties:o's theGlobalsOrProperties, theLocales:o's theLocales}

#=====

on decoupe(t, d)
	local oTIDs, l
	set {oTIDs, AppleScript's text item delimiters} to {AppleScript's text item delimiters, d}
	set l to text items of t
	set AppleScript's text item delimiters to oTIDs
	return l
end decoupe

#=====

on supprime(t, d)
	local oTIDs, l
	set {oTIDs, AppleScript's text item delimiters} to {AppleScript's text item delimiters, d}
	set l to text items of t
	set AppleScript's text item delimiters to ""
	set t to l as text
	set AppleScript's text item delimiters to oTIDs
	return t
end supprime

#=====

on recolle(l, d)
	local oTIDs, t
	set {oTIDs, AppleScript's text item delimiters} to {AppleScript's text item delimiters, d}
	set t to "" & l
	set AppleScript's text item delimiters to oTIDs
	return t
end recolle

#=====

Yvan KOENIG running High Sierra 10.13.6 in French (VALLAURIS, France) dimanche 18 aout
2019 21:36:44

Edited because the original version detected false globals, locales or properties.

I think the problem Yvan’s been experiencing with the scripts is that his expectations aren’t the same as what the scripts are meant to do. He’s been expecting lists of all the locals, globals, or properties declared in a script, whereas the original brief was to find variables which had been declared but weren’t being used. My own script (which still has a few problems) doesn’t really concentrate on globals and properties either. :smile:

Nigel, Your script is very impressive. One potential problem I’ve found is that it returns the name of variables that occur only inside a handler (where they’re set to something and then used to do something, and so they occur twice).

Hi emendelson.

Thanks for the feedback. At the moment, it’s trying to return the names of what might be variables and which are only mentioned once anywhere in the target script’s operational code. It’s like Shane’s scripts, but with various bits of smartarsery to eliminate words in comments and avoid possible but unlikely problems with barred labels. I’m not totally happy with the way it identifies variables towards the end and I’m becoming aware that it may not be pursuing the right approach or even the right end.

The first problem is that I’m not entirely clear if we’re pursuing “variables” generally, as in the subject of this topic, or just “declared globals or properties”, as in your opening post.

The names-which-only-appear-once idea is based on the assumption that, if the target script works, a variable that’s only mentioned in it once will be either declared once but not used or set once but not used (taking “used” to mean that the value is retrieved at some point). But if it’s both declared and set, or if it’s declared or set multiple times, or if it has the same label as a local elsewhere in the script, or if it is such a local, the label will appear more than once in the code and so not be returned by my script, whether the value’s “used” or not.

It may be possible to write a simple search based on the known habits of the target script’s author, but I think I’ve bitten off more than I can chew in attempting a catch-all solution. But I’ll look at it again later after I’ve run today’s errands.

Actually I confused the issue, and the confusion is my fault. What I hoped to see in SD is the feature in Smile that does two things:

  1. detect globals and properties that are declared and not used

  2. detect variables that occur only once in a script

I realize that these are not the same, and Smile has separate lists.

One kind of “false positive” that occurred with one of the scripts on this page were words that occur once in a “display dialog” string. Is it possible for a script to ignore anything that occurs within quotation marks (testing for the first two quotation marks in a line, then the second two, etc.)?

What you say about variables that are both declared and set but not “used” is very apt, and I suppose there’s no way to find that except through an elaborate filtering method that asks "Is this variable ONLY declared, or ONLY set, or only (DECLARED and SET)? That would probably take forever.

Meanwhile, I’m very grateful for everyone’s efforts with this, and hope something can be built into SD in the way it was built into Smile.