cat > ~/lbin/fname.new <<'EOF'
#!/usr/bin/env bash
#
# fname - recursively find files and directories by name.
#
# Uses find -L to follow symlinked roots such as /etc on macOS.
#

set -o pipefail

PROG=${0##*/}

results_file=
errors_file=
sort_file=

cleanup() {
    rm -f "$results_file" "$errors_file" "$sort_file" "${sort_file}.keys"
}

trap cleanup EXIT HUP INT TERM

usage() {
    cat >&2 <<'HELP'
fname - find files or directories by name

USAGE
  fname [options] [directory] [name-pattern]

OPTIONS
  -f        Files only (default)
  -d        Directories only
  -a        Files and directories
  -x        Exact name match
  -p        Match name-pattern against the full path
  -e EXT    Files with extension EXT; a leading dot is optional
  -n        Files with no dot in basename; overrides -e and name-pattern
  -l        Long listing (ls -ld)
  -t        Long listing, newest first; implies -l -s newest
  -j        JSON array of matching paths
  -s MODE   newest | oldest | size | name
  -q        Quiet; only results go to stdout
  -v        Verbose (default)
  -D        Print generated find command
  -h        Show this help

EXAMPLES
  fname /etc hos
  fname -x /etc hosts
  fname -p /etc ssh
  fname -e php src user
  fname -n /etc
  fname -t -e log /var/log
HELP
}

die() {
    printf '%s: error: %s\n' "$PROG" "$1" >&2
    exit 2
}

info() {
    if (( ! quiet )); then
        printf '%s: %s\n' "$PROG" "$*" >&2
    fi
}

show_command() {
    local arg

    printf '%s: debug:' "$PROG" >&2

    for arg in "$@"; do
        printf ' %q' "$arg" >&2
    done

    printf '\n' >&2
}

json_escape() {
    local value=$1

    value=${value//\\/\\\\}
    value=${value//\"/\\\"}
    value=${value//$'\n'/\\n}
    value=${value//$'\r'/\\r}
    value=${value//$'\t'/\\t}

    printf '%s' "$value"
}

get_mtime() {
    if stat -f '%m' "$1" >/dev/null 2>&1; then
        stat -f '%m' "$1"
    else
        stat -c '%Y' "$1"
    fi
}

get_size() {
    if stat -f '%z' "$1" >/dev/null 2>&1; then
        stat -f '%z' "$1"
    else
        stat -c '%s' "$1"
    fi
}

type=f
exact=0
path_match=0
extension=
no_extension=0
long_listing=0
time_listing=0
json_output=0
sort_mode=
quiet=0
debug=0

while getopts ':fdaxpe:nljts:qvDh' opt; do
    case "$opt" in
        f) type=f ;;
        d) type=d ;;
        a) type=a ;;
        x) exact=1 ;;
        p) path_match=1 ;;
        e) extension=$OPTARG ;;
        n) no_extension=1 ;;
        l) long_listing=1 ;;
        t) time_listing=1 ;;
        j) json_output=1 ;;
        s) sort_mode=$OPTARG ;;
        q) quiet=1 ;;
        v) quiet=0 ;;
        D) debug=1 ;;
        h)
            usage
            exit 0
            ;;
        :)
            die "option -$OPTARG requires an argument"
            ;;
        \?)
            die "invalid option: -$OPTARG"
            ;;
    esac
done

shift $((OPTIND - 1))

(( $# <= 2 )) || die 'too many arguments'

if (( json_output && (long_listing || time_listing) )); then
    die '-j cannot be combined with -l or -t'
fi

case "$sort_mode" in
    ''|newest|oldest|size|name)
        ;;
    *)
        die "invalid sort mode: $sort_mode"
        ;;
esac

if (( time_listing )); then
    long_listing=1
    sort_mode=newest
fi

directory=.
pattern=

