Find Any File is Shareware

You may try it out without buying first. Simply download it.

If you keep using it you are expected to pay for it, though.

faf-big

Find Any File (FAF)

Key Features

  • Convenient folder and icon views for results
  • Can search in other users' home folders ("root" mode)
  • Searches can be saved for easy re-use
  • Can be launched with a self defined keyboard shortcut

New in version 2.5:

Find Any File Scripts

FAF's Matching Scripts are an extension for FAF that lets you add search methods that FAF doesn't inherently offer.

How to install the examples below

Note: This method requires FAF 2.5 or later.

  • Choose a script from the Example Scripts at the bottom of this page.
  • Click on the script description so that it expands.
  • Click on the "Install" button at the top right of the script code.
  • FAF will open and insert the script rule for you.
  • From now on, the script is available in the rules menu under "Script", so you can choose it any time you need it.

How to install the examples manually

  • Copy the script code (that's the text inside the light blue box) and paste it into a text editor, such as BBEdit or TextEdit, as a plain text file (ending in .txt). If you use TextEdit, make sure to open the Format menu and choose “Make Plain Text” if it's available, or you'll use Rich text, which won't work.
  • Save the file somewhere convenient.
  • Once saved, change the file's extension (in Finder) to “.lua” for Lua scripts, or to “.js” for Javascripts (the script's introductory text will indicate which type it is).
  • Open Find Any File (FAF), hold down the option (⌥) key and click on the rules pop-up menu (usually titled “Name”) and then choose "Scripts" from the menu. This will set the rule to “Script matches any”.
  • Click on “any” and choose “Open Scripts Folder”.
  • Move your script into that folder (the folder's name is “Matching”).
  • Back in FAF, click on “any” and choose your newly added script.


More details for power users

Starting with FAF version 2.4, FAF's search rules can be extended by scripts (programming code) in the Lua and JavaScript (short: JS) languages.

This makes it possible to create very specific and complex search rules. If you don't feel comfortable with writing script code, look below for a list of readily available scripts that you can install, or ask me for assistance.

(I am also planning to add more scripting features, such as for displaying custom columns and more information about listed files and folders, and offering more commands in the contextual menu for found items.)

Installing scripts

FAF's AppSupport folder (which you can open by opening the menu bar, under Help, and choosing Open the AppSupport folder) contains a Scripts folder, which in turn contains sub folders for the different types of scripts:

  • Matching – These are used as search rules, which you can then choose from once you select the Script matches rule.

You can also reach the scripts folder by clicking on the rightmost pop-up menu for the Script rule:

Open Script Folder

(Note: In v2.4 and 2.4.1, the Script rule does not appear in the pop-up menu unless you hold down the option (⌥) key before clicking on it!)

Place the script into the appropriate sub folder (i.e. Matching), and make sure its name ends in either .lua, .js or .javascript.

Then click on the right-most pop-up menu again, where you will now find the newly installed script. Choose the script from the menu to use it with the Script rule.

Writing new scripts

Click here to see the details if you want to learn how to write your own scripts.

Scripts for matching during search

These are scripts that are placed into the Matching folder. For instance, if you have a file named test.lua in that folder, you can choose it in the Find window as shown:

Script rule

A simple script that imitates the "Name contains good" rule would look like this:

Lua:

function match(diskItem)
    if string.find(diskItem.name, "good") then
        return true
    end
end

JavaScript:

function match(diskItem) {
    if (diskItem.name.includes("good")) {
        return true;
    }
}

Basically, you have to implement a function named match that takes one argument, and returns either true or false (returning nothing is fine, too, and is the same as returning false). The one argument is an object providing access to many properties of the to-be-checked item (i.e. either a file or a folder). The properties will be listed in a section further below.

A script's lifetime and storing data persistently

The script's context, i.e. where it stores its global properties, gets re-initialized after each search operation. That means a script can't keep its values across multiple searches.

It could store some information in FAF's preferences, but if your script attempts this, it should take care to use a unique name for the preference key, ideally based on the name of the script, or on a reverse domain name you own. And the data stored should be kept small; a few 100 bytes is okay, a path list of all found items would not be, as that could easily become megabytes of data, which the prefs system is not suited for.

Another option would be to store data in a text file, using the faf.fileLoad and faf.fileSave functions. If you do not pass an absolute path but only a file name, the file will be located inside a folder named "Storage" next to the script file. That way, you won't have to figure out where best to store the data for the script; just pass a resonable file name.

Configuration options

If the source code has a single-line comment containing "_FAF_Config_" in the form

_FAF_Config_ ( option1, option2 ) 

then the one or more listed options will be used to configure the rule's appearance and/or behavior.

The following options are available:

  • input or input=single – will add a single-line input field to the rule in the Find window. The input value can be queried by the script using currentSearch.input, which will be a string. The rule will only be usable if the user has input something into the text field.
  • input=multi – will add a multi-line input field. currentSearch.input will return an array of strings in this case. An empty input will disable the rule.
  • inputOptional – The rule doesn't require input, i.e. even with no text in the input field the rule will be run. Only applies if input is also declared.
  • persistentContext – The lifetime (see above) will be extended as long as possible, meaning that it won't create a new context for each search (but will still renew the context if the script was stopped due to an error or when clicking Stop in faf.showAlert() or whenever the script's source file gets updated).
  • minAppVersion=N – Declares that this script requires a certain FAF version (e.g. when using functions introduced later). N is the app's integer version that's shown in parentheses, such as 367 in version 2.4.2 (367.1). This works since appVersion 365 (i.e. it works in v2.5 and later).

