رفتن به محتوا
LoopX

کار با رشته‌ها

توی این درس یاد می‌گیری رشته‌ها را بدون صدا زدن ابزار بیرونی (sed، cut، basename) و فقط با خود Bash پردازش کنی، با ابزاری به اسم گسترش پارامتر (parameter expansion): ${...}. طول (${#var}) و برش (${var:2:3})، حذف پیشوند و پسوند (${file%.txt}، ${path##*/})، جایگزینی (${var/old/new})، تبدیل حروف (${var^^}) و مقدار پیش‌فرض (${var:-default}) را می‌بینی، و آخرش چند کاربرد واقعی مثل trim، ساخت slug و تجزیه‌ی URL. تمرین: پسوند همه‌ی فایل‌های txt را به md تغییر بده.

مسئله: اسم را عوض کن، ولی بدون ابزار

Section titled “مسئله: اسم را عوض کن، ولی بدون ابزار”

اسم یک فایل را داری (/var/log/app.log.gz) و فقط خود اسم (app.log.gz) را می‌خواهی. یا فقط پسوند را. یا اسم بدون پسوند را. یا یک آرگومان را که شاید خالی باشد با مقدار پیش‌فرض عوض کنی. راه‌های بیرونی هست (basename $path، echo $f | sed ...)، ولی هر کدام یک پروسه‌ی جدید می‌سازند و برای هر فایل یک بار اجرا می‌شوند. خود Bash برای همین‌ها عملگرهای داخلی دارد که سریع‌اند (بدون پروسه) و دقیقاً همان‌جا کار می‌کنند، در خود اسکریپت. این‌ها همه از یک شکل می‌آیند: ${متغیر عملگر ...}.

تشبیه: قیچی و چسب روی نوار کاغذ

Section titled “تشبیه: قیچی و چسب روی نوار کاغذ”

رشته مثل یک نوار کاغذ است. ${var#...} از سمت چپ می‌برد (پیشوند)، ${var%...} از سمت راست (پسوند)، ${var/...} یک تکه را پیدا می‌کند و با تکه‌ی دیگری عوض می‌کند، و ${var:-...} یک نوار یدکی می‌چسباند اگر نوار اصلی خالی بود. نوار اصلی دست‌نخورده می‌ماند؛ هر عملگر یک نوار تازه می‌دهد که می‌توانی در متغیر دیگری بریزی.

یک راه یادسپاری: روی صفحه‌کلید، # (شماره ۳) سمت چپِ $ (شماره ۴) است و % (شماره ۵) سمت راستآن: # از چپ می‌برد، % از راست.

عملگرهای گسترش پارامتر

Section titled “عملگرهای گسترش پارامتر”
حذف پیشوند (#) و پسوند (%): علامت تکی «کوتاه‌ترین» تطابق را برمی‌دارد و علامت دوتایی «بلندترین» را. الگوها همان wildcardهای case هستند (* و ? و [...]).
عملگر کار نتیجه برای s="hello world"
${#s} طول رشته 11
${s:6} / ${s:0:5} برش: از خانه‌ی ۶ تا آخر / ۵ کاراکتر از خانه‌ی ۰ world / hello
${s#pat} / ${s##pat} حذف پیشوند (کوتاه‌ترین / بلندترین)
${s%pat} / ${s%%pat} حذف پسوند (کوتاه‌ترین / بلندترین)
${s/old/new} / ${s//old/new} جایگزینی اولین / همه
${s^} ${s^^} ${s,} ${s,,} حرف اول بزرگ / همه بزرگ / حرف اول کوچک / همه کوچک Hello world …
${s:-d} اگر s خالی یا تعریف‌نشده بود، d
${s:=d} مثل بالا، و d را در s هم می‌ریزد
${s:?msg} اگر خالی بود، پیام خطا و توقف
${s:+alt} اگر s مقدار داشت، alt (وگرنه خالی)
Terminal window
s="hello world"
echo "length: ${#s}"
echo "from 6: ${s:6}"
echo "5 chars from 0: ${s:0:5}"
echo "last 5 chars: ${s: -5}"
echo "3 chars from -5: ${s:(-5):3}"
echo "--- دام: فاصله قبل از منفی!"
echo "\${s: -5} = ${s: -5}"
echo "\${s:-5} = ${s:-5} (این «مقدار پیش‌فرض» است، نه برش)"
echo "--- فارسی: کاراکتر یا بایت؟"
fa="سلام"
echo "characters: ${#fa}"
LC_ALL=C bash -c 'fa="سلام"; echo "bytes (C locale): ${#fa}"'
خروجی
length: 11
from 6: world
5 chars from 0: hello
last 5 chars: world
3 chars from -5: wor
--- دام: فاصله قبل از منفی!
${s: -5} = world
${s:-5} = hello world (این «مقدار پیش‌فرض» است، نه برش)
--- فارسی: کاراکتر یا بایت؟
characters: 4
bytes (C locale): 8
  • ${s:offset:length}: خانه‌ها از ۰ شماره‌گذاری می‌شوند. بدون length تا آخر رشته.
  • برای شمارش از انتها، قبل از عدد منفی فاصله بگذار: ${s: -5} (یا ${s:(-5)}). اگر بنویسی ${s:-5}، Bash آن را «مقدار پیش‌فرض» می‌فهمد (:-) و کل رشته را برمی‌گرداند. یکی از رایج‌ترین دام‌ها.
  • ${#s} تعداد کاراکتر را می‌شمارد، نه بایت: «سلام» چهار کاراکتر است ولی در UTF-8 هشت بایت (هر حرف فارسی دو بایت). با LC_ALL=C locale ساده می‌شود و به بایت می‌شمارد. wc -c همیشه بایت می‌شمارد؛ پس این دو ممکن است فرق کنند.

مثال ۲: حذف پیشوند و پسوند

Section titled “مثال ۲: حذف پیشوند و پسوند”

مهم‌ترین عملگرها در کار روزمره. الگو همان wildcard است (درس case):

Terminal window
p="/var/log/app.log.gz"
echo "path: $p"
echo "--- از چپ (پیشوند):"
echo "\${p#*/} (کوتاه): ${p#*/}"
echo "\${p##*/} (بلند): ${p##*/}"
echo "--- از راست (پسوند):"
echo "\${p%.*} (کوتاه): ${p%.*}"
echo "\${p%%.*} (بلند): ${p%%.*}"
echo "--- کاربردهای معروف:"
echo "basename: ${p##*/}"
echo "dirname: ${p%/*}"
echo "extension (last): ${p##*.}"
echo "name without last ext: ${p%.*}"
echo "--- حذف یک پسوند مشخص:"
f="report.txt"
echo "${f%.txt}.md"
خروجی
path: /var/log/app.log.gz
--- از چپ (پیشوند):
${p#*/} (کوتاه): var/log/app.log.gz
${p##*/} (بلند): app.log.gz
--- از راست (پسوند):
${p%.*} (کوتاه): /var/log/app.log
${p%%.*} (بلند): /var/log/app
--- کاربردهای معروف:
basename: app.log.gz
dirname: /var/log
extension (last): gz
name without last ext: /var/log/app.log
--- حذف یک پسوند مشخص:
report.md
  • کوتاه یا بلند؟ ${p#*/} کوتاه‌ترین تکه‌ای را که با */ جور باشد از چپ حذف می‌کند (فقط اولین /)، ولی ${p##*/} بلندترین (تا آخرین /) و فقط اسم فایل می‌ماند. برای پسوند هم همین: %.* فقط .gz را برمی‌دارد و %%.* همه‌ی نقطه‌ها را (.log.gz).
  • چهار الگوی طلایی که در همه‌ی اسکریپت‌ها می‌بینی: ${p##*/} (اسم فایل، مثل basename)، ${p%/*} (پوشه، مثل dirname)، ${f##*.} (پسوند)، ${f%.*} (اسم بدون پسوند).
  • ${f%.txt}.md پسوند .txt را برمی‌دارد و .md می‌چسباند؛ همان کاری که فایل‌ها را عوض می‌کند (تمرین اصلی).

مثال ۳: جایگزینی و تبدیل حروف

Section titled “مثال ۳: جایگزینی و تبدیل حروف”
Terminal window
t="a-b-c-d"
echo "first only: ${t/-/+}"
echo "all: ${t//-/+}"
echo "at start: ${t/#a/X}"
echo "at end: ${t/%d/Z}"
echo "delete all: ${t//-/}"
echo "--- الگو (wildcard) هم می‌پذیرد:"
u="foo.bar.baz"
echo "dots to spaces: ${u//./ }"
echo "everything up to the last dot removed: ${u/*./}"
echo "--- جایگزینی اسلش (باید escape شود):"
dir="/var/log/nginx"
echo "${dir//\//-}"
echo "--- تبدیل حروف (Bash 4+):"
n="hello World"
echo "first upper: ${n^}"
echo "all upper: ${n^^}"
echo "all lower: ${n,,}"
echo "first lower: ${n,}"
خروجی
first only: a+b-c-d
all: a+b+c+d
at start: X-b-c-d
at end: a-b-c-Z
delete all: abcd
--- الگو (wildcard) هم می‌پذیرد:
dots to spaces: foo bar baz
everything up to the last dot removed: baz
--- جایگزینی اسلش (باید escape شود):
-var-log-nginx
--- تبدیل حروف (Bash 4+):
first upper: Hello World
all upper: HELLO WORLD
all lower: hello world
first lower: hello World
  • / یک بار و // همه‌ی تطابق‌ها. /# فقط اگر تطابق اول رشته باشد و /% فقط آخر. جایگزین خالی یعنی حذف (${t//-/}).
  • الگو wildcard است نه regex: ${u/*./} از ابتدا تا آخرین نقطه را (حریصانه) برمی‌دارد و baz می‌ماند.
  • ${n^^} و ${n,,} حروف بزرگ/کوچک (Bash ۴ به بعد) را عوض می‌کنند؛ برای مقایسه‌ی بی‌توجه به حروف خیلی مفید است: [[ ${answer,,} == y* ]].

یک نکته‌ی نسخه‌به‌نسخه: در Bash ۵.۲ نویسه‌ی & در متن جایگزین، «همان چیزی که پیدا شد» است (قابلیت patsub_replacement که پیش‌فرض روشن است)؛ در نسخه‌های قبلی & معمولی بود:

Terminal window
w="a and b"
echo "[${w//and/&}] (& = متن پیداشده)"
echo "[${w//and/\&}] (با \\ یک & معمولی)"
echo "[${w//and/[&]}] (& داخل کروشه)"
shopt -s | grep patsub
خروجی
[a and b] (& = متن پیداشده)
[a & b] (با \ یک & معمولی)
[a [and] b] (& داخل کروشه)
patsub_replacement on
Terminal window
/bin/bash -c 'w="a and b"; echo "[${w//and/&}]"'
/bin/bash -c 's=hello; echo "${s^^}"' 2>&1
خروجی
[a & b]
/bin/bash: ${s^^}: bad substitution

دو خط آخر از bash ۳.۲ مک گرفته شده: & را همان‌طور چاپ کرد (a & b) و ${s^^} را اصلاً نمی‌شناسد (bad substitution). اگر اسکریپتت باید روی مک و لینوکس هر دو بدون تغییر کار کند، به این تفاوت‌ها دقت کن: برای & از \& استفاده کن و برای تبدیل حروف از tr.

مثال ۴: مقدار پیش‌فرض و بررسی

Section titled “مثال ۴: مقدار پیش‌فرض و بررسی”

وقتی متغیر ممکن است تعریف‌نشده یا خالی باشد، پنج عملگر داری که فرقشان را باید بدانی:

Terminal window
unset un
em=""
se="set"
echo "--- \${v:-d} (خالی یا تعریف‌نشده ← d):"
echo "unset=[${un:-d}] empty=[${em:-d}] set=[${se:-d}]"
echo "--- \${v-d} (فقط تعریف‌نشده ← d؛ خالی خالی می‌ماند):"
echo "unset=[${un-d}] empty=[${em-d}] set=[${se-d}]"
echo "--- \${v:+alt} (اگر مقدار داشت ← alt):"
echo "unset=[${un:+alt}] empty=[${em:+alt}] set=[${se:+alt}]"
echo "--- \${v:=d} (پیش‌فرض را در متغیر هم می‌ریزد):"
echo "[${cfg:=assigned}] cfg is now: $cfg"
خروجی
--- ${v:-d} (خالی یا تعریف‌نشده ← d):
unset=[d] empty=[d] set=[set]
--- ${v-d} (فقط تعریف‌نشده ← d؛ خالی خالی می‌ماند):
unset=[d] empty=[] set=[set]
--- ${v:+alt} (اگر مقدار داشت ← alt):
unset=[] empty=[] set=[alt]
--- ${v:=d} (پیش‌فرض را در متغیر هم می‌ریزد):
[assigned] cfg is now: assigned
  • ${v:-d}: مقدار پیش‌فرض (بدون دست‌زدن به v). پرکاربردترین: name="${1:-World}" (درس ورودی و آرگومان‌ها).
  • ${v-d} (بدون دونقطه): فقط وقتی v تعریف‌نشده باشد. با : یعنی «تعریف‌نشده یا خالی».
  • ${v:=d}: مثل :- ولی d را در v هم می‌گذارد.
  • ${v:+alt}: برعکس: اگر v مقدار دارد، alt (مثلاً ${verbose:+--verbose} برای افزودن یک گزینه فقط وقتی روشن است).
  • ${v:?پیام}: اگر v خالی/تعریف‌نشده بود، پیام خطا و اسکریپت متوقف می‌شود. محافظ عالی برای متغیرهای ضروری:
/home/ali/bashlab/must-have.sh
#!/usr/bin/env bash
# must-have.sh: refuses to run without a backup directory
backup_dir="${1:?usage: must-have.sh <backup-dir>}"
echo "backing up to $backup_dir"
Terminal window
cd ~/bashlab
chmod +x must-have.sh
./must-have.sh; echo "کد خروج: $?"
./must-have.sh /backups; echo "کد خروج: $?"
خروجی
./must-have.sh: line 3: 1: usage: must-have.sh <backup-dir>
کد خروج: 1
backing up to /backups
کد خروج: 0

بدون آرگومان، اسکریپت با پیام توقف کرد و خط دوم (echo) هرگز اجرا نشد؛ با آرگومان به‌خوبی ادامه داد. این همان محافظی است که در درس متغیرها برای rm -rf "${dir:?}"/* معرفی کردم.

مثال ۵: تجزیه‌ی رشته‌ها: URL، تنظیمات و trim

Section titled “مثال ۵: تجزیه‌ی رشته‌ها: URL، تنظیمات و trim”

با همین عملگرها می‌شود متن را تجزیه کرد. اول یک URL، تکه به تکه:

Terminal window
url="https://example.com:8443/path/to/page?x=1"
scheme="${url%%://*}"
rest="${url#*://}"
hostport="${rest%%/*}"
path="/${rest#*/}"
host="${hostport%%:*}"
port="${hostport#*:}"
echo "scheme: $scheme"
echo "host: $host"
echo "port: $port"
echo "path: ${path%%\?*}"
echo "query: ${path#*\?}"
خروجی
scheme: https
host: example.com
port: 8443
path: /path/to/page
query: x=1

هر خط یک برش است: %%://* همه‌چیز از :// به بعد را می‌برد (می‌ماند https)، #*:// همه‌چیز تا :// را (می‌ماند example.com:8443/path...) و همین‌طور ادامه. دقت کن ? در الگو یک wildcard (یک کاراکتر) است، پس برای خود علامت سؤال \? نوشتیم.

حالا یک خط تنظیمات KEY = VALUE با فاصله‌های اضافه؛ جدا کردن آسان است ولی فاصله‌ها می‌مانند:

Terminal window
line=" PORT = 8080 "
key="${line%%=*}"
value="${line#*=}"
echo "raw: [$key] [$value]"
trim() {
local s="$1"
s="${s#"${s%%[![:space:]]*}"}"
s="${s%"${s##*[![:space:]]}"}"
printf '%s' "$s"
}
echo "trimmed: [$(trim "$key")] [$(trim "$value")]"
خروجی
raw: [ PORT ] [ 8080 ]
trimmed: [PORT] [8080]

تابع trim فاصله‌های ابتدا و انتها را برمی‌دارد، فقط با گسترش پارامتر. بخوانش از داخل به بیرون: ${s%%[![:space:]]*} همه‌ی فاصله‌های ابتدای رشته را (از اولین غیرفاصله به بعد را می‌برد) به‌عنوان یک تکه می‌دهد و ${s#"..."} همان تکه را از سر s حذف می‌کند (کوتیشن داخلی، یعنی «متن دقیق» نه الگو). نیمه‌ی دوم قرینه‌ی آن برای انتهاست. معروف‌ترین روش trim در Bash است؛ اگر خواستی بدون این پیچیدگی، sed 's/^[[:space:]]*//; s/[[:space:]]*$//' هم هست.

مثال ۶: تغییر اسم فایل‌ها

Section titled “مثال ۶: تغییر اسم فایل‌ها”

حالا ترکیب با حلقه، برای چند کار واقعی. اول حروف بزرگ و کوچک:

Terminal window
mkdir -p ~/bashlab/docs && cd ~/bashlab/docs
touch README.TXT "Meeting Notes.TXT" Budget.Final.TXT
echo "--- قبل:"
ls
for f in *; do
mv -n -- "$f" "${f,,}"
done
echo "--- بعد از حروف کوچک:"
ls
خروجی
--- قبل:
Budget.Final.TXT
Meeting Notes.TXT
README.TXT
--- بعد از حروف کوچک:
budget.final.txt
meeting notes.txt
readme.txt

و برای کل آرایه یک‌جا (بدون حلقه): عملگرها روی هر عضو آرایه اعمال می‌شوند:

Terminal window
files=("a.txt" "b c.txt" "d.txt")
echo "names: ${files[@]}"
echo "no extension: ${files[@]%.txt}"
echo "as .bak: ${files[@]/%.txt/.bak}"
for f in "${files[@]%.txt}"; do
echo "[$f]"
done
خروجی
names: a.txt b c.txt d.txt
no extension: a b c d
as .bak: a.bak b c.bak d.bak
[a]
[b c]
[d]

"${files[@]%.txt}" پسوند را از همه‌ی عضوها برمی‌دارد و (با کوتیشن) هر عضو را سالم نگه می‌دارد؛ حتی b c. این میان‌بر در اسکریپت‌های آرایه‌محور خیلی به کار می‌آید.

مثال ۷: چرا سریع‌تر است؟ بدون پروسه‌ی جدید

Section titled “مثال ۷: چرا سریع‌تر است؟ بدون پروسه‌ی جدید”

basename یک برنامه‌ی جداست؛ برای هر صدازدن، شل باید یک پروسه‌ی فرزند بسازد (fork) و آن را اجرا کند (exec). گسترش پارامتر داخل خود شل است. تفاوت را اندازه بگیریم، برای ۳۰۰۰ مسیر:

Terminal window
TIMEFORMAT='%R seconds'
paths=(/var/log/app.log /home/ali/docs/report.txt /etc/hosts)
echo "basename (3000 processes):"
time {
for ((i = 0; i < 1000; i++)); do
for p in "${paths[@]}"; do
x=$(basename "$p")
done
done
}
echo "\${p##*/} (no process):"
time {
for ((i = 0; i < 1000; i++)); do
for p in "${paths[@]}"; do
x=${p##*/}
done
done
}
خروجی
basename (3000 processes):
1.370 seconds
${p##*/} (no process):
0.013 seconds

نتیجه برای هر کدام یک عدد ثانیه است؛ نسخه‌ی ${p##*/} حدود صد برابر سریع‌تر است (اعداد دقیق به ماشین بستگی دارند، ولی نسبت همین حدود است). برای یک فایل فرقی حس نمی‌شود؛ برای حلقه روی هزار فایل (یا اسکریپتی که هر ثانیه چندین بار اجرا می‌شود) فرق بزرگ است.

گسترش پارامتر یکی از مرحله‌های بازنویسی خط فرمان است (درس متغیرها): بعد از آکولاد و ~، شل ${...} را با نتیجه جایگزین می‌کند. چند نکته:

  • الگو‌ها همان wildcard نام فایل‌اند (*، ?، [...])، نه regex. برای همین ${s/[0-9]+/N} «یک یا چند رقم» نیست: + یک کاراکتر معمولی است. (با shopt -s extglob الگوی +([0-9]) هم داری، درس case.)
  • نتیجه‌ی عملگر یک رشته‌ی جدید است؛ متغیر اصلی عوض نمی‌شود، مگر خودت دوباره در آن بریزی (s="${s%.txt}") یا از := استفاده کنی.
  • عملگرها روی هر عضو آرایه (مثال ۶) و روی آرگومان‌ها ("${@%.txt}") هم اثر می‌کنند.
  • ${!name} (گسترش غیرمستقیم): مقدار متغیری را می‌دهد که اسمش در name است. و ${var@Q} رشته را طوری برمی‌گرداند که بتوانی مستقیم در شل به کار ببری (با کوتیشن امن). این دو برای دیباگ و کدهای پیشرفته به کار می‌آیند:
Terminal window
name=HOME
echo "indirect: \${!name} = ${!name}"
q="it's a b"
echo "quoted for reuse: ${q@Q}"
خروجی
indirect: ${!name} = /home/ali
quoted for reuse: 'it'\''s a b'

حذف و جایگزینی (برای p=/var/log/app.log.gz):

عبارت نتیجه کاربرد
${p##*/} app.log.gz اسم فایل (basename)
${p%/*} /var/log پوشه (dirname)
${p##*.} gz پسوند
${p%.*} /var/log/app.log بدون آخرین پسوند
${p%%.*} /var/log/app بدون همه‌ی پسوندها
${p//\//-} -var-log-app.log.gz همه‌ی / ها را - کن
${p/log/LOG} /var/LOG/app.log.gz اولین log را عوض کن

مقدار پیش‌فرض:

عبارت اگر تعریف‌نشده اگر خالی اگر مقدار دارد
${v:-d} d d مقدار
${v-d} d خالی مقدار
${v:=d} d (و در v می‌ریزد) d (و می‌ریزد) مقدار
${v:+alt} خالی خالی alt
${v:?msg} خطا و توقف خطا و توقف مقدار

مثال ۱: بدون فاصله، «مقدار پیش‌فرض» می‌شود و کل رشته برمی‌گردد.

Terminal window
for f in archive notes.txt .bashrc; do
echo "$f -> ext: [${f##*.}] no-ext: [${f%.*}]"
done
خروجی
archive -> ext: [archive] no-ext: [archive]
notes.txt -> ext: [txt] no-ext: [notes]
.bashrc -> ext: [bashrc] no-ext: []

برای فایل بی‌پسوند (archive)، ${f##*.} کل اسم را برمی‌گرداند (چون الگو جور نشد و چیزی حذف نشد) و ${f%.*} همان اسم. برای .bashrc هم نقطه‌ی اول پسوند حساب می‌شود (پس bashrc را به‌عنوان پسوند می‌دهد و نام خالی می‌ماند). راه‌حل: اگر ممکن است پسوند نباشد، اول بررسی کن: [[ $f == *.* ]].

۳) اسم‌های دارای فاصله و نبود کوتیشن

Section titled “۳) اسم‌های دارای فاصله و نبود کوتیشن”
Terminal window
cd ~/bashlab/docs
touch "my notes.txt"
f="my notes.txt"
echo "--- بدون کوتیشن:"
mv $f ${f%.txt}.md
echo "--- با کوتیشن:"
mv -- "$f" "${f%.txt}.md"
ls
خروجی
--- بدون کوتیشن:
mv: target 'notes.md': No such file or directory
--- با کوتیشن:
budget.final.txt
meeting notes.txt
my notes.md
readme.txt

بدون کوتیشن mv my notes.txt my notes.md شد (چهار آرگومان) و خطا داد. راه‌حل: همیشه "$f" و "${f%.txt}.md".

Terminal window
v="order 12345 shipped"
echo "wildcard +: ${v/[0-9]+/N}"
echo "one digit: ${v/[0-9]/N}"
echo "all digits: ${v//[0-9]/N}"
shopt -s extglob
echo "extglob: ${v/+([0-9])/N}"
خروجی
wildcard +: order 12345 shipped
one digit: order N2345 shipped
all digits: order NNNNN shipped
extglob: order N shipped

+ در wildcard معمولی است و [0-9]+ یعنی «یک رقم و بعدش +» که اینجا جور نشد و رشته عوض نشد. [0-9] یک رقم، // همه‌ی رقم‌ها، و با extglob الگوی «یک یا چند رقم» (+([0-9])) کار می‌کند.

۵) فراموش‌کردن اینکه متغیر عوض نمی‌شود

Section titled “۵) فراموش‌کردن اینکه متغیر عوض نمی‌شود”

${s%.txt} فقط نتیجه را می‌دهد؛ اگر لازم داری در s بماند، s="${s%.txt}". (مگر اینکه فقط می‌خواهی چاپش کنی.)

مثال ۳: bad substitution در bash ۳.۲؛ tr '[:lower:]' '[:upper:]' راه سازگار است.

✎ تمرینآسان

برای مسیر /home/ali/docs/report.final.tar.gz با گسترش پارامتر (نه basename/dirname) این‌ها را چاپ کن: پوشه، اسم فایل، آخرین پسوند، اسم بدون آخرین پسوند، و اسم بدون هیچ پسوند.

دیدن جواب
Terminal window
path="/home/ali/docs/report.final.tar.gz"
name="${path##*/}"
echo "directory: ${path%/*}"
echo "file name: $name"
echo "last extension: ${name##*.}"
echo "without last extension: ${name%.*}"
echo "without any extension: ${name%%.*}"
خروجی
directory: /home/ali/docs
file name: report.final.tar.gz
last extension: gz
without last extension: report.final.tar
without any extension: report
✎ تمرینمتوسط

تمرین اصلی درس: پسوند همه‌ی فایل‌های txt یک پوشه را به md تغییر بده. اسکریپت txt2md.sh پوشه را از آرگومان بگیرد، اسم‌های دارای فاصله را درست مدیریت کند، روی فایل md موجود رونویسی نکند (آن را رد کند و بگوید) و در پایان تعداد تغییرها را چاپ کند. قبل و بعد را نشان بده.

دیدن جواب
mkdir -p ~/bashlab/notes && cd ~/bashlab
touch notes/todo.txt "notes/meeting notes.txt" notes/ideas.txt notes/ideas.md notes/readme.md
cat > txt2md.sh <<'EOF'
#!/usr/bin/env bash
# txt2md.sh: change the extension of every .txt file in a folder to .md
# Usage: ./txt2md.sh <folder>
if [[ $# -ne 1 || ! -d $1 ]]; then
echo "Usage: $0 <folder>" >&2
exit 1
fi
cd "$1" || exit 1
shopt -s nullglob
renamed=0
for f in *.txt; do
new="${f%.txt}.md"
if [[ -e $new ]]; then
echo "skip (exists): $f -> $new"
continue
fi
mv -- "$f" "$new"
echo "renamed: $f -> $new"
((renamed++))
done
echo "done: $renamed file(s) renamed"
EOF
chmod +x txt2md.sh
echo "--- قبل:"; ls notes
echo "--- اجرا:"; ./txt2md.sh notes
echo "--- بعد:"; ls notes
خروجی
--- قبل:
ideas.md
ideas.txt
meeting notes.txt
readme.md
todo.txt
--- اجرا:
skip (exists): ideas.txt -> ideas.md
renamed: meeting notes.txt -> meeting notes.md
renamed: todo.txt -> todo.md
done: 2 file(s) renamed
--- بعد:
ideas.md
ideas.txt
meeting notes.md
readme.md
todo.md

${f%.txt}.md همان الگوی طلایی است. ideas.txt رد شد چون ideas.md از قبل بود (جلوگیری از گم‌شدن محتوا). nullglob پوشه‌ی بدون txt را بی‌خطا رد می‌کند و کوتیشن‌ها از اسم meeting notes.txt محافظت می‌کنند.

✎ تمرینمتوسط

فایل تنظیماتی با خط‌هایی مثل PORT = 8080 داری، همراه با کامنت (# ...) و خط خالی. اسکریپتی بنویس که هر خط تنظیم را بخواند (کامنت و خط خالی را رد کند)، کلید و مقدار را trim کند و به شکل PORT -> 8080 چاپ کند.

دیدن جواب
Terminal window
cd ~/bashlab
cat > app.conf <<'EOF'
# application settings
PORT = 8080
HOST=localhost
# debug mode
DEBUG = true
EOF
cat > read-conf.sh <<'EOF'
#!/usr/bin/env bash
# read-conf.sh: print "key -> value" for every setting in a config file
trim() {
local s="$1"
s="${s#"${s%%[![:space:]]*}"}"
s="${s%"${s##*[![:space:]]}"}"
printf '%s' "$s"
}
while IFS= read -r line; do
line="$(trim "$line")"
[[ -z $line || $line == \#* ]] && continue
key="$(trim "${line%%=*}")"
value="$(trim "${line#*=}")"
echo "$key -> $value"
done < "$1"
EOF
chmod +x read-conf.sh
./read-conf.sh app.conf
خروجی
PORT -> 8080
HOST -> localhost
DEBUG -> true

ترکیب چند درس: while IFS= read -r (حلقه‌ها)، تابع trim (مثال ۵)، [[ ... ]] و continue برای رد کردن، و ${line%%=*} / ${line#*=} برای جدا کردن کلید و مقدار. \#* در [[ ]] یعنی «با # شروع شود» (علامت # باید escape شود تا کامنت حساب نشود).

✎ تمرینسخت

تابع slugify بنویس که یک عنوان را به slug مناسب URL تبدیل کند: فقط حروف کوچک انگلیسی، عدد و -؛ هر فاصله یک -؛ علامت‌های نگارشی حذف؛ چند - پشت‌سرهم یکی؛ بدون - در ابتدا و انتها. فقط با گسترش پارامتر (بدون sed/tr).

دیدن جواب
cd ~/bashlab
cat > slug.sh <<'EOF'
#!/usr/bin/env bash
# slug.sh: turn a title into a URL slug using only parameter expansion
slugify() {
local s="${1,,}" # lowercase
s="${s//[^a-z0-9 -]/}" # drop everything except a-z 0-9 space -
s="${s// /-}" # spaces -> hyphens
while [[ $s == *--* ]]; do # collapse repeated hyphens
s="${s//--/-}"
done
s="${s#-}" # trim leading hyphen
s="${s%-}" # trim trailing hyphen
printf '%s\n' "$s"
}
slugify " Hello, World! Bash & Linux --- 2026 "
slugify "Learn BASH in 10 Minutes"
echo "[$(slugify '***')]"
EOF
bash slug.sh
خروجی
hello-world-bash-linux-2026
learn-bash-in-10-minutes
[]

مرحله‌ها پشت‌سرهم همان عملگرهای درس‌اند: ${1,,} حروف کوچک، ${s//[^a-z0-9 -]/} حذف همه‌چیزهای ناخواسته (کلاس منفی wildcard)، ${s// /-} فاصله به خط‌تیره، حلقه‌ی while برای فشرده‌کردن -- (یک جایگزینی ساده فقط جفت‌های غیرهم‌پوشان را می‌گیرد، پس تا وقتی -- هست تکرار می‌کنیم)، و #-/%- برای حذف خط‌تیره‌ی ابتدا و انتها. برای ورودی *** (که چیزی از آن نمی‌ماند) رشته‌ی خالی برگشت ([] در خروجی).

⚡ بررسی سریع

اگر f=archive.tar.gz باشد، echo ${f%%.*} چه چاپ می‌کند؟

؟ آزمونک
  1. p=/var/log/app.log.gz و echo ${p##*/} چه چاپ می‌کند؟

  2. تفاوت ${v:-d} و ${v-d}؟

  3. s="hello world" و echo ${s:-5} چه می‌دهد؟ و برای پنج کاراکتر آخر چه می‌نویسی؟

  4. for f in *.txt; do mv $f ${f%.txt}.md; done با فایل «my notes.txt» چه می‌شود؟

  5. چرا ${p##*/} از $(basename "$p") سریع‌تر است؟

  6. echo ${#s} برای s="سلام" در locale UTF-8 چه می‌دهد؟

  • گسترش پارامتر ${...} رشته را داخل خود Bash و بدون پروسه‌ی جدید پردازش می‌کند؛ نتیجه یک رشته‌ی تازه است و متغیر عوض نمی‌شود (مگر دوباره بریزی یا :=).
  • طول و برش: ${#s} (کاراکتر)، ${s:2:3}، از انتها ${s: -3} (با فاصله؛ ${s:-3} پیش‌فرض است).
  • حذف: # و ## از چپ، % و %% از راست (تکی کوتاه‌ترین، دوتایی بلندترین). الگوهای طلایی: ${p##*/} اسم، ${p%/*} پوشه، ${f##*.} پسوند، ${f%.*} بی‌پسوند، ${f%.txt}.md تغییر پسوند.
  • جایگزینی: ${s/old/new} (اولی)، ${s//old/new} (همه)، /# و /% (ابتدا/انتها)؛ الگو wildcard است نه regex؛ & در Bash ۵.۲ یعنی متن پیداشده.
  • حروف: ${s^^} ${s,,} ${s^} ${s,} (Bash ۴+؛ در مک ۳.۲ نیست).
  • پیش‌فرض: ${v:-d}، ${v:=d}، ${v:+alt}، ${v:?msg} (توقف با پیام) و نسخه‌ی بدون دونقطه که فقط «تعریف‌نشده» را می‌سنجد.
  • روی هر عضو آرایه ("${files[@]%.txt}") و آرگومان اعمال می‌شود. همیشه کوتیشن: "${f%.txt}.md".
برگه‌ی تقلب این درس
دستورکاری که می‌کند
${#s} ${s:2:3} ${s: -3}طول / برش / سه کاراکتر آخر
${p##*/} ${p%/*}اسم فایل / پوشه (basename و dirname)
${f##*.} ${f%.*} ${f%%.*}پسوند / بی‌آخرین‌پسوند / بی‌همه‌ی‌پسوندها
${f%.txt}.mdتغییر پسوند
${s#pat} ${s##pat} ${s%pat} ${s%%pat}حذف پیشوند/پسوند (کوتاه و بلند)
${s/old/new} ${s//old/new}جایگزینی اولی / همه
${s//[^a-z0-9]/}حذف هر چیز جز حرف و عدد
${s^^} ${s,,} ${s^}بزرگ / کوچک / حرف اول بزرگ (Bash 4+)
${v:-d} ${v:=d} ${v:+alt}پیش‌فرض / پیش‌فرض و ذخیره / فقط اگر مقدار دارد
${v:?پیام}اگر خالی بود، پیام و توقف
"${files[@]%.txt}"حذف پسوند از همه‌ی عضوهای آرایه
${!name} ${s@Q}گسترش غیرمستقیم / رشته‌ی امن برای استفاده‌ی مجدد