45

I'm on Solaris 10 and I have tested the following with ksh (88), bash (3.00) and zsh (4.2.1).

The following code doesn't yield any result:

function foo {
    echo "Hello World"
}

find somedir -exec foo \;

The find does match several files (as shown by replacing -exec ... with -print), and the function works perfectly when called outside from the find call.

Here's what the man find page say about -exec:

 -exec command       True if the executed command  returns  a
                     zero  value  as  exit status. The end of
                     command must be punctuated by an escaped
                     semicolon (;).  A command argument {} is
                     replaced by the current pathname. If the
                     last  argument  to  -exec  is {} and you
                     specify + rather than the semicolon (;),
                     the command is invoked fewer times, with
                     {} replaced by groups of  pathnames.  If
                     any  invocation of the command returns a
                     non-zero  value  as  exit  status,  find
                     returns a non-zero exit status.

I could probably get away doing something like this:

for f in $(find somedir); do
    foo
done

But I'm afraid of dealing with field separator issues.

Is it possible to call a shell function (defined in the same script, let's not bother with scoping issues) from a find ... -exec ... call?

I tried it with both /usr/bin/find and /bin/find and got the same result.

rahmu
  • 20,023

4 Answers4

42

A function is local to a shell, so you'd need find -exec to spawn a shell and have that function defined in that shell before being able to use it. Something like:

find ... -exec ksh -c '
  function foo {
    echo blan: "$@"
  }
  foo "$@"' ksh {} +

bash allows one to export functions via the environment with export -f, so you can do (in bash):

foo() { ...; }
export -f foo
find ... -exec bash -c 'foo "$@"' bash {} +

ksh88 has typeset -fx to export function (not via the environment), but that can only be used by she-bang less scripts executed by ksh, so not with ksh -c.

Another option is to do:

find ... -exec ksh -c "
  $(typeset -f foo)"'
  foo "$@"' ksh {} +

That is, use typeset -f to dump the definition of the foo function inside the inline script. Note that if foo uses other functions, you'll also need to dump them as well.

Or instead of passing the function definition on the command line (which would be visible in the output of ps -f for instance), you can pass it via an environment variable:

FUNCDEFS=$(typeset -f foo) find ... -exec ksh -c '
  eval "$FUNCDEFS" &&
    unset -v FUNCDEFS &&
    foo "$@"' ksh {} +

(the unset -v FUNCDEFS to avoid polluting the environment of commands started by that foo function if any).

  • 3
    Can you explain why there are two occurrences of ksh or bash in the -exec command? I understand the first, but not the second occurrence. – daniel kullmann Oct 15 '12 at 08:13
  • 7
    @danielkullmann In bash -c 'some-code' a b c, $0 is a, $1 is b..., so if you want $@ to be a, b, c you need to insert something before. Because $0 is also used when displaying error messages, it's a good idea to use the name of the shell, or something that makes sense in that context. – Stéphane Chazelas Oct 15 '12 at 08:34
  • @StéphaneChazelas, thank you for such nice answer. – User9102d82 Jan 20 '19 at 20:58
  • Remember to export -f some_function as well as export some_var. – WesternGun Feb 28 '22 at 15:48
11

Use \0 as a delimiter and read the filenames into the current process from a spawned command, like so:

foo() {
  printf "Hello {%s}\n" "$1"
}

while IFS= read -d '' -r filename; do foo "${filename}" </dev/null done < <(find . -maxdepth 2 -type f -print0)

What's going on here:

  • read -d '' reads until the next \0 byte, so you don't have to worry about strange characters in filenames.
  • similarly, -print0 uses \0 to terminate each generated filename instead of \n.
  • the -r option (raw) prevents backslash escaping in the filename
  • cmd2 < <(cmd1) is the same as cmd1 | cmd2 except that cmd2 is run in the main shell and not a subshell.
  • the call to foo is redirected from /dev/null to ensure it doesn't accidentally read from the pipe.
  • $filename is quoted so the shell doesn't try to split a filename that contains whitespace.
  • IFS= prevents Bash from stripping trailing whitespace from the filenames (thanks DanielSmedegaardBuus for this correction)

Now, read -d and <(...) are in zsh, bash and ksh 93u, but I'm not sure about earlier ksh versions.

ilkkachu
  • 138,973
aecolley
  • 2,177
  • much faster than standard answer but slightly less than using a for loop: time find . -type d -exec bash -c 'dosomething "$0"' {} \; real 0m16.102s time while read -d '' filename; do dosomething "${filename}" </dev/null; done < <(find . -type d -print0) real 0m0.355s time for dir in $(find . -type d); do dosomething $dir; done real 0m0.339s (1465 dirs, on standard hard drive armv7l GNU/Linux synology_armada38x_ds218j) – user1767316 May 31 '21 at 21:37
  • readable benchmark here – user1767316 May 31 '21 at 22:04
  • @user1767316 The problem with a for loop is that it requires generating a complete list of files and then iterating over that list, potentially exhausting shell size limits. Reading from a piped input requires no caching, and won't test any limits. It will work regardless of the amount of filenames being discovered by the find command. – DanielSmedegaardBuus Dec 22 '21 at 12:16
  • 1
    Actually, there's one thing missing here, because even with -print0 and -d '', you'd think that funky file names such as those with leading or trailing whitespace would be good, but no. Bash will trim that whitespace off. To prevent this from happening, you can do while IFS= read -d '' filename; do. What a crazy mistress the shell is. OMG. – DanielSmedegaardBuus Dec 22 '21 at 13:45
10

if you want a child process, spawned from your script, to use a pre-defined shell function you need to export it with export -f <function>

NOTE: export -f is bash specific

since only a shell can run shell functions:

find / -exec /bin/bash -c 'function "$1"' bash {} \;

EDIT: essentially your script should resemble this:

#!/bin/bash
function foo() { do something; }
export -f foo
find somedir -exec /bin/bash -c 'foo "$0"' {} \;
Wildcard
  • 36,499
h3rrmiller
  • 13,235
7

This is not always applicable, but when it is, it's a simple solution. Set the globstar option (set -o globstar in ksh93, shopt -s globstar in bash ≥4; it's on by default in zsh). Then use **/ to match the current directory and its subdirectories recursively.