Debugging

If you use a script, and if the script has an issue, FAF will display a message about the encounted syntax or runtime error in the Find window, and abort the search or otherwise stop using the script until you edit it.

JavaScript

Debugging your JS scripts is fairly easy thanks to Apple having provided the JavaScriptCore engine to run the scripts and linking it with Safari's Web Inspector:

Safari debugger

For this to work, you need a special version of FAF that allows the debugger to be enabled. You should find this special version either here for release versions and here for beta versions. These versions have a reduced safety state (i.e. they're not notarized). This means that you cannot launch these versions with a double click after downloading them, or macOS will tell you that the app is damaged. Instead, you first need to remove the quarantine state from the app by issuing this command in Terminal.app: xattr -d com.apple.quarantine /path/to/FindAnyFile.app (you need to enter the correct path to the app, of course). Then ctrl-click on the app's icon and choose "Open" from the menu, and confirm to open it (you'll have to do this only once). After that, this version will work just like the regular version, with the added benefit of being able to use the JavaScript debugger.

But note that this debug-enabled version can't automatically be updated by FAF's self-update mechanism. To update FAF, you will need to run the regular version instead. Then, if you want to debug JavaScript again, you need to download the new special version manually again, as explained above.

Now, to enable JS debugging (which includes support for the console.log() command), launch Safari, open its Preferences window, switch to the Advanced tab and check the "Show Developer menu in menu bar" option. With that, you'll find a new Develop item in the menu bar. In that, find the row titled after your Mac's name (3rd from the top, usually), and check the options "Automatically Show Web Inspector for JSContexts" and "Automatically Pause Connecting to JSContexts" inside the submenu.

Now, if you use a Script in FAF, Safari will open a Web Inspector window in which you can see the Console output, view the source code, set breakpoints into the match function, view the diskInfo object's properties and their values, and single-step through the code to see what it does.

Turn the options in the Develop menu off again once you're finished with debugging your script, or the Web Inspector windows will keep popping up when FAF runs any scripts.

Lua

Unfortunately, there's no advanced debugging support for Lua with FAF. Therefore, consider using JavaScript and Safari's Web Inspector if you want to write more complex scripts, as it'll probably make it easier.

You can, however, use the logInfo function as in faf.logInfo("file name:"..diskItem.name) to write a line of text to the FAF.log file, which you can view by opening the menu bar, under Help, and choose Show Log

Objects and operations (events) provided to the scripts

function match (diskItem)

The optional match function, when implemented by the script, is called during a search repeatedly, being passed every disk item (file or directory) that has been left after matching by any other previous rules in the search. The function then has to return true to keep the item matched. If all rules agree that an item matches, it will be added to the "found results". Since the scripts usually run a bit slower than FAF's built-in rules, scripts will therefore always be invoked last (but before file content rules), in order to sort out any misses (non-matches) more quickly before invoking the script with the remaining items that the other rules have matched. If the match function is not declared in the script, then its rule will always match.

function searchHasStarted ()

The optional searchHasStarted function, when implemented by the script, is called once a search starts. The global variable currentSearch will be set at this point (and remains available until after the search has ended). The function gets a ruleInfo passed, which has information about the rule the script is running under.

function searchHasEnded (completed, foundItems)

The optional searchHasEnded function, when implemented by the script, receives two arguments: The first is a boolean stating whether the search was completed (as opposed to being stopped by the user), and the second provides all the found disk items so far in an array (this may exclude items that require a content search, though).

Since version 2.4.2 (appVersion 365), currentSearch.addMatch() may be called from here to add more to the set of found items.

function skipDirectory (diskItem)

The optional skipDirectory function, when implemented by the script, gets called during a "slow" search for every directory it encounters. If this function returns true, that directory's contents won't be searched.

function finishedDirectory (diskItem)

The optional finishedDirectory function, when implemented by the script, gets called during a "slow" and recursive search (set currentSearch.searchMode to 3 to enable) for every directory it had traversed into.

The "disk item" object

The properties of the disk item objects can be seen in the Web Inspector (see above, Debugging). Here's a list (which is manually collected and thus may be outdated or incorrect):

volumeName: string
name: string                // normalized, POSIX format (using ":" in names, not "/")
localizedName: string       // as shown by Finder (using "/" in names, not ":") 

path: string
canonicalPath: string       // normalized & canonical
normalizedPath: string      // normalized
parentPath: string          // normalized

fileSize: number            // combined data and resource fork size
dataSize: number            // data fork size only
resourceSize: number

fileType: string            // the UTI
kind: string

tagsArray: array of string 

typeCode: number
typeCodeString: string
creatorCode: number
creatorCodeString: string

ownerID: number
groupID: number

fileIDNumber: number
inodeNumber: number

creationDate: date
modificationDate: date
lastContentAccessDate: date
addedToDirectoryDate: date

labelNumber: number
localizedLabel: string
finderComment: string

exists: boolean
isDirectory: boolean
isRegularFile: boolean
isSymlink: boolean      // true for symlinks only
isAlias: boolean        // true for symlinks and Finder Aliases
isHidden: boolean       // true only if the item itself is invisible or its name starts with a "."
isItemOrParentItemHidden: boolean   // true if hidden or inside a hidden folder
isVolume: boolean       // true if it's the root folder of a volume
isPackage: boolean
isTrashed: boolean
isTrashFolder: boolean
isLocked: boolean
isSystemProtected: boolean
isContentOfPackage: boolean     // true if inside a package (bundle)
isDataless: boolean     // true if the file or folder is offline, i.e. its data is only in the cloud

parentItem: DiskItem    // returns the parent object, or nil (undefined) for the root dir

function resourceValueNamed(name): any type

The date type is actually a number in Lua. You can then use the os.date() function to extract day and time from it.

For resourceValueNamed(), pass any of the names listed as Key under NSURLResourceKey.

For now, all these properties are read-only.

The "faf" global

faf is a global object that provides general functions and properties:

// These log functions write to the "FAF.log" file; see FAF's Help menu
function logInfo(msg)
function logWarning(msg)
function logError(msg)

// Disable the script with an error message, e.g. if requirements are not met.
function fail(msg)  // Available since appVersion 365

// Get a diskItem for the given POSIX path.
function diskItem(path) // Available since appVersion 365

// Get and set FAF's preferences
function prefsValue(key): any type of value
function setPrefsValue(key, value)

// Load from and write to text files; passing an empty string to the
// path parameter will use the script's file name, and passing no
// absolute path but only a file name will put the file into a folder
// named "Storage" next to the script file.
function fileLoad(path): string
function fileSave(path, content): boolean
function fileAppend(path, content): boolean

function showAlert(title, subtext)
   // Shows a modal dialog with an "OK" and a "Stop" button

function showNotification(title, subtext)
   // Shows a notification in the top right screen corner
   // (if not disabled in System Preferences by the user)

function beep()             // Plays the system alert sound
function playSound(name)    // E.g.: faf.playSound("Frog")

scriptFileName: (read-only) // Returns the file name of the script

appVersion: (read-only)     // Returns the app's numeric version (integer part only). Available since appVersion 365

// Execute a commandline tool and wait for it to end. For example, in JavaScript:
//   let output = faf.runCommandWithArgs("/bin/ls", ["-l","/"])
// or to run a shell with a free command string:
//   let cmd = "ls -la ~"
//   let output = faf.runCommandWithArgs("/bin/sh", ["-c",cmd])
function runCommandWithArgs(cmd, args): string // args is an array of strings

// Execute a command without waiting for it to finish, and instead providing
// a callback function for its output. For example, in JavaScript:
//   faf.runCommandWithArgsDataAsync("/sbin/md5", ["-q", diskItem.path], diskItem, function (diskItem, output) {
//     faf.logInfo("-- MD5 of "+diskItem.path+ " is "+output+" --")
//   })
// The `data` argument is passed on to the function, which has two parameters: data and the output from the command.
// Available since appVersion 366.
function runCommandWithArgsDataAsync(cmd, args, data, function(data, output)) // args is an array of strings

// The following two functions are needed when using runCommandWithArgsDataAsync()
// inside the searchHasEnded() script handler function, in order to let FAF know that the search
// shall not be finished until all async command calls have been completed.
// `ignorePrematureStop()` needs to be invoked first, and `waitForAsyncTasksToFinish()` at the end
// of the function. Available since appVersion 366.
function ignorePrematureStop()
function waitForAsyncTasksToFinish()

For example, to write a line to the log file, use:

faf.logInfo ("some info")

The "currentSearch" global

This is an object that only exists during a search.

// Set and retrieve named values that persist across
// all involved matching scripts during a search:
function setSharedValue(name, value)
function sharedValue(name): any type of value

currentSearchTarget:
   (read-only) A disk item that specifies which volume or folder
   is currently searched.

statusMessage:
   (string, r/w) When assigning a string to this, the search will be
   stopped and the text be shown in the Find window. This can be used
   to show an error message as well as to show extra information at
   the end of a successful search (when set from `searchHasEnded`).

input:
   (read-only) If the script has requested to show an input field
   with the rule, this property will contain the text the user has
   typed in. It's either a string or an array of strings, depending
   on the config value (`input=single` or `input=multi`).

stopped:
   (boolean, read-only) Is true once the user stops the search with the Stop button.
   If you run a lengthy operation in one of the event functions, you should regularly
   check this property and exit the function once its value is true.
   Available since appVersion 365.

// These settings are to be applied to all comparisons
// and can be changed with related rules:
caseSensitiveNames (boolean, read-only)
diacriticsSensitiveNames (boolean, read-only)
caseSensitiveContent (boolean, read-only)
negateConditions (boolean, read-only)

// The following properties can only be changed from
// within the `searchHasStarted` function:
searchMode (integer, r/w):
   0: Default (prefers fast search).
   1: Forced slow mode (no searchfs aka CatalogSearch and no `find` tool).
   2: Prefer recursive in slow mode.
   3: Force recursive mode (slower than 1 but needed for `finishedDirectory` callback).
      This also implictly sets spotlightMode to 0.
   4: May use `find` tool in Pro version for searches run on the Mac.
   8: May use `find` tool in Pro version for searches on servers via SSH.
spotlightMode (integer, r/w):
   0: No Spotlight use.
   1: Include Spotlight query.
   33: Spotlight only.
calculateFolderSizes (boolean, r/w):
   Only used searchMode is 3. Calculates the size of every
   searched folder, and can be fetched in the `finishedDirectory`
   callback with `f.resourceValueNamed("NSURLTotalFileSizeKey")`.

addMatch(diskItem)
    Adds the item to the results.


Example scripts

These and more scripts are downloadable here.

Find executable files

This finds executable files (another, faster, method is to use the rule "Kind is executable").

JavaScript version (save this as "Is executable.js" to the Scripts/Matching folder):

function match(f) {
    if (! f.isRegularFile) { // ignores directories and symlinks
        return false
    }
    isExecutable = f.resourceValueNamed ("NSURLIsExecutableKey")
    return isExecutable
}

And the alternative Lua version (save this as "Is executable.lua" to the Scripts/Matching folder):

function match(f)
    if not f.isRegularFile then -- ignores directories and symlinks
        return false
    end
    isExecutable = f.resourceValueNamed ("NSURLIsExecutableKey")
    return isExecutable
end
Find Finder-locked files

Finder-locked files are those where you can check the "lock" in the "Get Info" window in Finder. Files can also be locked by other methods, e.g. via ACLs, which will not be identified by this script.

Save as "Is locked.lua" to the Scripts/Matching folder:

function match(f)
    return f.isLocked
end
Find purgeable files

Works on macOS 11 and later. See Which files are purgeable? for details.

Save as "Is purgeable.lua" to the Scripts/Matching folder:

function match(f)
    if not f.isRegularFile then -- sorts out directories and symlinks
        return false
    end
    return f.resourceValueNamed ("NSURLIsPurgeableKey")
end
Find sparse files

Save as "Is sparse file.lua" to the Scripts/Matching folder:

function match(f)
    if not f.isRegularFile then -- sorts out directories and symlinks
        return false
    end
    return f.resourceValueNamed ("NSURLIsSparseKey")
end
Find items that have Extended Attributes

Save as "Has Extended Attribute.lua" to the Scripts/Matching folder:

function match(f)
    local hasXattr = f.resourceValueNamed ("NSURLMayHaveExtendedAttributesKey")
    if hasXattr then
        -- This item _may_ have an EA; now we need to check if it really has any
        local output = faf.runCommandWithArgs("/usr/bin/xattr", {"-s", f.path})
        --faf.logInfo(output)
        if output ~= "" then
            return true
        end
    end
end
Find files and folders by length of their name

Here is a lua script that also shows how to specify that an input value is required

Save as "Name length.lua":

-- a Lua program, see https://findanyfile.app/scripting.html
--
-- _FAF_Config_ (input=single)
--
--
-- The user may enter a comparator symbol (=, <, >) and then a number to compare against.
--
-- Examples:
--   >80   -- checks for names longer than 80 chars. Same as >=81 (which you cannot use).
--   =12   -- checks for names of exactly 12 chars in length.
--   <5    -- checks for names shorter than 4 chars. Same as <=4 (which you cannot use).

name_length = 0
comparator = ""

function searchHasStarted ()
    -- Fetch the user's input and make sure it's valid
    local input = currentSearch.input
    comparator = string.sub (input, 1, 1)
    if not (comparator == "=" or comparator == "<" or comparator == ">") then
        currentSearch.statusMessage = "Input must start with =, < or >"
    else
        name_length = tonumber (string.sub (input, 2, -1))
        if name_length <= 0 then
            currentSearch.statusMessage = "Input must provide a number > 0"
        end
    end
end

function match(f)
    local s = f.name
    if comparator == "=" then
        return string.len (s) == name_length
    elseif comparator == "<" then
        return string.len (s) < name_length
    elseif comparator == ">" then
        return string.len (s) > name_length
    else
        currentSearch.statusMessage = "Invalid comparator in script's match function"
    end
end

BTW, you could also perform a name length check with a Regex rule:

Regex length rule

The above rule finds any name that is at least 25 bytes long (plain "ASCII" characters such as A-Z and digits count as one byte, whereas non-latin characters may count as 2 to 5 bytes, though, so using regex may not work well if you're using non-latin scripts for your file names).

Find files whose names are not allowed in Microsoft OneDrive

Certain file names cannot be used with OneDrive. To identify them, use this script (save it as "OneDrive Name Issues.lua"):

-- Finds file names that will cause an issue if moved to Microsoft OneDrive.
-- Save this to FAF's Scripts/Matching folder with a name ending in ".lua".
-- For more info, see https://findanyfile.app/scripting.html

function match(f)
    name = f.name
    firstchar = string.byte (name, 1)
    lastchar = string.byte (name, -1)

    -- these chars may not appear in file names: /\:"*?
    if name:find "[/\\:\"%*%?]" then
        return true
    end

    -- names may not start nor end with blanks
    if firstchar == 32 then
        return true
    end
    if lastchar == 32 then
        return true
    end

    -- names may not end with a period
    if lastchar == 46 then
        return true
    end
end

To fix the file names of these items, you may use renamer programs such as NameChanger: Install the program, then select the found items in FAF, open the "Services" menu (e.g. by right-clicking on the selection) and choose "Rename with NameChanger" and then enter various replacement rules for "Original Text" and "New Text", such as replacing each of the invalid chars with an underscore ("_") or a dash ("-") character.

Find folders with a min/max number of files and folders inside

To find folders that contain a minimum, exact, or maximum number of files and/or folders inside, use this script, and adapt it to your needs.

The version below finds folders whose cumulated item count, which means the number of items in a folder and all its decendents, is at least the entered number. You can alter the code to match exact numbers or count only the immediate items in a folder without any deeper folder contents.

Save as "Minimum item count.lua":

-- A Lua program, see https://findanyfile.app/scripting.html
--
-- _FAF_Config_ (input=single)

targetItemCount = 0

function searchHasStarted ()
    -- Fetch the user's input and make sure it's a sensible number, i.e. > 0
    targetItemCount = tonumber (currentSearch.input)
    if targetItemCount <= 0 then
        currentSearch.statusMessage = "input must be a number > 0"
    else
        currentSearch.searchMode = 3 -- forces recursive search that performs the folder calculations we need below
        if faf.appVersion >= 366 then
            currentSearch.countHiddenItems = true   -- without this, the counts of hidden items will not be determined
        end
    end
end

function match(f)
    -- Let's not match anything by default (we'll do it below)
    return false
end

function finishedDirectory(f)
    -- Determine the currently searched folder's item counts
    cumulatedItemCount = (f.resourceValueNamed("dirFileCount") or 0) + (f.resourceValueNamed("dirFolderCount") or 0)
    if faf.appVersion >= 366 then
        -- These additional values require FAF 2.5 or later:
        cumulatedHiddenItemCount = (f.resourceValueNamed("dirFileCountHidden") or 0) + (f.resourceValueNamed("dirFolderCountHidden") or 0)
        immediateItemCount = (f.resourceValueNamed("dir1FileCount") or 0) + (f.resourceValueNamed("dir1FolderCount") or 0)
        immediateHiddenItemCount = (f.resourceValueNamed("dir1FileCountHidden") or 0) + (f.resourceValueNamed("dir1FolderCountHidden") or 0)
    end

    -- Print the values into the FAF.log file, for debugging:
    --faf.logInfo (f.path)
    --faf.logInfo ("    total: "..cumulatedItemCount.." ("..cumulatedHiddenItemCount.." hidden), in this dir only: "..immediateItemCount.." ("..immediateHiddenItemCount.." hidden)")

    -- If the count reaches the input value, then make this folder a match that'll appear in the results.
    -- You can change the following line to check against a different count, e.g. to count only visible items,
    -- you would use:
    --   if cumulatedItemCount-cumulatedHiddenItemCount >= targetItemCount then
    -- And to find folder with a maximum of immediate items (ignore those in sub folders):
    --   if immediateItemCount <= targetItemCount then
    if cumulatedItemCount >= targetItemCount then
        currentSearch.addMatch (f)
    end
end
Find folders of a minimum size (of the total files inside)

Save as "Minimum folder size.lua":

-- a Lua program, see https://findanyfile.app/scripting.html
--
-- _FAF_Config_ (input=single)

targetSize = 0

function searchHasStarted ()
    -- Fetch the user's input and make sure it's a sensible number, i.e. > 0
    targetSize = tonumber (currentSearch.input)
    if targetSize <= 0 then
        currentSearch.statusMessage = "input must be a number > 0"
    else
        currentSearch.searchMode = 3 -- forces recursive search that performs folder calculations
        currentSearch.calculateFolderSizes = true -- needed for getting "NSURLTotalFileSizeKey" set
    end
end

function match(f)
    -- let's not match anything by default (we'll do it below)
    return false
end

function finishedDirectory(f)
    -- determine the currently searched folder's content size
    currentSize = (f.resourceValueNamed("NSURLTotalFileSizeKey") or 0)
    -- if the size reaches the input value, then make this folder a match that'll appear in the results
    if currentSize >= targetSize then
        currentSearch.addMatch (f)
    end
    -- optional logging of values:
    --   faf.logInfo (f.name .. ": " .. tonumber(currentSize))
end
Find the parent folders of specific files

Use this rule as the last rule with other rules that match specifc files. That way, if the other (previous) rules find a match, this script will then drop those matches and instead mark the parent folder as a match.

Save as "Parent folders of matched items.lua":

-- a Lua program, see https://findanyfile.app/scripting.html

function searchHasStarted ()
    currentSearch.searchMode = 3 -- forces a recursive search
end

function match(f)
    -- if we get here, previous rules have already matched - which means we drop
    -- this match and instead match its parent directory
    currentSearch.addMatch (f.parentItem)
    return false
end
Find folders that do not contain specific files

Use this rule as the last rule with other rules that match specifc files. That way, if the other (previous) rules find a match, this script will then drop those matches and remember that the folder had at least one match. It will then instead mark those folder as matched that had no matches inside.

Save as "Folders with no matches.lua":

-- a Lua program, see https://findanyfile.app/scripting.html

function searchHasStarted ()
    currentSearch.searchMode = 3 -- forces a recursive search
end

matchStack = {}

function skipDirectory (diskItem)
    -- we're entering a new directory - put a fresh "has no match" flag onto the stack
    table.insert (matchStack, false)
    return false
end

function finishedDirectory (diskItem)
    -- directory has been searched - did we have matches inside?
    local hadMatch = table.remove (matchStack)
    if not hadMatch then
        currentSearch.addMatch (diskItem)
    end
end

function match (diskItem)
    -- if we get here, previous rules have already matched - which means we drop
    -- this match and instead remember that this directory had a match
    matchStack[#matchStack] = true
    return false
end
Find folders that do not contain files with specific extensions

Use this rule to locate folders that are missing files with the given extensions, any levels deep.

Save as "Folders without extensions.lua":

-- See https://findanyfile.app/scripting.html
--
-- Save as "Folders without extensions.lua" to FAF's "Scripts/Matching" folder.
--
-- FAF script for listing folders that do not contain files with a set of specified extensions.
-- To specify multiple extensions, enter them with a blank (space) as a separator.
--
-- _FAF_Config_ (input=single)

-- "deep" setting: Choose true or false. "false" means to look for the extensions only in the
-- same folder as the matched file, "true" will also search all deeper folders.
deep = true

exts = {}

if deep then
    ls_cmd = "find ."
else
    ls_cmd = "ls -1"
end

function searchHasStarted ()
    -- Build a shell command that lists all files with the given extensions
    -- This turns an input like "png jpeg" into the cmd "ls -1 *.png *.jpeg"
    local input = currentSearch.input
    for ext in input:gmatch("%w+") do table.insert(exts, ext) end
    for idx = 1, #exts do
        local ext = exts[idx]
        if ext ~= "" then
            if not startswith(ext, ".") then
                ext = "." .. ext
            end
            if deep then
                if idx > 1 then
                    ls_cmd = ls_cmd .. " -or"
                end
                ls_cmd = ls_cmd .. " -iname '*" .. ext .. "'"
            else
                ls_cmd = ls_cmd .. " *" .. ext
            end
        end
    end
    -- faf.logInfo ("Script cmd: " .. ls_cmd)
end

visitedDirs = {}

function match (diskItem)
    -- if we get here, previous rules have already matched - which means we drop
    -- this match and instead remember that this directory if there's none of the exts inside
    local parentItem = diskItem.parentItem
    local path = parentItem.path
    local visited = visitedDirs[path]
    if not visited then -- we need to check each dir only once
        visitedDirs[path] = true
        -- Are there any files of the input's extensions in the same
        -- directory as the currently matched file?
        local cmd = 'cd "' .. path .. '" ; ' .. ls_cmd .. " 2>/dev/null"
        local res = faf.runCommandWithArgs ("/bin/sh", {"-c",cmd})
        res = trim(res)
        if res == "" then
            -- there were no matching files in the dir
            currentSearch.addMatch (parentItem)
        else
            -- there were matching files in the dir
        end
    end
    return false
end

function startswith(text, prefix)
    return string.sub(text, 1, #prefix) == prefix
end

function trim(text)
    return string.gsub(text, "%s+", "")
end
Find empty folders

Use this rule to identify directories (which includes folders and bundles) that contain no files apart from the insignificant .DS_Store files or contain only directories that in turn are determined to be empty as well.

This is effectively the same operation that you can also get when using my free tool Find Empty Folders.

Save as "Is empty directory.lua":

-- "Is empty directory.lua"
--
-- a Lua program, see https://findanyfile.app/scripting.html

function searchHasStarted ()
    currentSearch.searchMode = 3 -- forces a recursive search
end

isEmptyDirectoryStack = {}  -- an array of booleans for the currently scanned folder and all its parents

-- The calling order of the following functions is as follows:
--
-- match() - can be a file or directory. If it's a directory, then:
--   skipDirectory() if it's a directory
--   recursion (i.e. calling match, skipDirectory, finishedDirectory for all items inside)
--   finishedDirectory() if it's a directory

function match(f)
    -- remember whether this directory has a visible item inside
    if f.isDirectory then
        -- we'll let finishedDirectory() handle this later, after its contents have been checked
    else
        -- check a file inside the current directory
        if f.name == ".DS_Store" then
            -- let's ignore this often occuring file inside otherwise empty directories
        else
            --faf.logInfo("dir not empty bc of file: "..f.path)
            isEmptyDirectoryStack[#isEmptyDirectoryStack] = false
        end
    end
    return false
end

function skipDirectory (f)
    -- we're entering a new directory - put a fresh "is empty" flag onto the stack
    table.insert (isEmptyDirectoryStack, true)
    return false
end

function finishedDirectory(f)
    -- directory has been searched - is it still marked as empty?
    local isEmpty = table.remove (isEmptyDirectoryStack)
    if isEmpty then
        currentSearch.addMatch (f)
    else
        -- mark the content of the parent directory as non-empty, too
        --faf.logInfo("dir not empty bc of non-empty subdir: "..f.parentItem.path)
        isEmptyDirectoryStack[#isEmptyDirectoryStack] = false
    end
end
Excluding file names using regular expressions

Looking for a "Name doesn't match regex" rule? Use this script and set the rule to [Script] [does not match] [regex]

Save as "regex.js":

// a Javascript program, see https://findanyfile.app/scripting.html
//
// _FAF_Config_ (input=single)
//
//
// Matches on the entered a regular expression.

var regex;

function searchHasStarted () {
    regex = new RegExp(currentSearch.input, "i");   // "i": case insensitive
}

function match(f) {
    return regex.test (f.name);
}
Find identical files (duplicates)

This script finds identical files, by comparing their contents. It's not very efficient (there are much better apps in the App Store, for instance), but it works, and it's a nice demonstration of what's possible with FAF's scripting feature.

Ideally, you would combine this script with an additional rule that requires a minimum file size, e.g. [File size] [is greater than] [1 M], to have it only check larger files, because checking every tiny file will take a lot of memory and extra time.

It requires FAF v2.5 or later.

See the comments in the script below for more information.

Save as "Duplicates.lua":

-- a Lua program, see https://findanyfile.app/scripting.php
--
-- Latest update: 2 Dec 2024
--
-- This script finds identical files (only checking for identical data forks, not other
-- metadata such as resource forks and extended attributes).
--
-- When it finds files with identical contents, it adds them to the results. It's up to you
-- to see which files are the identical pairs, e.g. by looking at their file sizes (which
-- would be identical unless they also have different-size resource forks, as FAF lists
-- their sizes as a sum of their data and optional resource forks).
--
-- This script should ideally be used with additional rules such as a minimum file size,
-- for performance reasons – if you want it to find any tiny duplicate file on your main
-- volume with millions of files on it, this could take many hours!)
-- So add a rule such as "File system is greater than 10m" to have it only check files
-- above 10 MB in size. It might still take many minutes for it to finish the process,
-- because it'll have to read each file (it's calculating an md5 checksum for each).
--
-- You can see the groups of identical files also in the log, which you can open from
-- FAF's Help menu.
--
-- _FAF_Config_ ( persistentContext, minAppVersion=366 )
--

if faf.appVersion < 366 then
    faf.fail ("The script '" .. faf.scriptFileName .. "' doesn't work with this outdated version of FAF.")
end

filesBySize = {} -- a dictionary with every file's size (if over the threshold size) as the key
filesByMD5 = {} -- a dictionary with every file's MD5 hash as the key and an array of the file objects as its value
md5ByFile = {} -- a dictionary with every file's know MD5 hash value (useful if the search is repeated, whereby the script retains previously collected values)

threshold = 32768 -- hashes of files smaller that this value are collected immediately, others are collected at the end

function searchHasStarted ()
    -- Clear some of our globals because FAF keeps their values in memory due to the
    --  "persistentContext" config setting above.
    filesBySize = {}
    filesByMD5 = {}
    -- However, we do not clear the possibly collected hashes from previous searches,
    -- instead re-using them (though, if the file contents changed since then, we won't
    -- notice this). To have FAF forget the previous hashes, FAF needs to be quit first.
end

function match (f)
    if not f.isDirectory then
        local size = f.dataSize
        if size > 0 then
            if size > threshold then
                -- Skip md5 calculation for now for larger files, in order to make this faster.
                -- Instead, we only record their size. Then, in searchHasEnded(), we look at
                -- each recorded size and see if we have multiple files of that sizes, and only
                -- then we need to check their content to see if they have also an identical
                -- md5 checksum value.
                local files = filesBySize[size]
                if files == null then
                    files = {}
                    filesBySize[size] = files
                end
                files[f] = true -- adds this file to the list of files for this particular size
            else
                getMD5 (f)  -- for smaller files, we get the checksum right away, as it'll be quite fast.
            end
        end
    end
    return false
end

function addMD5 (f, md5)
    local dupes = filesByMD5[md5]
    if dupes == nil then
        dupes = {}
        filesByMD5[md5] = dupes
    end
    dupes[f] = true
end

function getMD5 (f)
    local md5 = md5ByFile[f.path]
    if md5 then
        addMD5 (f, md5)
    else
        faf.runCommandWithArgsDataAsync ("/sbin/md5", {"-q",f.path}, f,
            function (f, result)
                local md5 = trim (result)
                if md5 ~= "" then
                    md5ByFile[f.path] = md5
                    addMD5 (f, md5)
                end
            end
        )
    end
end

function trim (s)
   return s:match "^%s*(.-)%s*$"
end

function searchHasEnded (completed, foundItems)
    if not completed then
        return  -- user has stopped the search
    end
    faf.logInfo("-- Collecting MD5 of larger files… --")
    for size, files in pairs(filesBySize) do
        -- we need to get the hash only if there's more than one file with the same size
        local first
        local handledFirst = false
        for f, v in pairs(files) do
            if currentSearch.stopped then
                return
            end
            if not first then
                first = f
            else
                if not handledFirst then
                    handledFirst = true
                    getMD5 (first)
                end
                getMD5 (f)
            end
        end
    end
    faf.logInfo("-- Waiting for MD5 collection to finish… --")
    faf.waitForAsyncTasksToFinish()
    faf.logInfo("-- Listing duplicates… --")
    -- now that we have all checksums, let's see which checksums are used by more than one file
    for md5, files in pairs(filesByMD5) do
        local first
        local handledFirst = false
        for f, v in pairs(files) do
            if currentSearch.stopped then
                return
            end
            if not first then
                first = f
            else
                if not handledFirst then
                    handledFirst = true
                    faf.logInfo("\t" .. first.path)
                    currentSearch.addMatch(first)
                end
                faf.logInfo("\t" .. f.path)
                currentSearch.addMatch(f)
            end
        end
    end
    faf.logInfo("-- Finished --")
end
Find files having the same name

See the comments in the script below for more information.

Save as "Duplicate Names.lua":

-- a Lua program, see https://findanyfile.app/scripting.php
--
-- This script finds files with identical names.
--
-- When it finds files with identical names, it adds them to the results. It's up to you
-- to see which files are the identical pairs, e.g. by sorting them by name.
--
-- You can also see all duplicates listed in the log file (see FAF's Help menu).
--
-- _FAF_Config_ ( persistentContext )
--

caseSensitive = false
includeDirectoryNames = false   -- set to true if you want to include folder names as well

filesByName = {}  -- a dictionary with every file's name as the key and an array of the file objects as its value

function match (f)
    if includeDirectoryNames or not f.isDirectory then
        local name = f.name
        if not caseSensitive then
            name = string.lower(name)
        end
        local dupes = filesByName[name]
        if dupes == nil then
            dupes = {}
            filesByName[name] = dupes
        end
        table.insert (dupes, f)
    end
    return false
end

function searchHasEnded (completed, foundItems)
    if not completed then
        return  -- user has stopped the search
    end
    faf.logInfo("-- Listing duplicates… --")
    for name, files in pairs(filesByName) do
        if #files > 1 then
            faf.logInfo(name .. ":")
            for index, f in pairs(files) do
                if currentSearch.stopped then
                    return
                end
                faf.logInfo("\t" .. f.path)
                currentSearch.addMatch(f)
            end
        end
    end
    faf.logInfo("-- Finished --")
end

Search for EXIF tags in audio or video files

See the instructions in the comments in the file below.

Save as "exiftool.lua":

-- a Lua program, see https://findanyfile.app/scripting.php
--
-- _FAF_Config_ (input=single)
--
-- This script looks for EXIF tags in files.
--
-- It requires that the "exiftool" is installed, see https://exiftool.org/
--  (Install the macOS package - you may have to right-click on the installer and choose
--  "Open" to get around the macOS warning about an unidentified developer).
--
-- To use, enter either just the tag name you seek, or, after a space, its specific value that you seek.
-- Case of the tag name is not significant.
--
-- Example input:
--   Samplerate 44100
-- The above input matches if the the file contains the SampleRate tag and if its value is 44100.
--
-- To learn the name of a tag you seed, you need to run the exiftool command in Terminal.app and add to it a space,
-- "-s", a space, and the path to a file containing the tag (simply drag the file from Finder into the
-- Terminal window to get its path inserted). A complete command line would look like this:
--    exiftool -s /System/Library/CoreServices/Finder.app/Contents/Resources/Invitation.aiff
--

tagName = ""
expectedValue = nil

function trim(s) -- https://stackoverflow.com/a/27455195
   return s:match("^%s*(.-)%s*$")
end

function searchHasStarted ()
    -- Fetch the user's input and extract the tag name and the optional searched value (which may contain spaces)
    local input = trim (currentSearch.input)
    local from, to = input:find (" ")
    from = from and from-1
    tagName = input:sub(1, from)
    if to then
        expectedValue = trim (input:sub(to+1))
    end
    -- faf.logInfo("tag:<"..tagName..">, value:<"..expectedValue..">")
end

function match(f)
    local output = faf.runCommandWithArgs ("/usr/local/bin/exiftool", {"-S", "-n", "-"..tagName, f.path})
    -- faf.logInfo(output)
    local from, to, str = output:lower():find(tagName:lower()..": ([^\n]+)")
    -- faf.logInfo("->"..str)
    if from then
        -- tag exists in file
        if not expectedValue then
            -- we're just looking for files that contain the tag -> this is a match
            return true
        end
        if str == expectedValue:lower() then
            -- the tag's value matches
            return true
        end
    end
end