2
\$\begingroup\$

I often find I need to find the earliest or latest file matching a given pattern, and sometimes to choose the lowest or highest string from several possibilities.

The following four functions provide that functionality, and are robust enough to work correctly with all possible inputs. They do require the GNU implementations of sort and head that work with NUL-separated input.

#!/bin/sh

set -eu

first_sorted() {
    ${1+true} return 1
    printf '%s\0' "$@" | sort -z | head -z -n1 | tr '\0' '\n'
}

last_sorted() {
    ${1+true} return 1
    printf '%s\0' "$@" | sort -rz | head -z -n1 | tr '\0' '\n'
}

first_mtime() {
    test -e "${1-}" || return 1
    first=$1; shift
    for i
    do
        test -e "$i" || return 1
        test "$first" -ot "$i" || first=$i
    done
    printf '%s\n' "$first"
}

last_mtime() {
    test -e "${1-}" || return 1
    last=$1; shift
    for i
    do
        test -e "$i" || return 1
        test "$last" -nt "$i" || last=$i
    done
    printf '%s\n' "$last"
}

Unit tests:

# Unit tests of string ops
LC_COLLATE=C
export LC_COLLATE

nl='
'

first_sorted && exit 1
test "twenty${nl}seven" = "$(first_sorted "twenty${nl}seven")"
test "twenty${nl}five" = "$(first_sorted "twenty${nl}seven" "twenty${nl}five")"
test "" = "$(first_sorted '' "twenty${nl}five")"

last_sorted && exit 1
test "twenty${nl}seven" = "$(last_sorted "twenty${nl}seven")"
test "twenty${nl}seven" = "$(last_sorted "twenty${nl}seven" "twenty${nl}five")"
test "twenty${nl}five" = "$(last_sorted '' "twenty${nl}five")"

# Unit tests of file ops
d=$(mktemp -d)
trap 'rm -r "$d"' EXIT
cd "$d"
touch -t 12310900 "Hogmanay${nl}morning"
touch -t 12311200 "Hogmanay${nl}midday"
touch -t 12311500 "Hogmanay${nl}afternoon"
touch -t 12312200 "Hogmanay${nl}evening"

first_mtime && return 1
test "Hogmanay${nl}morning" = "$(first_mtime H*)"

last_mtime && return 1
test "Hogmanay${nl}evening" = "$(last_mtime H*)"
\$\endgroup\$

1 Answer 1

1
\$\begingroup\$

Avoiding duplication

The pairs of functions have duplicated logic in them. I would extract the comparator to avoid it.

Avoiding clever code

I find this a bit too clever:

${1+true} return 1

This alternative will have the same effect and I think looks more familiar and thereby easier to read:

test $# -gt 0 || return 1

Similarly, I find this one a bit clever too, because it combines two things, validating that there is a parameter and that it's a file:

test -e "${1-}" || return 1

I would spell out the two conditions, to separate the two intents:

test $# -gt 0 || return 1
test -e "$1" || return 1

A note on time complexity

I would be remiss to not point out that sorting (a log-linear operation) to find the first item feels a waste, when the task can be done with a linear operation.

If these functions will be used with at most hundreds of files, and not in a loop, then of course it doesn't matter much.

Using descriptive name instead of i

I only use i in counting loops. In the posted code path would be a natural name.

Possible bug in the tests

If the posted test code runs in a function, then it's ok.

If not, then the return statement here is invalid:

first_mtime && return 1

I suspect you meant to write exit 1 instead of return 1 consistently everywhere in the test code.

Missing tests

The mtime functions fail when a parameter is not a valid file, but this case is not covered.

Simple remedy:

first_mtime 'nonexistent' && exit 1
last_mtime 'nonexistent' && exit 1

Alternative implementation

The main benefits of this alternative:

  • Reduced duplicated logic: one common function does the main work
  • Pure shell implementation (no sort, head, tr), linear logic

Note that the functions starting with underline _ are meant as private. That's why I didn't add the usual recommended parameter validation. They assume they are only called correctly as intended.

_first_by_comparator() {
    validator=$1; shift
    comparator=$1; shift

    test $# -gt 0 || return 1
    first=$1; shift
    "$validator" "$first" || return 1

    for item
    do
        "$validator" "$item" || return 1
        test "$first" "$comparator" "$item" || first=$item
    done

    echo "$first"
}

first_sorted() {
    _first_by_comparator true '<' "$@"
}

last_sorted() {
    _first_by_comparator true '>' "$@"
}

_file_exists() {
  test -e "$1"
}

first_mtime() {
    _first_by_comparator _file_exists '-ot' "$@"
}

last_mtime() {
    _first_by_comparator _file_exists '-nt' "$@"
}
\$\endgroup\$
0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.