3
\$\begingroup\$

We all face questions that forget to include their code. Sometimes all of it, sometimes only parts, that make the rest of the question unfortunately incomplete and therefore off-topic.

What if it was possible to remedy that behavior with a simple script? What if said script fits in a single comment, at least in a less featured variant?

Goals

A POSIX compatible script that prints all files in the current directory in the following format:

## File: <path-to-file>

     source code line 1
     source code line 2
     source code line 3
     ...

Therefore, no bashism (like variable arrays), GNU extensions to find or similar.

The script must only depend on POSIX variants of sh, find and sed. Shellcheck must pass without errors or warnings.

By default, it should print only C/C++ related files and skip the .git and build folders that are often found in those projects. Both options should be configurable via environment variables.

The complete script

#!/bin/sh
FILE_HEADING="## File: %s\n\n"
IGNORED_DIRS="${IGNORED_DIRS:-build .git}"
EXTENSIONS="${EXTENSIONS:-c cc cpp h hpp}"

PRUNE_RULES=""
for dir in ${IGNORED_DIRS}; do
  PRUNE_RULES="${PRUNE_RULES} -name ${dir} -prune"  
done

RULE_GLUE=""
if [ -n "${PRUNE_RULES}" ]; then
  RULE_GLUE="-o"
fi

NAME_RULES=""
for ext in ${EXTENSIONS}; do
  if [ -z "${NAME_RULES}" ]; then
    NAME_RULES="-name '*.${ext}'"
  else
    NAME_RULES="${NAME_RULES} -o -name '*.${ext}'"
  fi
done

if [ -n "${NAME_RULES}" ]; then
  NAME_RULES="\\( ${NAME_RULES} \\)"
fi

sh << EOF
    find . ${PRUNE_RULES} ${RULE_GLUE} ${NAME_RULES} \
           -exec sh -c "printf '${FILE_HEADING}' {} && sed 's/^/    /' {}" \\;
EOF

Usage:

cd repository
EXTENSIONS=rb IGNORED_DIRS="gem .git" path/to/script.sh

The second sh call is necessary as find doesn't work with multiple-options in a single argument. Every option for find needs to be in its own argument (e.g. find . '-name' '*' is fine, find . '-name *' is not).

Wait, I was promised a command that fits in a single comment!

For a comment, we can make some adjustements. First of all, we don't need to prune, as .git folders don't contain source code (not counting hooks). However multiple spaces in comments will get replaced by a single one, even in code, so you have to use a tabulator instead:

find . -name '*.hs' -exec sh -c 'printf "## File: %s\n\n" {} && sed "s/^/\t/" {}' \;

You should adjust the file type to the users question, of course. If you're sure that they have GNU find installed, it gets a little bit easier:

find . -o -name '*.rb' -printf "## File: %P\n\n" -exec  sed "s/^/\t/" {} \;

That being said, for some languages like Rust or JavaScript, you really should ignore their additional directories, or at least tell the user to check their post.

Those two variants are also eligible for review, although the latter doesn't follow the POSIX and the settings goals anymore

\$\endgroup\$
1
\$\begingroup\$

An alternative to defaulting variables by self-assigning with fallback is to use ${parameter:=word} at the point of first use:

for dir in ${IGNORED_DIRS:=build .git}; do
for ext in ${EXTENSIONS:=c cc cpp h hpp}; do

Actually, since those are the only uses, we could get away with :- expansion, even there.

Also, it makes sense to allow user to specify no ignored dirs and/or no extensions (and we check for that later in the code), so perhaps remove the : from those expansions.


RULE_GLUE can also be simplified:

RULE_GLUE=${PRUNE_RULES:+-o}

Again, it's not necessary to name this, but it might aid clarity. Personally, I'd just add it directly to the end of the PRUNE_RULES:

PRUNE_RULES="${PRUNE_RULES} ${PRUNE_RULES:+-o}

or just ensure that each prune rule contains its own -o as it should:

for dir in ${IGNORED_DIRS}; do
  PRUNE_RULES="${PRUNE_RULES} -name ${dir} -prune -o"  
done

The name rules can be simplified by starting with with the -false predicate (did you ever wonder what it's useful for?):

NAME_RULES=${EXTENSIONS:+-false}
for ext in ${EXTENSIONS}; do
    NAME_RULES="${NAME_RULES} -o -name '*.${ext}'"
done

It's unspecified whether find expands {} when it's a substring of an argument; the safer POSIX way is to pass it as a positional argument and have the child shell expand it:

    -exec sh -c "printf '${FILE_HEADING}' \"\$1\" && sed 's/^/    /' \$1" -- {} ';'

It's probably a good idea to only match plain files (including symlinks to plain files), rather than everything that matches the name rules. Especially if $NAME_RULES is empty, it's quite possible for a directory name to match.

I don't see why we need the explicit sh subshell as the final command (since we expand the "rules" predicates unquoted, so word splitting happens - though we'll need to disable globbing: set -f). There's no good reason not to exec the final command.

Also, we need to be careful if $FILE_HEADING ever contains ': it might be safer to also pass this as a positional parameter, or to export the variable and have it expanded later:

export FILE_HEADING
exec find . ${PRUNE_RULES} ${NAME_RULES} -type f
    -exec sh -c 'printf "${FILE_HEADING}" "$1" && sed "s/^/    /" "$1"' -- {} ';'

I think that rather than -exec sh, it might be simpler to have two -exec predicates:

... -exec printf "${FILE_HEADING}" {} \; -exec sed 's/^/    /' {} \;

(and we no longer need to export $FILE_HEADING).


Modified code

#!/bin/sh

# user-modifiable parameters
FILE_HEADING='## File: %s\n\n'
DEFAULT_IGNORED_DIRS='build .git'
DEFAULT_EXTENSIONS='c cc cpp h hpp'

# end of user variables; code starts here

set -eu  # Usual safety
set -f   # Don't expand pathnames when expanding $NAME_RULES below

for dir in ${IGNORED_DIRS-$DEFAULT_IGNORED_DIRS}; do
    PRUNE_RULES="${PRUNE_RULES-} -name ${dir} -prune -o"  
done

for ext in ${EXTENSIONS-$DEFAULT_EXTENSIONS}; do
    NAME_RULES="${NAME_RULES:--false} -o -name *.${ext}"
done

# We intend word splitting of rules variables
# shellcheck disable=SC2086
exec find -L . ${PRUNE_RULES-} ${NAME_RULES:+'(' $NAME_RULES ')'} -type f \
     -exec printf "${FILE_HEADING}" {} \; \
     -exec sed 's/^/    /' {} \;

Modified one-liner

find . -name \*.sh -exec printf '## File: %s\n\n' {} \; -exec sed 's/^/    /' {} \;
\$\endgroup\$
  • \$\begingroup\$ Thanks for the review. Ad default expansion: I'm aware of that one, but it makes it harder for users to change their defaults. For example, if I'm a Ruby developer, I'd change the EXTENSIONS to rb. Having all variables at the top makes it easier to set them, rather than go on a hunt for default expansions, IMHO. That being said I'm not able to get any output with your (complete) variant, which was the reason I used sh << EOF. Do you have a full script at hand ? \$\endgroup\$ – Zeta Feb 8 at 11:05
  • \$\begingroup\$ Okay, I've tried out my suggestions, and debugged them. See what you think of the replacement code. I'm reasonably certain it's fully POSIX (I referenced sh and find specifications), and I only needed one Shellcheck suppression (oh, how I miss Bash array variables!) \$\endgroup\$ – Toby Speight Feb 8 at 11:56

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.