Thursday, May 23, 2019

#1 2018-08-14 03:13:42 am

Nigel Garvey
From:: Warwickshire, England
Registered: 2002-11-20
Posts: 4869

'whose'/'where' imitator for AppleScript lists

'whose' (aka 'where') filters can't, unfortunately, be applied to AppleScript lists. While it's easy enough to write your own code to search a list for items matching specific criteria, the handler below is a generic effort which takes the criteria as parameters. Like a real 'whose' filter, it needs to be told the host object (here the list), the class of item to return, which match instance(s) to return, and the predicate the items have to match.

Say you have a list of text and you want to perform the equivalent of item 1 of myList where it begins with "s". At its simplest, you can call the handler with:


my filterList:myList forInstance:1 ofClass:(item) |where|:"it begins with \"s\""

If the list contains items of more than one class, or simply if you prefer, you can be more specific about the type of object required:


my filterList:myList forInstance:1 ofClass:(text) |where|:"it begins with \"s\""

The ofClass: parameter can be any relevant AppleScript class keyword, including number. With the help of using terms from …, it's possible to specify application class keywords as well, but I haven't looked into this very deeply. With "interleaved parameter" handlers like this one, it's necessary to parenthesise keywords when they're written directly into a call. Such handlers also require the script containing them to be identified in the calls, even when the calls and the handler are in the same script. That's why all the examples here begin with 'my'.

The forInstance: parameter indicates which of any matching items to return. An integer value is like the integer in an index reference and can be positive or negative, but not zero. Index references also allow anything which can be coerced to integer (or have done up to now), and it's the same with this handler.

A range of matches can be specified with a two-integer list …


-- text 1 thru -4 of myList where it begins with "s".
my filterList:myList forInstance:{1, -4} ofClass:(text) |where|:"it begins with \"s\""

… and an 'every' match with an empty list:


-- every text of myList where it begins with "s".
my filterList:myList forInstance:{} ofClass:(text) |where|:"it begins with \"s\""

The |where|: predicate parameter can be text, as above, or a list or a script object, whichever's convenient at the time. The list form can contain text or text fragments similar to the above and/or two-item lists containing text and an actual value. These two-item lists are templates for when the value you want to use is in a variable or is otherwise fiddly to represent as text.


set alternativeStartLetter to "s"
my filterList:myList forInstance:{} ofClass:(text) |where|:{"it begins with \"g\"", "or", {"it begins with", alternativeStartLetter}}
my filterList:myList forInstance:{} ofClass:(list) |where|:{{"its length ≥", (count list2)}, "and", {"its fourth item is", {a:"aardvark", b:"Hubert \"Knuckles\" O'Shaunessy", c:"{a:\"aardvark\", b:\"Hubert \\\"Knuckles\\\" O'Shaunessy\"}"}}}

In a compound predicate, it's recommended that template lists cover just one condition each. It's possible for them to cover more:


my filterList:myList forInstance:{} ofClass:(text) |where|:{"its length is 7 and", {"it begins with \"g\" or it begins with", "s"}}

… but the conditions are then parenthesised togther — as in its length is 7 and (it begins with "g" or it begins with "s") — so only do this if it's what you actually mean.

If preferred, you can write a script object to perform the tests and use that as the |where|: parameter. It must contain a one-parameter handler labelled isMatch() which returns a boolean indicating whether or not the parameter matches the 'where' requirements:


