1

On my Ubuntu system, I commonly need to do a 'Deep Replace' in file contents, the file name and directory name. For example when copying source code as a template for another application.

I've put together a function in ~/.bashrc which works, but fails if the find or replace string has white space in it. I believe this is due to the sed command not accepting white space in the script as is, and the cd to the path variable also fails where the path includes white space.

The arguments are ($1) directory, ($2) find text, ($3) replace text.

Is it possible to improve this script so the arguments can all include white space?

deepreplace() {
if [ -z "$1" ] || [ -z "$2" ] || [ -z "$3" ]
then
    echo "A parameter is missing"
else

    cd $1

    vfind=$2
    vreplace=$3 

    # Replace the string in file

    find . -type f -exec sed -i "s/$vfind/$vreplace/g" {} +

    # Replace the string in file names, then directories


    find . -type d -name "*$vfind*" | while read f; do mv $f $(echo $f | sed "s/$vfind/$vreplace/g"); done
    find . -type f -name "*$vfind*" | while read f; do mv $f $(echo $f | sed "s/$vfind/$vreplace/g"); done
fi

}

terdon
  • 242,166
gbro3n
  • 121
  • 2
    The sed is fine with whitespace, and the cd (which you don't need, by the way) just needs a quoted variable. It's the loop that parses the output of find that needs improvement. What operating system are you using? Do you have GNU find? Does your find have a -print0 or -printf option? – terdon Jun 27 '22 at 21:31
  • 2
    Also, any args with spaces, tabs, shell metachars in them must be quoted (or appropriately escaped) when passed to the script or function. – cas Jun 28 '22 at 05:19
  • @terdon - the cd is to force that the user specifies the directory, to prevent accidental use in the wrong directory. GNU find - is that different to the find command already used in the script? I presume you mean it's the input to mv that is causing problems with spaces? – gbro3n Jun 28 '22 at 08:26
  • The cd isn't needed since find can take $1 as the directory to search in, it doesn't need to use .: find "/some/path" -name foo works fine. As for GNU find, I don't know if it's different :) That's why I asked you to tell us what OS you are using. The default find on Linux systems is usually GNU find, but macOS and other UNIX systems have different implementations. So either please tell us your OS or check man find and see if your find has the -print0 or -printf options. – terdon Jun 28 '22 at 09:49
  • @terdon - OK thanks, understood on point about cd. Yes, I do have print0, printf available (its Ubuntu 22.04). – gbro3n Jun 28 '22 at 11:19
  • 2
    Does this answer your question? When is double-quoting necessary? – AdminBee Jun 28 '22 at 11:52
  • 2
  • 1
    How do you want to handle cases where you have more than one matching directory? For example, if vfind=foo and vreplace=bar and you have a directory called foo which is a subdirectory of another called foo (/whatever/foo/something/foo). Do we need to be able to handle such cases? – terdon Jun 28 '22 at 13:19
  • @terdon, yes that would need to handled. Another good point. At this point I'm thinking this may be better implemented in Python / Node due the complexity. While I'm sure bash can achieve what I'm trying to do here, it feels like it might not be the best tool for the job, at least with my current bash skills. – gbro3n Jun 28 '22 at 13:27
  • 1
    You can do things like this in bash, but yeah, it can get quite complicated fast. Proper scripting languages (bash is a shell, not a programming language although it can function as one) will always be better choices as soon as the complexity rises even a little. – terdon Jun 28 '22 at 13:38
  • Use find's -exec (or -execdir) option instead of piping into a shell while-read loop. If necessary, you can run shell code with sh -c "...shell code here..." find-sh {} + from -exec, or write a standalone shell script and run that from find ... -exec. If you have the perl rename utility installed, you could run something like find . -name "*$vfind*" -exec rename "s/$vfind/$vreplace/g" {} + (this will rename both directories AND regular files). – cas Jun 29 '22 at 02:28

2 Answers2

1

