Sep 07,
2023

Mind the Gap: Replacing Whitespace with Dots in Zsh

Hello folks!

In the shell I spend too much time renaming files to replace spaces with dots or dashes. Without that, I'm stuck quoting filenames or escaping spaces everywhere. Really tedious.

Why not automate it? Zsh has all the machinery to customize the line editor (ZLE). Shell scripting is awfully inconsistent, but after renaming files manually one too many times, I decided to wade into the mess.

My initial thought: when using mv, have the shell parse the second argument and automatically replace all spaces. Type mv 'foo bar' <duplicate><transform> and get 'foo.bar'. Simple, right?

Maximum effort

This approach has quite a bit of hidden complexity — I was thinking the move command has two arguments: the original file name and the new name. I can duplicate the first argument with Zsh's `copy-earlier-word`` function (which copies the previous word to cursor position), then I only need a way to parse that duplicated string and replace spaces, but only if the cursor is right after a quote symbol.

To duplicate arguments efficiently, I suggest to the interested reader that they bind copy-earlier-word to a chord — I chose Alt-, because it's near Alt-. and both perfom a similar function1:

autoload -Uz copy-earlier-word
zle -N copy-earlier-word
bindkey '^[,' copy-earlier-word  # Alt+,

Usage: mv foo → press Alt+, → mv foo foo

Back to the main argument. This is the result of a couple of hours. When the cursor is right after a quoted string, it will parse the string and replace spaces with dots, handling escaped single quotes in the filename. (I don't check for double quotes — who in their right mind would use double quotes in a filename?)

# Function to replace spaces with dots in quoted string at cursor
replace-spaces-with-dots() {
  # Get position right before cursor
  local pos=$((CURSOR - 1))

  # Check if cursor is right after a quote
  if [[ $pos -ge 0 ]]; then
    local char=${BUFFER:$pos:1}

    # Check if it's a quote character
    if [[ $char == "'" || $char == '"' ]]; then
      local quote_char=$char
      local -a segments
      local search_pos=$pos

      # Find all concatenated quoted segments (handles '\'' pattern)
      while [[ $search_pos -ge 0 ]]; do
        local end_pos=$search_pos
        local start_pos=-1

        # Search backward for matching opening quote
        local k=$((search_pos - 1))
        while [[ $k -ge 0 ]]; do
          if [[ ${BUFFER:$k:1} == $quote_char ]]; then
            # Count preceding backslashes
            local backslash_count=0
            local m=$((k - 1))
            while [[ $m -ge 0 && ${BUFFER:$m:1} == '\' ]]; do
              ((backslash_count++))
              ((m--))
            done
            # If even number of backslashes (including 0), quote is not escaped
            if [[ $((backslash_count % 2)) -eq 0 ]]; then
              start_pos=$k
              break
            fi
          fi
          ((k--))
        done

        # If we found a matching quote, record this segment
        if [[ $start_pos -ge 0 ]]; then
          segments=("$start_pos:$end_pos" "${segments[@]}")

          # Check if there's a '\'' pattern before this segment (for single quotes)
          # Pattern is: ' \ ' '  (4 characters: close quote, backslash, quote, open quote)
          if [[ $quote_char == "'" && $start_pos -ge 3 ]]; then
            local pattern=${BUFFER:$((start_pos-3)):4}
            if [[ $pattern == "'\\''" ]]; then
              # Continue searching before the '\'' pattern (before the closing quote)
              search_pos=$((start_pos - 4))
              continue
            fi
          fi
        fi
        break
      done

      # Process all segments (from left to right)
      if [[ ${#segments[@]} -gt 0 ]]; then
        local total_offset=0
        local seg
        for seg in "${segments[@]}"; do
          local seg_start=${seg%:*}
          local seg_end=${seg#*:}

          # Adjust for previous offsets
          seg_start=$((seg_start + total_offset))
          seg_end=$((seg_end + total_offset))

          # Extract content between quotes
          local content_start=$((seg_start + 1))
          local content_length=$((seg_end - seg_start - 1))
          local quoted_content=${BUFFER:$content_start:$content_length}

          # Replace spaces with dots
          local replacement=${quoted_content// /.}
          local diff=$((${#replacement} - content_length))

          # Update buffer
          BUFFER="${BUFFER:0:$content_start}${replacement}${BUFFER:$seg_end}"

          # Track cumulative offset
          total_offset=$((total_offset + diff))
        done

        # Adjust cursor position
        CURSOR=$((CURSOR + total_offset))
      fi
    fi
  fi
}

# Register the function as a ZLE widget
zle -N replace-spaces-with-dots

# Bind it to Alt+/
bindkey '^[/' replace-spaces-with-dots

What's going on here?

This monstrosity operates on Zsh's line editor buffer ($BUFFER). When triggered, it checks if the cursor sits right after a quote character. If so, it searches backward through the buffer to find the matching opening quote, carefully counting backslashes to distinguish escaped quotes from real delimiters.

But wait, there's more! It handles filenames like 'foo'\''s bar' — the shell's awkward way of embedding a single quote inside single-quoted strings. The function detects these '\'' patterns and treats them as concatenated segments, collecting all pieces that belong together.

Once it maps out all the segments, it extracts the content between quotes, replaces spaces with dots using Zsh's ${var// /.} substitution, then reconstructs the buffer. It even tracks offset changes to keep the cursor position correct as string lengths change.

All this complexity for what? So I could type mv 'foo bar' <Alt+,><Alt+/> and have the second argument magically transformed to 'foo.bar'.

Awesome!

Doh!

Only after debugging the horrible mess above did it occur to me that I was approaching it wrong. Why replace the spaces in the argument when I could simply create a function that takes the file name and renames the file without spaces?

To be fair, the first approach has merit — it works during command construction. It could theoretically be used with cp, ln, or any command where one want to transform a duplicated argument on the fly. But honestly? Beyond mv, I can't think of many practical use cases where this inline string manipulation would be needed.

So, here is the sensibly simpler function spc2dot in all its glory:

# Rename files by replacing spaces with dots
spc2dot() {
    if [[ $# -eq 0 ]]; then
        print "Usage: spc2dot <filename>"
        return 1
    fi

    # Join all arguments (handles unquoted filenames)
    local original="$*"

    # Replace spaces with dots
    local newname="${original// /.}"

    if [[ ! -e "$original" ]]; then
        print "Error: File '$original' not found"
        return 1
    fi

    if [[ "$original" == "$newname" ]]; then
        print "No spaces to replace"
        return 0
    fi

    if [[ -e "$newname" ]]; then
        print "Error: '$newname' already exists"
        return 1
    fi

    mv -v "$original" "$newname"
}

The lesson? Sometimes the clever solution is the wrong solution. A simple wrapper function beats 100 lines of buffer manipulation any day. In the process I learned way too much I needed to know about Zsh.


  1. on default Z_sh it's bound to insert-last-word 

zsh