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?
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
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!
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.
on default Z_sh it's bound to insert-last-word ↩