1

I'm working on my bash prompt PS1 and I'd like to print the number of files in the current directory.

I write a working code, but,
Is there a way to simplify this (redundant) script?

$(ls -l | grep ^- | wc -l) $(if [ $(ls -l | grep ^- | wc -l) -eq 1 ]; then echo "file"; else echo "files"; fi)

My purpose is to print the number of files in a folder and the IF statement is needed to handle the plural, to manage the two cases:

  • 1 file
  • 2 files

e.g. my ~ folder contains three files, so that script should print the word "files"; my ~/Desktop has only one file, so that script should print "file"

I write down the line of code above, that makes the job, but I think, there is a condensed and smarter way to get it...

  • Thanks you all for the answers! I have chosen the one which is the simplest (at least for me) and above all that fits in a single line. – mattia.b89 Aug 7 '17 at 19:28
1
find . -maxdepth 1 -type f | awk 'END {printf("%d %s%s\n", NR, "file", (NR > 1) ? "s" : "")}'

Explanation:

  1. find . -maxdepth 1 -type f - search all files in the current directory.
  2. awk 'END {printf("%d %s%s\n", NR, "file", (NR > 1) ? "s" : "")}'
    • NR = number of lines received from the find program, which is equal to the number of files.
    • (NR > 1) ? "s" : "") - if the number of lines (files) more than 1, add the s ending to the file word.
3

First you can use a faster command to find the correct number. My tests show that find . -maxdepth 1 -type f -not -name '.*' | wc -l is faster than ls -l | grep '^-' | wc -l (about 4:3). If you also want hidden files you have to use ls -a or leave out the -not -name '.*' part with find.

Next you do not need to count the files twice but instead store the result in a variable and reuse it:

$(count=$(find . -maxdepth 1 -type f -not -name '.*' | wc -l)
  if [[ "$count" -eq 1 ]]; then
    echo "1 file"
  else
    echo "$count files"
  fi)

As you can see you have to use one subshell, otherwise the variable will not be available for the second command. I also used the bash specific [[ ... ]] instead of [ ... ] here. In general it is the better solution.

Lastly you can also use the Bash variable $PROMPT_COMMAND to execute some code just before the $PS1 prompt is printed. That way you don't have to store all the code in the $PS1 variable. The $count variable will be global though. It could look like this:

function count_files () {
  __count=$(find . -maxdepth 1 -type f -not -name '.*' | wc -l)
  if [[ "$__count" -eq 1 ]]; then
    __plural=
  else
    __plural=s
  fi
}
PROMPT_COMMAND=count_files
PS1='other stuff $__count file$__plural more stuff'

Not sure which of these steps you would count as a simplification though :)

EDIT

I just found this SO thread. It can be used to count the files like this

function count_files () {
  local name
  __count=0
  for name in *; do
    [[ -f "$name" ]] && ((__count++))
  done
  # like above ...
}

The speed is around 1:13 compared to the find version. That is manly because it starts less processes (the same reason the find version is faster than the ls version). The tread also has some other solutions with extglob. It all depends on the question if you want files or also links to files or whatnot. Again the code is faster but looks more complex now, so what kind of "simple" are you aiming at?

EDIT 2

Note although in the comments I said that the overhead to start a process might only be small compared to the overhead of sorting when you have to sort many files, for me it starts to show if I run the code in /usr/bin/ which has around 2500 files.

  • In contrast to find and ls -U the shell construct for name in * does sort the results before using them which is not needed here and thus a waste of time. – Hauke Laging Aug 5 '17 at 9:42
  • @HaukeLaging yes in order to count entries their order is irelevant so it is unneeded overhead that shell globing sorts the results. But in my tests (and I guess unless you have many entries to sort) the overhead to start the ls or find process is much bigger than the string sorting done by the shell. – Lucas Aug 5 '17 at 10:12
  • this will cause annoying delays every time the shell prompt is displayed (e.g. on my reasonably fast system it takes 0.4 secs to run count_files in /usr/bin the first time, then 0.1 secs on subsequent runs). i'd suggest making count_files cache the file count unless called with -c (to clear the cache). then wrap cd, pushd, popd, etc so that they call count_files -c after changing directory. eliminates the delay unless changing directory, but disadvantage is that the cached value won't be current. – cas Aug 5 '17 at 10:17
  • actually, i'd suggest not doing it at all - it doesn't provide anywhere near enough value to justify it....but people want strange things. – cas Aug 5 '17 at 10:18
  • 5
    Your find ... | wc -l will get the wrong answer if any filename happens to contain a newline. Instead you can do find ... -printf '.' | wc -c so it will print a dot for each match, then count those dots. – Eric Renouf Aug 5 '17 at 10:46
3

The first way to simplify the script would be to save the number of files into a variable so that you don't have to recompute it.

Other ways to come up with the number of files in the current directory:

n=$(ls -l | grep -c ^-)

Here, the simplification is using grep's -c option to count the matches. There's a risk of miscounting the matches when parsing the output of ls if there is a file named $'some\n-file', which pretends to put a hyphen at the beginning of the line.

n=$(stat -c %F -- * | grep -c 'regular .*file')

The .* in the grep is to account for both "regular file" and "regular empty file" matches. The stat command outputs the type of each file, and the * shell glob avoids the concerns with ls.

If you're familiar with bash, but have heard of zsh and it's powerful filename globbing syntax, you could:

n=$(zsh -c 'a=( *(.) ); echo ${#a}')

Where we create an array named a that is populated with the list of files * filtered by only "plain files" with ..

To print the plural correctly, consider a case statement:

case $n in
(1) printf "1 file";;
(*) printf "$n files";;
esac

A case statement would allow more flexibility if you wanted to print different messages for different numbers of files, for example: "No files!".

More simply, consider a conditional:

[[ $n == 1 ]] && printf "1 file" || printf "$n files"

Finally getting around to steeldriver's suggestion:

n=$( files=(*); dirs=(*/); echo $(( ${#files[@]} - ${#dirs[@]} )))
printf "%d file%s" "$n" "$(test "$n" -ne 1 && echo s)"

This assigns a value to n (eventually) by opening a command substitution; inside that temporary command substitution, I create two arrays: files for everything and dirs for only directories. The last act of the command substitution is to report the difference between the two. The printf then prints the number of files along with the appropriate plural suffix.

This uses whatever setting you have for dotglob; if you want to force the counting (or omission) of dot-files, then you should set or unset dotglob inside the command substitution:

use the current value of dotglob:

n=$( files=(*); dirs=(*/); echo $(( ${#files[@]} - ${#dirs[@]} )))

enforce counting of dot-files, regardless of current dotglob:

n=$(shopt -s dotglob;
    files=(*); dirs=(*/); echo $(( ${#files[@]} - ${#dirs[@]} )))

enforce no counting of dot-files, regardless of current dotglob:

n=$(shopt -u dotglob;
    files=(*); dirs=(*/); echo $(( ${#files[@]} - ${#dirs[@]} )))
  • 1
    I guess if you wanted to keep it all bash, you could do something like files=(*); dirs=(*/); n=$(( ${#files[@]} - ${#dirs[@]} )) maybe? – steeldriver Aug 5 '17 at 13:23
1

I just made this. Instead of ls I used pathname expansion because if your filenames have weird characters (newlines, for example) it will break up the output.

#!/usr/bin/env bash

((n=0))

for i in *; do
  [[ -f "${i}" ]] && ((n++))
done

((n==1)) && { echo "${n} file"; exit; }
echo "${n} files"

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

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