if (( $# == 1 )); then
    if [[ -d $1 ]]; then
        directory=$1
    else
        pattern=$1
    fi
elif (( $# == 2 )); then
    directory=$1
    pattern=$2
fi

[[ -d $directory ]] || die "not a directory: $directory"

if [[ -n $extension ]]; then
    extension=${extension#.}
    [[ -n $extension ]] || die 'empty extension'
fi

if (( no_extension )); then
    type=f
    extension=
    pattern=
    exact=0
    path_match=0
fi

case "$type" in
    f) description='files' ;;
    d) description='directories' ;;
    a) description='files and directories' ;;
esac

if (( no_extension )); then
    description="$description with no extension"
elif [[ -n $extension && $type != d ]]; then
    description="$description with extension .$extension"
fi

if [[ -n $pattern ]]; then
    if (( exact )); then
        description="$description named \"$pattern\""
    elif (( path_match )); then
        description="$description whose path contains \"$pattern\""
    else
        description="$description containing \"$pattern\""
    fi
fi

info "Searching for $description under \"$directory\"..."

declare -a find_args

# -L follows symlinks, including the macOS /etc -> /private/etc link.
find_args=(find -L "$directory")

case "$type" in
    f)
        find_args+=(-type f)
        ;;
    d)
        find_args+=(-type d)
        ;;
    a)
        find_args+=(\( -type f -o -type d \))
        ;;
esac

if (( no_extension )); then
    find_args+=(! -name '*.*')
else
    if [[ -n $extension && $type != d ]]; then
        find_args+=(-iname "*.$extension")
    fi

    if [[ -n $pattern ]]; then
        if (( path_match )); then
            if (( exact )); then
                find_args+=(-path "$pattern")
            else
                find_args+=(-path "*$pattern*")
            fi
        else
            if (( exact )); then
                find_args+=(-iname "$pattern")
            else
                find_args+=(-iname "*$pattern*")
            fi
        fi
    fi
fi

find_args+=(-print)

if (( debug )); then
    show_command "${find_args[@]}"
fi

results_file=$(mktemp "${TMPDIR:-/tmp}/fname.XXXXXX") ||
    die 'could not create temporary results file'

errors_file=$(mktemp "${TMPDIR:-/tmp}/fname-errors.XXXXXX") ||
    die 'could not create temporary error file'

# A permission error in one child directory makes find return nonzero,
# even when it emitted valid matches elsewhere in the requested tree.
"${find_args[@]}" > "$results_file" 2> "$errors_file"
find_status=$?

if [[ -s $errors_file && $quiet -eq 0 ]]; then
    while IFS= read -r error_line; do
        printf '%s: warning: %s\n' "$PROG" "$error_line" >&2
    done < "$errors_file"
fi

# Only fail if find errored and produced no usable paths at all.
if (( find_status != 0 )) && [[ ! -s $results_file ]]; then
    die 'find could not complete the search and produced no results'
fi

if [[ -n $sort_mode && -s $results_file ]]; then
    sort_file=$(mktemp "${TMPDIR:-/tmp}/fname-sort.XXXXXX") ||
        die 'could not create temporary sort file'

    case "$sort_mode" in
        name)
            LC_ALL=C sort "$results_file" > "$sort_file"
            ;;
        newest|oldest|size)
            while IFS= read -r path; do
                if [[ $sort_mode == size ]]; then
                    value=$(get_size "$path")
                else
                    value=$(get_mtime "$path")
                fi

                printf '%020d\t%s\n' "$value" "$path"
            done < "$results_file" > "${sort_file}.keys"

            if [[ $sort_mode == oldest ]]; then
                LC_ALL=C sort -n "${sort_file}.keys" |
                    sed 's/^[0-9][0-9]*	//' > "$sort_file"
            else
                LC_ALL=C sort -rn "${sort_file}.keys" |
                    sed 's/^[0-9][0-9]*	//' > "$sort_file"
            fi

            rm -f "${sort_file}.keys"
            ;;
    esac

    mv "$sort_file" "$results_file"
    sort_file=
fi

count=$(wc -l < "$results_file" | tr -d '[:space:]')

if (( json_output )); then
    printf '['
    first=1

    while IFS= read -r path; do
        if (( ! first )); then
            printf ','
        fi

        first=0
        printf '"%s"' "$(json_escape "$path")"
    done < "$results_file"

    printf ']\n'

elif (( long_listing )); then
    while IFS= read -r path; do
        ls -ld "$path"
    done < "$results_file"

else
    cat "$results_file"
fi

info "Found $count match(es)."
EOF

chmod 755 ~/lbin/fname.new