For example, instead of find . -name '*.txt' -exec somecommand {} \;, you can run

for x in **/*.txt; do somecommand "$x"; done

Instead of find . -type d -exec somecommand {} \;, you can run

for d in **/*/; do somecommand "$d"; done

Instead of find . -newer somefile -exec somecommand {} \;, you can run

for x in **/*; do
  [[ $x -nt somefile ]] || continue
  somecommand "$x"
done

When **/ doesn't work for you (because your shell doesn't have it, or because you need a find option that doesn't have a shell analogue), define the function in the find -exec argument.

  • I cannot seem to find a globstar option on the version of ksh (ksh88) I'm using. – rahmu Oct 16 '12 at 10:04
  • @rahmu Indeed, it's new in ksh93. Doesn't Solaris 10 have a ksh93 somewhere? – Gilles 'SO- stop being evil' Oct 16 '12 at 10:08
  • According to this blog post ksh93 has been introduced in Solaris 11 to replace both the Bourne Shell and ksh88... – rahmu Oct 16 '12 at 13:39
  • @rahmu. Solaris has had ksh93 for a while as dtksh (ksh93 with some X11 extensions), but an old version and possibly part of an optional package where CDE is optional. – Stéphane Chazelas Jan 23 '13 at 06:56
  • It should be noted that recursive globbing differs from find in that it excludes dotfiles and doesn't descend into dotdirs and that it sorts the file list (both of which can be addresses in zsh through globbing qualifiers). Also with **/* as opposed to ./**/*, filenames may start with -. – Stéphane Chazelas Jan 23 '13 at 06:58