As in comments, I've determined that a pure bash solution was not optimal for my use case.

I've settled for a command that depends on Node.js / npm (since Node is fairly ubiquitous on environments I work on, including non Unix (Windows / Mac).

It depends on two npm packages renamer and replace.

The advantage of using these libraries is that they support regex and other more advanced rename / replace scenarios.

With Node.js / npm installed:

# Globally install the required packages (this prevents npx from trying to install in current directory if they do not already exist)

$ npm install -g renamer $ npm install -g replace

Set variables (dir, find, replace) for directory, find and replace strings, then run change directory, rename and replace, before reverting to original directory.

npx is not strictly required, but have included to prevent collisions with command names on the PATH.

$ dir="./example_dir" && find="example_find" && replace="example_replace" && cd "$dir" && npx renamer --find "$find" --replace "$replace" "**" && npx replace "$find" "$replace" . -r && cd -

The equivalent .bashrc function looks like this

renamereplace() {
# Install global packages if not installed

npm list -g renamer || npm install -g renamer
npm list -g replace || npm install -g replace

# Alias positional arguments

dir="$1"
find="$2"
replace="$3" 

# Change to replace directory

cd "$dir"

# Rename in directory and file names

npx renamer --find "$find" --replace "$replace" "**" 

# Replace in file contents

npx replace "$find" "$replace" . -r 

# Revert current directory

cd -

}

And can be called with:

$ renamereplace "./example_dir" "example_find" "example_replace"

Disclaimer: To avoid data loss, ensure you a) understand this script, b) have checked the directory this command will run in and c) have backed up any important data before running the above.

gbro3n
  • 121
  • 2
    Your original version would have also choked on whitespace, which is why I edited to add the quotes. You should always quote your variables to avoid this. – terdon Jun 29 '22 at 09:59
  • @terdon Thanks for your help and input – gbro3n Jun 29 '22 at 10:19
0

Your function were almost conform to your requirements, just missing a lot of quotes around variables and substitutions, that's why it did not resist to space in filenames.

  • variables : "$1" "$2" "$f" etc.
  • substitutions : "$( ... )"

Here is a first low touch review:

deepreplace() {
  if [ -z "$1" ] || [ -z "$2" ] || [ -z "$3" ]
  then
      echo "A parameter is missing"
  else
    cd "$1" # quote missing
    vfind="$2" # quote missing
    vreplace="$3" # quote missing
    # Replace the string in file contents
    find . -type f -exec sed -i "s/$vfind/$vreplace/g" {} +
    # Replace the string in directory names
    find . -type d -name "*$vfind*" |
    while read d ; do
      mv "$d" "$(echo "$d" | sed "s/$vfind/$vreplace/g")"
    done
    # Replace the string in file names
    find . -type f -name "*$vfind*" |
    while read f; do
      mv "$f" "$(echo "$f" | sed "s/$vfind/$vreplace/g")"
    done
  fi
}

next improvements:

  • instead of $(echo $v | sed s/x/y/g) use the built-in ${v//x/y}
    example: mv "$f" "${f//$vfind/$vreplace}"
  • you have a problem when the path is to be replaced at more than directory level; that's why you first target directories then files (and not files then directories)
  • it's useless to perform find three times

Here is a pure bash new approach:

    find . | while read p ; do
      ddir="$(dirname "${p//$vfind/$vreplace}")"
      obas="$(basename "$p")"
      nbas="${obas//$vfind/$vreplace}"
      # if object rename
      [[ $obas != $nbas ]] && mv "$ddir/$obas" "$ddir/$nbas"
      # if object is file, edit
      [ -f "$ddir/$nbas" ] && sed -i "s/$vfind/$vreplace/g" "$ddir/$nbas"
    done
  • find natural output order make that directories are always renamed before their children, so that it is safe to rename only the basename element
  • all names may safely contain spaces
  • only one find does the job at lower cost
  • you save dependencies to js