script myScriptObject
   property alternativeStartLetter : missing value

   on isMatch(a) -- a is an item from the list being filtered.
       return ((a's length is 7) and ((a begins with "g") or (a begins with alternativeStartLetter)))
   end isMatch
end script

set myScriptObject's alternativeStartLetter to "s"
my filterList:myList forInstance:{} ofClass:(text) |where|:myScriptObject

Like all 'whose' filters I've tried, the handler errors if there aren't enough matches to cover all the requested instances. But if you've passed an empty list to ask for 'every' match, and it turns out there are none, you get back an empty list.

I think that covers everything. Here's the handler:


(* 'whose'/'where' imitator for AppleScript lists, by Nigel Garvey 2018.

Handler format: filterList:forInstance:ofClass:|where|:

       The list from which to derive the filtered result.
       Either an integer denoting the instance number of the match to return;
       or a list of two integers indicating a range of matching instances to return;
       or an empty list, signifying that every matching item should be returned.
       Values which are coercible to integer are treated as integers.
       The keyword for the class of item to match. It will need to be parenthesised if coded directly into a call.
       Either a script object containing an isMatch(a) handler which returns true or false according to whether or not an item passed to it matches the conditions, eg.:
               on isMatch(a) -- 'a' is an item from the list.
                   return (a's length < 32)
               end isMatch
           end script
       or text representing the source code for a predicate in the form it might appear after 'where' in a real filter. eg.:
           "its length < 32"
           "(its fourth item is 5) and ((its item 2 begins with \"z\") or (its item 7 is {|name|:\"Fred\", age:109}))"
       or a list containing text(s) and/or {text, value} lists (which represent templates for individual conditions) from which such a predicate can be constucted.
           {"its length < 32"}
           {{"its length <", 32}}
           {"(its fourth item is 5)", "and", "((its item 2 begins with \"z\")", "or", "(its item 7 is {|name|:\"Fred\", age:109}))"}
           {{"its fourth item is", 5}, "and (", {"its item 2 begins with", "z"}, "or", {"its item 7 is", {|name|:"Fred", age:109}}, ")"}
   The requested match(es) if fully achievable. Otherwise an error.

on filterList:theList forInstance:n ofClass:requiredClass |where|:thePredicate
   script o
       property sourceList : theList
       property matchedItems : {} -- For multiple matches, if required.
   end script
   -- Check that the list parameter is in fact a list.
   if (theList's class is not list) then error "filterList:forInstance:ofClass:|where|: : the filterList: parameter isn't a list." number -1700 from theList to list
   -- Analyse the instance parameter. Integer (single match index), two-integer list (indices for a range of matches), or empty list (every match)?
       set singleMatchWanted to ((n's class is not list) or ((count n) is 1))
       if (singleMatchWanted) then
           -- If it's not a list, or is a single-item one, try coercing it to a non-zero integer.
           set n to n as integer
           if (n is 0) then error
           -- If it's negative, reverse the source list and use a positive index for convenience and speed.
           if (n < 0) then
               set o's sourceList to reverse of o's sourceList
               set n to -n
           end if
       else if (n is not {}) then
           -- If it's a non-empty list, check that it only contains two items and derive non-zero integers from both of them.
           if ((count n) > 2) then error
           set {n1, n2} to {beginning of n as integer, end of n as integer}
           if ((n1 is 0) or (n2 is 0)) then error
       end if
   on error
       error "filterList:forInstance:ofClass:|where|: : bad forInstance: parameter." number -1700 from n
   end try
   -- Check that the class parameter's a class.
   if (requiredClass's class is not class) then error "filterList:forInstance:ofClass:|where|: : the ofClass: parameter isn't a class." number -1700 from requiredClass to class
   -- Analyse the 'where' parameter.
   set classOfThePredicate to thePredicate's class
       if (classOfThePredicate is script) then
           -- If it's a script object, great.
           set matcher to thePredicate
       else if (classOfThePredicate is text) then
           -- If it's a line of text, insert it into the source code for a script to create a script object and run the code.
           set matcher to (run script ¬
               ("on run
                   on isMatch(a)
                       tell a to return ("
& thePredicate & ")
               return result
       else if (classOfThePredicate is list) then
           -- If it's a list of texts and/or {text, value} lists, parse its contents to use in the source code for a parametered script to create a script object.
           set insertCode to ""
           set argv to {}
           set argvIndex to 0
           repeat with i from 1 to (count thePredicate)
               set thisFragment to item i of thePredicate
               set classOfThisFragment to thisFragment's class
               if (classOfThisFragment is text) then
                   -- Text. Append to the insert code as is.
                   set insertCode to insertCode & " " & thisFragment
               else if ((classOfThisFragment is list) and ((count thisFragment) is 2)) then
                   -- Two-item list. If its first item's text, append that to the insert code along with a reference to the next position in the parameter list and add the second item to that list.
                   set fragmentCode to beginning of thisFragment
                   if (fragmentCode's class is not text) then error
                   set argvIndex to argvIndex + 1
                   set insertCode to insertCode & (" (" & fragmentCode & " argv's item " & argvIndex & ")")
                   set end of argv to end of thisFragment
               end if
           end repeat
           -- Complete the source code and run it to set up and obtain the script object.
           set matcher to (run script ¬
               ("on run argv
                   on isMatch(a)
                       tell a to return ("
& insertCode & ")
               return result
) ¬
                   with parameters argv)
       end if
   on error
       error "filterList:forInstance:ofClass:|where|: : bad |where|: parameter." number -1700 from thePredicate
   end try
   -- Work through the source list, testing each item against the specified conditions and dealing with it accordingly.
   set matchCount to 0
   repeat with i from 1 to (count o's sourceList)
       set thisItem to item i of o's sourceList
       set classOfThisItem to thisItem's class
       -- Class membership is satisfied by an exact match to the required class, the required class being item, or the required class being number and the item's class being either integer or real.
       -- A predicate match is indicated by matcher's isMatch(thisItem) returning true.
       if (((classOfThisItem is requiredClass) or (requiredClass is item) or ((requiredClass is number) and ((classOfThisItem is integer) or (classOfThisItem is real)))) and (matcher's isMatch(thisItem))) then
           set matchCount to matchCount + 1
           if (singleMatchWanted) then
               -- If only the nth match is wanted, simply return it when shows up.
               if (matchCount = n) then return thisItem
               -- Otherwise add all matches to the matched items list.
               set end of o's matchedItems to thisItem
           end if
       end if
   end repeat
   -- If we get this far, a single match wasn't returned above.
   if (singleMatchWanted) then
       -- Oops.
       error "filterList:forInstance:ofClass:|where|: : Can't get match " & n & " from " & matchCount & " matches." number -1728
   else if (n is {}) then
       -- Return every match, or the empty list if none.
       return o's matchedItems
       -- Try to return the requested range of matched items.
           return items n1 thru n2 of o's matchedItems
       on error
           error "filterList:forInstance:ofClass:|where|: : Can't get matches " & n1 & " thru " & n2 & " from " & matchCount & " matches." number -1728 partial result (get o's matchedItems)
       end try
   end if
end filterList:forInstance:ofClass:|where|:




Board footer

Powered by FluxBB

RSS (new topics) RSS (active topics)