Requirement: Write a program (in any language) that counts the number of lines of code in files matching *.sh in the directory tree starting from the directory that the program is executed in, excluding lines that are empty, only contain whitespace, or are just single-line comments (a line which the first character that is not a whitespace character is #). Files will only contain printable ASCII characters.

Output: A single integer representing the total count (can be followed by a newline too).

Here is an expanded solution in Python:

import os, sys

def recurse(dir = './'):
    count = 0

    for file in os.listdir(dir):
        if not os.path.isfile(dir + file):
            count += recurse(dir + file + '/')
        elif file.endswith('.sh'):
            with open(dir + file, 'r') as f:
                for line in f.read().split('\n'):
                    if (not line.strip().startswith('#')) and (not line.strip() == ''):
                        count += 1

    return count

  • \$\begingroup\$ Files will only contain printable ASCII characters. -- can a file contain a /? \$\endgroup\$ – Jonathan Frech Sep 26 '18 at 18:18

12 Answers 12


Powershell 3+, 40 byte

(ls *.sh -r|sls '^\s*(#|$)' -a -n).Count

ls *.sh -r gets a file names from the directory tree. sls (alias for Select-String) gets all strings (-a is shortcut for -AllMatches) that not mathces (-n is alias for -NotMatch) to the pattern '^\s*(#|$)'.

  • \$\begingroup\$ Side note: ().Count was introduced in PowerShell 3; in PowerShell 2 you can use |measure instead although that also tries to give you other measurements not relevant here. \$\endgroup\$ – Neil Sep 26 '18 at 8:18
  • \$\begingroup\$ I'm agree (....|measure).Count. But Powershell 2 is deprecated since the summer 2017. I'm not sure the version 3+ requires an explicit statement. In any case, I've specified the version. Thanks. \$\endgroup\$ – mazzy Sep 26 '18 at 8:27
  • \$\begingroup\$ Ah! I keep forgetting about Select-String! Nice solution. \$\endgroup\$ – AdmBorkBork Sep 26 '18 at 12:39

PowerShell, 47 46 bytes

ls *.sh -r|gc|%{$o+=!($_-match'^\s*(#|$)')};$o

Try it online! (will always return 0 since there aren't any files)

Try it online! (here's a link that populates a dummy file so you can see the process)

-1 byte thanks to Neil

ls is alias for Get-ChildItem, specifying *.sh with the -recurse parameter, then we get-content of files. For each of those lines |%{...}, we accumulate into our $output a one if the Boolean !(...) statement is truthy. Inside the statement is a simple regex -match against the whitespace-only/comment/blank lines. Finally we leave $o on the pipeline.

The implicit Write-Output that happens at program completion adds a trailing newline, but that shouldn't matter in this case because the variable $o itself doesn't have a trailing newline nor does the actual return variable. It's a quirk of the shell, not a quirk of the program. For example, saving this to a script and executing that script in a pipeline will not have a newline.

  • \$\begingroup\$ You can write $-notmatch'^\s*(#|$)' instead !($_-match'^\s*(#|$)'). The length is same. But expression is more clear. \$\endgroup\$ – mazzy Sep 26 '18 at 6:12
  • 1
    \$\begingroup\$ ls *.sh -r|gc| saves a byte. \$\endgroup\$ – Neil Sep 26 '18 at 8:13
  • \$\begingroup\$ Thanks @Neil - nice catch. \$\endgroup\$ – AdmBorkBork Sep 26 '18 at 12:38

Linux Shell, 30 60 bytes

confirmed in dash

cat `find . -name \*.sh`|tr -d " \t"|grep .|grep -v ^#|wc -l
  1. find . -name \*.sh: find files matching the pattern, list with path
  2. cat '': list contents of these files
  3. tr -d " \t": trim tabs and spaces
  4. grep .: remove empty lines
  5. grep -v ^#: remove comments
  6. wc -l: count lines of output
  • \$\begingroup\$ the comment # can be found after spaces. \$\endgroup\$ – mazzy Sep 26 '18 at 9:29
  • 1
    \$\begingroup\$ @mazzy, nope, after tr -d " \t" they can not. \$\endgroup\$ – manatwork Sep 26 '18 at 9:46

Haskell, 211 210 bytes

import System.Directory
f x=listDirectory x>>=fmap sum.mapM(\d->doesFileExist d>>=(#d)).map((x++"/")++)
p#d|p=do c<-readFile d;pure$sum[1|take 3(reverse d)=="hs.",(q:_):_<-map words$lines c,q/='#']|1<2=f d

Oh dear, without shell glob and regex you have to do all the work by yourself. Maybe there's somewhere a module for it. Also, IO code in Haskell requires some overhead to get the types right.

f x =                             -- main function, expects a directory 'x'
    listDirectory x >>=           -- read content of directory (without "." and "..")
              map((x++"/")++)     --   for each entry: prepend current directory 'x' and a slash
          mapM(\d->doesFileExist d>>=(#d))
                                  --   for each entry: call function '#' with
                                  --        first parameter: a boolean, True if it's a regular file, False if it's a directory
                                  --        second parameter: the filename itself
                                  --        '#' returns a list of valid lines for each file
     fmap sum                     --   sum this list

   |p                             -- if 'p' is True (i.e. 'd' is regular file)
     do c<-readFile d;            --   read the content 'c' of file 'd' and 
        pure$sum[1|      ]        --   return the number of lines
              take 3(reverse d)=="hs."
                                  --     (only if the file end with .sh)
              (q:_):_<-map words$lines c,q/='#'
                                  --     where the first word doesn't start with a hash sign
                                  --       (function 'words' strips leading whitespace)
   |1<2                           -- else ('d' is a directory)
       =f d                       --   examine d

f"."                              -- start with current directory

R, 70 64 bytes



The dir function has the recursive flag set.

readLines returns the lines of a file in a vector, which are then flattened with unlist.

  • \$\begingroup\$ I haven't tested it... but can you save bytes by using grepl, adding a ! and getting rid of |1 and of inv=T? \$\endgroup\$ – JayCe Sep 28 '18 at 19:25

Bash + GNU utilities, 55

find . -name \*.sh -exec cat {} +|grep -Evc '^\s*(#|$)'

Try it online!

  • \$\begingroup\$ Does find . -name \*.sh|xargs cat|grep -Evc '^\s*(#|$)' work? \$\endgroup\$ – ovs Sep 25 '18 at 13:29
  • \$\begingroup\$ Or even cat **/*.sh|..., though it needs globstar to be turned on (or a different shell such as zsh, csh/tcsh).. Not sure if you'd need to include it in your bytecount. \$\endgroup\$ – ბიმო Sep 25 '18 at 18:34

Batch + Internal tools, 80 76 Bytes

@for /F %%F IN ('findstr/SV "^\s*# ^\s*$" *.sh^|find/C":"')DO @set/P=%%F<NUL

Uses the builtin findstr to fetch the lines, then counts these line using find /C.

This however produces output with a newline, so we need to convert that into an output without. This is done by using for /F to fetch the output and then use <NUL set /P to output without the trailing newline.


Ruby, 61 bytes

p Dir.glob("**/*.sh").sum{|x|open(x).grep(/^\s*[^#\s]/).size}

Try it online!


Python 2, 149 146 144 140 149 146 116 bytes

import os
print sum(l.strip()[:1]not in'#'for a,b,c in os.walk(".")for n in c for l in open(a+"/"+n)if'.sh'==n[-3:])

Try it online!

Reports 0 on TIO but works locally. Probably not any .sh files in whatever is the current directory.

I think it now works correctly on TIO and fixed a bug for +6, both thanks to @JonathanAllen

-30 with thanks to @ovs

Alternative for 139 bytes but only works on Windows.

import os
os.system('dir/s/b *.sh>f')
print sum(sum((0,1)[x.strip()>''and'#'!=x.strip()[0]]for x in open(l[2:].strip()))for l in open("f"))

Creates a temporary file f to store the results for the dir command.

  • \$\begingroup\$ You can create some files at TIO. Unfortunately that shows up a bug - I believe you'd need to open(os.path.join(a_path_prefix, n)) for it to actually work (although just using + will of course be golfier!)) \$\endgroup\$ – Jonathan Allan Sep 25 '18 at 17:34
  • \$\begingroup\$ (0,1)[l.strip()>''and'#'!=l.strip()[0]] can become all([l.strip()>'','#'!=l.strip()[0:]]) to save 1 byte. \$\endgroup\$ – mypetlion Sep 25 '18 at 17:55
  • \$\begingroup\$ Thanks @JonathanAllan. Will delete for now as I don't have time to fix. \$\endgroup\$ – ElPedro Sep 25 '18 at 18:04
  • \$\begingroup\$ @mypetlion - thanks but temporarily deleted due to a bug spotted by JonathanAllan \$\endgroup\$ – ElPedro Sep 25 '18 at 18:06
  • \$\begingroup\$ That's impressive, nice job. I tried to cut off a byte or two, but couldn't think of any way to do it. \$\endgroup\$ – Aaron Esau Sep 29 '18 at 3:00

Röda + find, 98 bytes

{bufferedExec"find",".","-name","*.sh"|{|x|try readLines x}_|{|x|x~=`\s|#.*`,"";[1]if[#x>0]}_|sum}

Try it online!

Pure Röda, 129 bytes

{["."]|[_]if isFile(_1)else unpull(x)for x in[ls(_1)]|{|x|try readLines x if[x=~`.*\.sh`]}_|{|l|l~=`\s|#.*`,"";[1]if[#l>0]}_|sum}

Try it online!


["."]|                     /* Push "." to the stream */
                           /* For each _1 in the stream: */
[_]if isFile(_1)           /*   If _1 is a file, push it to the output stream */
else                       /*   Else (if _1 is a directory): */
unpull(x)for x in[ls(_1)]| /*     Push each file/dir in _1 to the *input stream* */
{|x|                       /* For each x in the stream: */
  try readLines x          /*   Push lines of x to the stream ignoring errors */
    if[x=~`.*\.sh`]        /*     if x ends in .sh */
{|l|                       /* For each l in the stream: */
  l~=`\s|#.*`,"";          /*   Remove whitespace and comments from l */
  [1]if[#l>0]              /*   Push 1 to the stream if l is not empty */
sum                        /* Sum all numbers in the stream */

PHP, 105 140 174 186 184 172 bytes

function f($d){foreach(glob("$d/*")as$n)$n[strlen($d)+1]^_^A&&$s+=!is_dir($n)?fnmatch("*.sh",$n)?preg_match_all("/^\s*[^#]/m",join(file($n))):0:f($n);return$s;}echo f(".");

skips hidden directories and files (name starts with a dot). Run with -nr.


function f($d)
    # loop through directory entries
        # if name does not start with a dot
        # then increase sum:
            # not a directory?
                # filename matches pattern?
                    # count lines that are neither empty nor begin with a "#"
                    # else 0
                # is a directory: recurse
echo f(".");

I could save two more bytes with fnmatch()*preg_match_all() instead of fnmatch()?preg_match_all():0; and then another one with is_dir()?B:A instead of !is_dir()?A:B; but that could make it insanely slow.

  • 1
    \$\begingroup\$ substr($n,-3)=='.sh' would be shorter than preg_match("/\.sh$/",$n). \$\endgroup\$ – manatwork Sep 26 '18 at 9:06
  • 1
    \$\begingroup\$ Sorry, actually fnmatch('*.sh',$n) is even shorter. \$\endgroup\$ – manatwork Sep 26 '18 at 9:49
  • \$\begingroup\$ @manatwork Wow I didn´t even know that function! \$\endgroup\$ – Titus Sep 26 '18 at 9:54
  • \$\begingroup\$ Yepp, weird thing. I only found out about it after used the similar File.fnmatch in Ruby for years. \$\endgroup\$ – manatwork Sep 26 '18 at 10:06

C (clang), 248 209 bytes

#import <regex.h>
*l,i,j;*k;regex_t r;f(n,s,t){if(fnmatch("*.sh",n,0))return 0;k=fopen(n,"r");for(regcomp(&r,"^\\s*(#|$)",1);i=getline(&l,&i,k)>0;)regexec(&r,l,0,0,0)&&j++;}main(){ftw(".",f,1);printf("%d",j);}

Try it online!

  • \$\begingroup\$ Save one byte char i,j,*l; ==> char*l,i,j; \$\endgroup\$ – cleblanc Sep 26 '18 at 15:37
  • \$\begingroup\$ 189 Bytes here \$\endgroup\$ – cleblanc Sep 26 '18 at 16:15
  • \$\begingroup\$ Why are you not counting your definition and include bytes? \$\endgroup\$ – Jonathan Frech Sep 26 '18 at 17:43
  • \$\begingroup\$ @JonathanFrech, because it's just an argument, it could be any directory \$\endgroup\$ – Logern Sep 26 '18 at 18:02
  • 1
    \$\begingroup\$ Suggest #import<regex.h> instead of #include <regex.h>. Also, can ditch ftw.h and fnmatch.h \$\endgroup\$ – ceilingcat Sep 26 '18 at 22:58

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.