يوميات مبعسس

🦀 تعلم برمجة مَظرف أوامر (Shell) مصغّر بلغة Rust

بسم الله الرحمن الرحيم

✍️ اليوم درس عملي لمحبي لغة السلطعون Rust، ممن تعلمها ويبحث عن تطبيق ما تعلمه في بناء البرمجيات والمشاريع فيزداد تمكُّنًا منها، فخذ درس اليوم وادرس كل سطر فيه بتمعُّنٍ وأناة، وهو بإذن الله نافع للمبتدي. سنبرمج اليوم مَظرفًا shell، المشتهر بترجمته الحرفية بالصَّدَفَة، صدفة البحر. لن نبرمج مَظرفًا متكاملًا بل مظرف Shell مصغّر يدعم:

  • ✅ ينفذ الأوامر

  • 🔗 يقبل تمرير المخرجات (Piping)

  • 📜 عنده سجل للأوامر (History)

  • 🛑 يعالج الإشارات (Signal Handling)


🐚 ما هو “المظرف” Shell؟

 

المَظرف لغةً من الظَّرف، أي الوعاء، فكل ما يستقرّ غيره فيه فهو ظرف، كقولك: “أدرجت الرسالةَ في الظرف”، وكقولك: “الكتاب ظرفٌ مُلِئ علمًا”، فالرسالة استقرت في الظرف، والكتاب وعاء العلم، أي استقر العلمُ في الكتاب.

أما اصطلاحًا فالمَظرف Shell برنامج وسيط بين المستخدم والنواة (kernel). والمَظرف في أنظمة جنو/لينكس يحيط بالنواة kernel، ونواة النظام طبقة، ثم طبقة ثانية هي طبقة المَظرف shell، ثم طبقة ثالثة هي البرامج. انظر الصورة:

🐚 لماذا سُمّي “Shell” بهذا الاسم؟

📸 وضعت لك صورة الصدفة لأن معنى كلمة Shell في الإنجليزية تعني “الصدفة”.
وأتى المترجم الحاذق ونقلها كما هي “مَظرفًا”، دون ترجمة حرفية.

💡 ولماذا سماها الإنجليز صدفة؟
إلا لأنها كالصدفة تحوي اللؤلؤة، واللؤلؤة هي نواة النظام المستكنة. وهكذا الـ Shell يحوي نواة النظام (Kernel) المستكنة داخله.


🧑‍💻 وأنت أيها المبرمج بلا ريب قد استعملت المَظرف Shell

أكيد! سواءً في:

  • 🪟 Windows: عند فتحك سطر الأوامر (Command Prompt).

  • 🐧 Linux: مع مظارف مثل Bash أو Zsh.

🧠 كل مرة تكتب أمرًا في سطر الأوامر، ينفذه المَظرف بـ 4 خطوات أساسية:


🔄 خطوات عمل المَظرف (Shell Workflow)

  1. 📝 القراءة (Read):
    يتلقَّى المَظرف ما يُلقيه إليه المرء من أوامر، كما يتلقَّى الكاتب ما يُمليه عليه سيِّده.

  2. 🧩 التحليل (Parse):
    يُحلّل ما ألقي إليه من أوامر، فيميِّز بين الأمر ومتعلَّقاته، يعرف ما يُراد منه بالضبط والتحقيق

  3. ⚙️ التنفيذ (Execute):
    وفيها يمضي المَظرف في تنفيذ ما فهمه، فيخاطب نظام التشغيل بلغته:

    • إذا كان برنامجًا: أمر بتشغيله.

    • إذا كان رمازًا (Code): نفّذه.

    • إن كان غير ذلك: أدّى العمل المطلوب.

  4. 🖥️ العرض (Display):
    وفيها تُعرض نتيجة ما نُفِّذ على المستخدِم، كما يعرض الكاتب على سيِّده ما كتبه.


🌀 هذه أربع خطوات تعاد في كل مرة تكتب فيها أمرًا، فبعد عرض المخرجات في الخطوة الرابعة يعود المَظرف إلى خطوة القراءة وينتظر منك كتابة الأمر، فهذه الخطوات الأربع: تلقَّي أوامر المستخدِم، وتَفهُّم ما يقول، ثم تَسلُّم ما فهمه المَظرف إلى نظام التشغيل لينفذه وعرض المخرجات = هي كل ما نحتاج لبرمجة المَظرف.
هذه الخطوات الأربع اسمها:

اختصارًا REPL – حلقة القراءة – التقييم – العرض
Read–Eval–Print Loop

📚 وهي في كتاب الساحر SICP الذي بدأناه، غرضها إنشاء مفسّر أوامر تفاعلي interactive.


🔧 لنبدأ البرمجة!

سنبدأ في تنجيز implementation الخطوة الأولى ثم الثانية.

🧑‍🏭 جهّز بيئتك وابدأ بكتابة:

cargo new minishell
cd minishell

كل ما سيأتي عليك كتابته في الملف main.rs داخل المجلد src.


1️⃣ قراءة المُدخل (Read)

💬 مُحث الأوامر (Command Prompt)

عند فتح سطر الأوامر يطالبك المَظرف بكتابة الأمر، ويظهر لك هذا مثل هذا:

[root@MyServer ~]#

💬 ما هو المحث (Prompt)؟

🪄 Prompt هو ما يظهر للمستخدم ليُخبره أن المَظرف جاهز لتلقّي الأوامر.
فأول ما سنبرمجه هو هذا المحثّ، لأنه بوابة التفاعل مع المستخدم.


🧪 كيف يتم ذلك؟

🖥️ الأنظمة الشبيهة بـ يونكس (Unix-like systems) تتلقّى المدخل من مجرى الدخل stdin.

🦀 في لغة Rust نستخدم مكتبة:

  std::io التي تعالج الدخل والخرج، مثل قراءة ما يكتبه المستخدم ثم عرض ما نشاء له.

🛠️ ماذا سنفعل؟

سنكتب بضعة أسطر تقوم بـ:

  1. ✨ إظهار المحثّ للمستخدم.

  2. 🔁 تشغيله داخل حلقة Loop لا نهائية (infinite loop)
    حتى يبقى المظرف في وضع الاستعداد للأوامر دائمًا. لأننا نريد من المَظرف أن يتلقى الأوامر بلا توقف:

 

// src/main.rs

use std::io::{self, Write};

fn main() {
    loop {
        // Print the prompt
        print!("BassYe> ");

        // Ensure prompt is displayed immediately
        io::stdout().flush().unwrap();

        // Read user input
        let mut input = String::new();
        match io::stdin().read_line(&mut input) {
            Ok(_) => {
                let input = input.trim();

                // For now, just echo the input
                println!("You entered: {}", input);
            }
            Err(error) => {
                eprintln!("Error reading input: {}", error);
                break;
            }
        }
    }
}

لن أشرح لك نحو اللغة syntax، فعليك أن تدرسها حتى تفهم الدرس، سأوضح لك وظيفة ما كتبته. أول سطر:

use std::io::{self, Write};

هذا السطر استدعاء لمكتبة std::io لمعالجة الدخل والخرج، واحتجنا للمسة Trait المسماة Write حتى نشغل ()flush. فيما سيأتي.

print!("BassYe> ");

استعضنا عن تعليمة println! بكتابة print! حتى لا ينطبع سطر جديد بعد عرض المحث BassYe>، لأن التعليمة الأولى (println!) تطبع سطرًا جديدًا أما الثانية (print!) فلا.

io::stdout().flush().unwrap();

استدعينا io::stdout().flush() حتى نضمن عرض المحث من لحظته دون تأخر وقبل قراءة ما سيكتبه المستخدم. وعلة هذا أن في لغة Rust شيء اسمه الصِوان buffering (ترجمة حرفية معناه: التخزين المؤقت) قد لا يعرض النص من لحظته ويتأخر، فأضفنا flush() التي تجبر البرنامج على عرض النص من الصِوان buffering إلى الشاشة مباشرة، ودونها قد لا يظهر للمستخدم المحث BassYe> حتى يضغط إدخال Enter.

مفهوم الكلام، انسخه وشغله وانظر ما الذي سيظهر لك (كل ما تكتبه سيُرجعه لك). شغله بالأمر:

cargo run

🔍 ٢. تحليل المُدخل (Parsing Input)

بعد أن انتهينا من أول خطوة ✅ وهي قراءة المُدخل، والآن حان تحليله، وأقصد:

📦 تجزئة المُدخل إلى جزئين:

  • 🧾 الأمر (Command)

  • 🧩 المُحددات (Arguments)


✳️ مثال:

مثلًا الأمر الشهير ls لعرض الملفات في المجلد الحالي، له محددات arguments (ترجمة الجامعة السورية، وهي جمع لفظة مُحَدِّد argument) مثل -a لعرض الملفات المخفية. كيف سنفعل هذا؟

سنجزئ المُدخل إلى كلمات بحسب المسافات، فأول كلمة ستكون الأمر، وما بعدها هي المحددات arguments. مثلًا إذا كتبنا ls -a ستكون نتيجة التجزئة:

["ls", "-a"]

هذه طريقة بدائية وسنعدل عليها لاحقًا. سنعدل على ما كتبناه سابقًا ونضيف له الخطوة الثانية (تحليل المُدخل):

// src/main.rs

use std::io::{self, Write};

fn main() {
    loop {
        print!("BassYe> ");
        io::stdout().flush().unwrap();

        let mut input = String::new();
        match io::stdin().read_line(&mut input) {
            Ok(_) => {
                let input = input.trim();

                // skip empty input
                if input.is_empty() {
                    continue;
                }

                // Parse the input into command and arguments
                let mut parts = input.split_whitespace();
                let command = parts.next().unwrap();
                let args: Vec<&str> = parts.collect();

                println!("Command: {}", command);
                println!("Arguments: {:?}", args);
            }
            Err(error) => {
                eprintln!("Error reading input: {}", error);
                break;
            }
        }
    }
}

خذه وشغله، ثم اكتب ls -a وانظر كيف سيجزئ المُدخل، ستكون النتيجة:

Command: ls
Arguments: ["-a"]

انتهينا من خطوتين ✅ ولله الحمد، وأوشكنا على الانتهاء، الخطوة الثالثة ستشمل عرض المخرجات، لأن عرضها يسير أيها المعبسس.


⚙️ ٣. التنفيذ (Execution)

🧠 كيف تُنفَّذ الأوامر؟

بعد أن عرفنا كيفية تحليل المُدخل، حان وقت تنفيذ الأوامر التي يكتبها المستخدم.


🧵 كل شيء عملية (Process)

🧑‍💻 اعلم أيها المبعسس أنك عندما تكتب أمرًا وتضغط زر إدخال Enter، يقوم المَظرف بتشغيل عملية جديدة process هذه العملية هي ما كتبته. مثلًا إذا كتبت أمر ls فإن المَظرف يشغل عملية فيها الأمر الذي كتبته. كالتالي:

🔄 المَظرف يشغّل عملية (process) لتنفيذ ls.

📌 والمَظرف بنفسه ليس إلا عملية أيضًا! كل شيء في الحاسوب عملية، ولكل عملية مُعرِّف ID فريد يمزيها عن غيرها:

  • 🔢 PID (معرّف العملية – Process ID) وكل عملية عندما تنتهي من عملها تُرجع قيمة تُبيّن حال انتهائها

  • 📤 exit code: (رمز الإنهاء): أنجحت في عملها أم أخفقت؟

🛣️ المَظرف في الأنظمة الشبيهة بيونكس في تشغيله العملية يسلك مسلكين اثنين:

1. 🌱 التفرّع (Fork)

🧬 المَظرف هاهنا ينشئ العملية بانشطاره إلى نسختين متطابقتين كما تنشطر الخليَّة الحيَّة،أي يتفرع، فتصير عملية المَظرف نسختين:

  • 🧔‍♂️  الأولى هي الأصل (الأب) ولها مُعرِّف فريد.

  • 👶 الثانية هي النسخة (الابن) العملية الجديدة ولها مُعرِّف فريد أيضًا.

📥 وهذا الانشطار يحدث باستدعاء أمرٍ في نظام التشغيل اسمه fork.

2. 🔁 الاستبدال (Exec):

🔄  العملية الجديدة هاهنا لا تنشطر عن عملية المَظرف الأصلية، بل أن العملية هاهنا تأخذ مُعرِّف المَظرف، كأنها تستبدل عملية المَظرف بنفسها، تتحول!
وهذا الاستبدال يحدث باستدعاء أمرٍ في نظام التشغيل اسمه exec، عند استدعائه كأنه يقول للنظام:

“توقف واستبدل المَظرف بالأمر المُدخل، نعم بنفس المُعرّف PID، لكن بمحتوى مُغاير جديد.”

🦀 لا تقلق إن استصعبت الشرح! Rust تتولى كل هذا

فنحن في لغة Rust :

سنستعمل الهيكل struct المسمى Command من مكتبة std::process وهذه التفاصيل كلها مخفية واللغة نفسها ستعالج كل شيء بنفسها.

🧰 الأوامر المُضمَّنة (Built-in Commands)

اعلم أيها المعبسس أن الأوامر قسمين:

  1. 🧷 مضمّنة داخل المَظرف، مثل:

    • cd ➜ والتي تعني تغيير المجلد

    • exit ➜ الخروج من المَظرف

  2. 📦 أوامر خارجية تُمرَّر لنظام التشغيل


❗ وهذان الأمران يجب تنفيذهما built-in داخل المَظرف لا أن يمررها لنظام التشغيل حتى يعالجها هو. أتدري ما علة هذا (أي ما سببه)؟

لأنك عند تنفيذ:

  • الأمر cd إذا سلك المَظرف مسلك التفرع (fork)، فإن هذه العملية التي تفرعت عن الأصل هي من سينفذ الأمر cd ويغير المجلد، لكن بعد انتهاء العملية الفرعية سيبقى → المجلد هو نفسه بلا تغيير، أخفق التغيير!

  • الأمر exit تبع المَظرف مسلك التفرع، فإن العملية الفرعية هي من سينفذ الأمر ثم تموت وتبقى العملية الأصل تعمل ولن يتوقف المَظرف ولن تخرج منه!

💡 لذا، لا بد أن يُعالجهما المَظرف نفسه قبل أي fork/exec.


✍️ تضمين الأمرين cd و exit في مَظرفنا

سنعدل على الجزء الذي بنيناه من مَظرفنا حتى الآن، وسنضيف له أذرع المطابقة match arm، (ذراع السلطعون 🦀!)

التي ستفحص إذا كان المُدخل أحد الأمرين المضمنين أم لا قبل تشغيل أي أمر خارجي:

use std::{
    env,
    error::Error,
    io::{stdin, stdout, Write},
    path::Path,
};

fn main() -> Result<(), Box<dyn Error>> {
    loop {
        print!("BassYe> ");
        stdout().flush()?;

        let mut input = String::new();
        stdin().read_line(&mut input)?;
        let input = input.trim();

        if input.is_empty() {
            continue;
        }

        // Parse the input into command and arguments
        let mut parts = input.split_whitespace();
        let Some(command) = parts.next() else {
            continue;
        };
        let args: Vec<&str> = parts.collect();

        // Handle built-in commands first
        match command {
            "cd" => {
                // Handle cd command - must be done by shell itself
                let new_dir = args.first().unwrap_or(&"/");
                let root = Path::new(new_dir);
                if let Err(e) = env::set_current_dir(root) {
                    eprintln!("cd: {}", e);
                }
            }
            "exit" => {
                // Handle exit command - terminate the shell
                println!("Goodbye!");
                return Ok(());
            }
            // All other commands are external commands
            command => {
                println!(
                    "Executing external command: {} with args: {:?}",
                    command, args
                );
                // We'll implement this in the next step
            }
        }
    }
}

انظر في الأمر المُضمَّن cd كيف استعملنا الوظيفة function المسماة env::set_current_dir حتى نغير المجلد الحالي لعملية المَظرف. ثم استعملنا unwrap_or(&"/") حتى نغير المجلد إلى مجلد الجذر root إذا لم تكن للأمر cd أي مُحددات argument.

قد يقول متسخدم لينكس الآن: لِماذا لم تستعمل ~ حتى تشير إلى مجلد المنزل؟

اعلم أن هذه العلامة ليست ثابتة لكل مَظرف، لكن كتابتنا / أمر ثابت في الأنظمة الشبيهة بيونكس. إذا أردت من هذا المَظرف أن يقبل العلامة ~ فعليك أن تترجمها إلى مجلد المنزل باستعمال dirs::home_dir() من الصندوق crate المسمى dirs وهذا رابطه:

https://crates.io/crates/dirs

🎯 برمجها بنفسك — هذا تمرين اليوم

🔧 الأوامر الخارجية

بعد برمجة الأوامر المضمنة فسهل علينا أن نبرمج الأوامر الخارجية، سنبرمجها بالهيكل Command من std::process. لنشغل أمرًا بالهيكل الآنف فإننا سنستدعي Command::new للنشئ أمرًا، ثم نشغله باستدعاء spawn حتى يعمل في عملية جديدة. لتنفيذ أمر ls -al جرب:

use std::process::Command;

// use Builder pattern to create a new command
let output = Command::new("ls") // create a new command
    .arg("-la") // add argument(s)
    .output() // execute the command and capture output
    .expect("Failed to execute command"); // handle any errors

أنشأنا الأمر ثم أضفنا محدده argument ثم شغلناه وحفظنا الخرج بكتابتنا .output() وهذه تُرجع Result<Output>، وoutput تحتفظ بالخرج والخطأ. سنضم هذا الجزء إلى مَظرفنا الذي كتبناه:

use std::{
    env,
    error::Error,
    io::{stdin, stdout, Write},
    path::Path,
    process::Command,
};

fn main() -> Result<(), Box<dyn Error>> {
    loop {
        print!("> ");
        stdout().flush()?;

        let mut input = String::new();
        stdin().read_line(&mut input)?;
        let input = input.trim();

        if input.is_empty() {
            continue;
        }

        // Parse the input into command and arguments
        let mut parts = input.split_whitespace();
        let Some(command) = parts.next() else {
            continue;
        };
        let args: Vec<&str> = parts.collect();

        // Handle built-in commands first
        match command {
            "cd" => {
                let new_dir = args.first().unwrap_or(&"/");
                let root = Path::new(new_dir);
                if let Err(e) = env::set_current_dir(root) {
                    eprintln!("cd: {}", e);
                }
            }
            "exit" => {
                println!("Goodbye!");
                return Ok(());
            }
            // All other commands are external commands
            command => {
                // Create a Command struct to spawn the external process
                let mut cmd = Command::new(command);
                cmd.args(&args);

                // Spawn the child process and wait for it to complete
                match cmd.spawn() {
                    Ok(mut child) => {
                        // Wait for the child process to finish
                        match child.wait() {
                            Ok(status) => {
                                if !status.success() {
                                    eprintln!("Command '{}' failed with exit code: {:?}",
                                            command, status.code());
                                }
                            }
                            Err(e) => {
                                eprintln!("Failed to wait for command '{}': {}", command, e);
                            }
                        }
                    }
                    Err(e) => {
                        eprintln!("Failed to execute command '{}': {}", command, e);
                    }
                }
            }
        }
    }
}

لاحظ أننا أنشأنا نسخة من Command بكتابة Command::new(command) وقد مررنا له الأمر بين القوسين.

ثم أضفنا المحددات إذا وجدت بكتابة cmd.args(&args)، ثم استدعينا cmd.spawn() حتى ننفذ الأمر في عملية جديدة، وهذا (cmd.spawn()) لا ينتظر العملية حتى تنتهي، والتابع method المسمى spawn يُرجع Result<Child>، وكلمة Child تمثل العملية التي بدأت العمل. وحتى نجعله ينتظر انتهاء العملية كتبنا child.wait() وتُرجع Result<ExitStatus> وهو رمز حال الانتهاء، كيف حاله عند الانتهاء، أنجح أم أخفق؟

إلى هنا انتهينا من الخطوات الأربع وصار عندنا مَظرف مُصغَّر، سنضيف له بعض التحسينات هنا وهناك بسرعة فقد طال المقال، وإن أردت أن تضيف له فأضف وبعسس!


🔗 تمرير الأوامر (Piping)

من أقوى ميزات مَظارف الأنظمة الشبيهة بيونكس ميزة تمرير مخرجات أمر ما إلى أمر آخر، مخرجات أمر صارت مدخلات أمر آخر، ويتم هذا بالأنبوب وعلامته | فهو يربط الأوامر معًا. مثلًا نعرض الملفات بالأمر ls ثم نمررها إلى الأمر grep حتى يبحث فيها:

ls | grep txt

من عيوب مظرفنا الحالي أنه:

⚠️ لا يقبل سوى أمرًا واحدًا في كل مرة

لذا، سنضيف له ميزة تمرير المخرجات (Piping) بشكل فعّال
حتى نتمكن من تنفيذ أوامر متعددة في نفس السطر ويقبل أكثر من أمر واحد كل مرة.

أول ما سنفعله هو تعديل تحليل المدخلات، الخطوة الثانية التي تجزئ المدخلات، وسنجعلها تجزئ المدخلات بحسب علامة الأنبوب | بدلاً من المسافات، وبهذا التعديل سندخل أوامر متعددة في نفس السطر!

سنخزن هذه الأوامر في مُكرّر iterator يُتصفَّح peekable. لماذا يستحسن استعماله؟

لأننا نريد التحقق إذا كانت عندنا المزيد من الأوامر التي يجب معالجتها بعد الأمر الحالي، حتى نقرر إذا كنا سنمرر المخرجات إلى الأمر التالي أم لا.

// Split input on pipe characters to handle command chaining
let mut commands = input.trim().split(" | ").peekable();

بما أننا نتعامل الآن مع أوامر متعددة، فعلينا تتبع مخرجات الأمر السابق لنقلها إلى الأمر التالي، إن وُجد. ونريد تتبع جميع العمليات الفرعية التي نُنشئها حتى نتمكن من انتظار انتهائها لاحقًا.

let mut prev_stdout = None; // This will hold the output of the previous command
let mut children: Vec<Child> = Vec::new(); // This will hold all child processes we spawn

الآن سنتحقق من كل أمر ونحلله إلى اسمه ومتعلقاته، أي محدداته arguments، ثم ننفذه. إذا كان المدخل من الأوامر المضمنة فلن تتغير معاملتنا له، لكن إذا كان المدخل من الأوامر الخارجية سنُعد الدخل القياسي stdin والخرج القياسي stdout بناءً على وجود أمر سابق للتحويل منه أو إذا كان هو الأمر الأخير، وإذا وجد أمر سابق سنحول مخرجاته للأمر التالي وهكذا…

انظر إلى مَظرفنا الآن:

use std::{
    env,
    error::Error,
    io::{stdin, stdout, Write},
    path::Path,
    process::{Child, Command, Stdio},
};

fn main() -> Result<(), Box<dyn Error>> {
    loop {
        print!("> ");
        stdout().flush()?;

        let mut input = String::new();
        stdin().read_line(&mut input)?;
        let input = input.trim();

        if input.is_empty() {
            continue;
        }

        // Split input on pipe characters to handle command chaining
        let mut commands = input.trim().split(" | ").peekable();
        let mut prev_stdout = None;
        let mut children: Vec<Child> = Vec::new();

        // Process each command in the pipeline
        while let Some(command) = commands.next() {
            let mut parts = command.split_whitespace();
            let Some(command) = parts.next() else {
                continue;
            };
            let args = parts;

            match command {
                "cd" => {
                    // Built-in: change directory
                    let new_dir = args.peekable().peek().map_or("/", |x| *x);
                    let root = Path::new(new_dir);
                    if let Err(e) = env::set_current_dir(root) {
                        eprintln!("cd: {}", e);
                    }
                    // Reset prev_stdout since cd doesn't produce output
                    prev_stdout = None;
                }
                "exit" => {
                    println!("Goodbye!");
                    return Ok(());
                }
                command => {
                    // External command: set up stdin/stdout for piping

                    // Input: either from previous command's output or inherit from shell
                    let stdin = match prev_stdout.take() {
                        Some(output) => Stdio::from(output),
                        None => Stdio::inherit(),
                    };

                    // Output: pipe to next command if there is one, otherwise inherit
                    let stdout = if commands.peek().is_some() {
                        Stdio::piped()  // More commands follow, so pipe output
                    } else {
                        Stdio::inherit()  // Last command, output to terminal
                    };

                    // Spawn the command with configured stdin/stdout
                    let child = Command::new(command)
                        .args(args)
                        .stdin(stdin)
                        .stdout(stdout)
                        .spawn();

                    match child {
                        Ok(mut child) => {
                            // Take ownership of stdout for next command in pipeline
                            prev_stdout = child.stdout.take();
                            children.push(child);
                        }
                        Err(e) => {
                            eprintln!("Failed to execute '{}': {}", command, e);
                            break;
                        }
                    }
                }
            }
        }

        // Wait for all child processes to complete
        for mut child in children {
            let _ = child.wait();
        }
    }
}

تهانينا، المَظرف الآن ينفذ أكثر من أمر في نفس السطر، جرب أمر ls | wc -l وتمتع!


📦 صندوق rustyline

لعلك لاحظت إذا كتبت الأوامر في المَظرف أنك لا تستطيع الرجوع بأسهم لوحة المفاتيح للخلف حتى تعدل كلمة إذا أخطأت فيها، ولن تقتدر على استرجاع الأوامر السابقة أيضًا. لحل هذه العيوب سنستعمل الصندوق rustyline لإدارة المدخلات والسجلات، وهذا رابطه:

https://crates.io/crates/rustyline

يمكننا هذا الصندوق من تحرير الأوامر التي نكتبها والاستكمال للأوامر وغيرها من الأشياء، وسأريك ما الذي تستعمله لإضافة سجل الأوامر وإضافة الإشارات Handling signals وأنت افعله بنفسك حتى تجرب بيديك. أضف الصندوق بكتابة الأمر:

cargo add rustyline

📜 سجل الأوامر (Command History)

إضافة سجل الأوامر نافع للمستخدم لأنه يسهل عليهم استرجاع الأوامر السابقة عند الحاجة، وفي المَظرف Bash تسترجع الأمر السابق بالأسهم في لوحة المفاتيح، وتضغط على Ctrl+R لتبحث في سجل الأوامر عن أمر كتبته فيما مضى. وحتى ننجز هذا بصندوق rustyline سنستعمل منه النوع DefaultEditor حتى ننشئ محرر الأسطر، وسنستعمل من الصندوق load_history وsave_history حتى نحمل السجل (أي نجلبه) ونحفظه.

لن أعيد كتابة كل شيء، سأعطيك هو وأنت أضفه وبعسس أيها المعبسس:

// src/main.rs

use rustyline::error::ReadlineError;
use rustyline::DefaultEditor;
use std::{
    env,
    error::Error,
    fs,
    path::Path,
    process::{Child, Command, Stdio},
};

fn main() -> Result<(), Box<dyn Error>> {
    // Create a new line editor instance with default settings
    let mut rl = DefaultEditor::new()?;

    // /tmp is a common place for temporary files and is writable by all users
    let history_path = "/tmp/.minishell_history";

    // Try to load existing history
    match rl.load_history(history_path) {
        Ok(_) => {}
        Err(ReadlineError::Io(_)) => {
            // History file doesn't exist, create it
            fs::File::create(history_path)?;
        }
        Err(err) => {
            eprintln!("minishell: Error loading history: {}", err);
        }
    }

    loop {
        let line = rl.readline("> ");

        match line {
            Ok(line) => {
                let input = line.trim();

                if input.is_empty() {
                    continue;
                }

                // Add command to history
                rl.add_history_entry(input)?;

                // بقية البرنامج يبقى كما هو، بلا تغيير

            }
            Err(e) => {
                eprintln!("minishell: Error: {:?}", e);
            }
        }
    }
}

ادرسه بتمعن، ولاحظ أني لم أحفظ الأوامر الفارغة في الملف إذا ضغط على زر إدخال دون كتابة أي شيء. حفظت الأوامر في ملف /tmp/.minishell_history، وهذا الملف يُحمَّل عند اشتغال المَظرف ويُحفظ عند إغلاقه. وبهذا تستطيع رؤية واسترجاع الأوامر بعد إغلاق المَظرف، وقد اخترت المجلد /tmp لأنه مجلد يكتب فيه الجميع، لكن بعد إطفاء الجهاز كل محتواه سيُحذف.

معالجة الإشارات

الإشارات المقصود بها هي إشارة توقف مثل الضغط على Ctrl+Cإذا ضغطت عليها سيطبع لك المَظرف رسالة ("minishell: Error: Interrupted"). سنضيفها بأذرع المطابقة match arm ونقول للمَظرف إذا ضغط رأيت الإشارة الفلانية افعل كذا:

use rustyline::error::ReadlineError;
// أضف الباقي هنا...

fn main() -> Result<(), Box<dyn Error>> {

    // كما السابق لا تغيير للمكتوب هنا...

    loop {
        match line {
            Ok(line) => {
                // وهنا كما السابق لا تغيير، لهذا لم أكتب ما كتبته سابقًا وأكرره
            }
            Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => {
                // Handle Ctrl-C or Ctrl-D gracefully
                println!("\nExiting minishell...");
                rl.save_history(history_path)?;
                break;
            }
            Err(e) => {
                // كما السابق، لا تغيير لما كتبته...
                eprintln!("minishell: Error: {:?}", e);
            }
        }
    }

    Ok(())
}

أضف في أماكن التعليقات ما سبق وكتبناه، فلم أكرره واكتفيت بالتعليق في المكان حتى أخبرك أن تضيفه بنفسك.

🙏 ختامًا لا تنسونا من دعائكم ❤️

ها قد وصلنا إلى نهاية المقال، وها أنا أخط بقلمي الخطوط الأخيرة لهذا المقال الشائق، وأرجو أنني قد وفّقت في الشرح.

وفي نهاية الأمر، لا يسعني سوى أن أشكرك على حسن قراءتك لهذا المقال، وأني لبشر أصيب وأخطئ، فإن وفقت في طرح الموضوع فمن اللّٰه عز وجل، وإن أخفقت فمن نفسي والشيطان.


أرجو منك تقييم كفاءة المعلومات من أجل تزويدي بالملاحظات والنقد البناء في خانة التعليقات أو عبر حساب الموقع.

 

لا تنسوا الدعاء لكل من ساهم في “عجن وخبز” هذه المقالة،
ولا تنسوا إخوانكم في فلسطين 🇵🇸، فهم بحاجة لدعواتكم ومواقفكم.

والسلام عليكم ورحمة اللّٰه تعالى وبركاته.


بسم الله الرحمن الرحيم

اليوم درس عملي لمحبي لغة السلطعون Rust ممن تعلمها ويبحث عن تطبيق ما تعلمه في بناء البرمجيات والمشاريع فيزداد تمكُّنًا منها، فخذ درس اليوم وادرس كل سطر فيه بتمعُّنٍ وأناة، وهو بإذن الله نافع للمبتدي. سنبرمج اليوم مَظرفًا shell، المشتهر بترجمته الحرفية بالصَّدَفَة، صدفة البحر. لن نبرمج مَظرفًا متكاملًا بل مَظرفًا مُصغَّرًا:

  1. ينفذ الأوامر
  2. ويقبل تمرير المخرجات piping
  3. وعنده سجل للأوامر history
  4. ويعالج الإشارات handle signals

وقبل الشروع في المقصود نُعرِّف المَظرف ما هو؟ وما معنى اسمه؟ وكيف يعمل؟ ثم ننتهي ببرمجته بلغة Rust.

المَظرف Shell

المَظرف لغةً من الظَّرف، أي الوعاء، فكل ما يستقرّ غيره فيه فهو ظرف، كقولك: “أدرجت الرسالةَ في الظرف”، وكقولك: “الكتاب ظرفٌ مُلِئ علمًا”، فالرسالة استقرت في الظرف، والكتاب وعاء العلم، أي استقر العلمُ في الكتاب.

أما اصطلاحًا فالمَظرف Shell برنامج وسيط بين المستخدم والنواة. والمَظرف في جنو/لينكس يحيط بالنواة kernel، ونواة النظام طبقة، ثم طبقة ثانية هي طبقة المَظرف shell، ثم طبقة ثالثة هي البرامج. انظر الصورة:

وضعت لك صورة الصدفة لأن معنى كلمة Shell عند الإنجليز هي الصدفة، وأتى المترجم الحاذق ونقلها كما هي. وما سماها الإنجليز صدفة إلا لأنها كالصدفة فيها لؤلؤة، واللؤلؤة هي نواة النظام المستكنة.

وأنت أيها المبرمج بلا ريب قد استعملت المَظرف Shell في ويندوز عند فتحك سطر الأوامر أو في لينكس مثل المَظرف Bash أو Zsh. فسطر الأوامر ذاك وما يلقى فيه من أوامر ينفذه المَظرف، ونهجه في ذلك في أربع خطوات:

الأولى: القراءة، وفيها يتلقَّى المَظرف ما يُلقيه إليه المرء من أوامر، كما يتلقَّى الكاتب ما يُمليه عليه سيِّده.

الثانية: التحليل، يُحلّل ما ألقي إليه من أوامر، فيميِّز بين الأمر ومتعلَّقاته، يعرف ما يُراد منه بالضبط والتحقيق.

الثالثة: التنفيذ، وفيها يمضي المَظرف في تنفيذ ما فهمه، فيخاطب نظام التشغيل بلغته، ويطلب منه ما يلزم من الأعمال. فإن كان المطلوب تشغيل برنامج أمر بتشغيله، وإن كان المطلوب تنفيذ رماز Code تولَّى تنفيذه، وإن كان المطلوب عملاً آخر قام به على الوجه المطلوب.

الرابعة: العرض، وفيها تُعرض نتيجة ما نُفِّذ على المستخدِم، كما يعرض الكاتب على سيِّده ما كتبه.

هذه أربع خطوات تعاد في كل مرة تكتب فيها أمرًا، فبعد عرض المخرجات في الخطوة الرابعة يعود المَظرف إلى خطوة القراءة وينتظر منك كتابة الأمر، فهذه الخطوات الأربع: تلقَّي أوامر المستخدِم، وتَفهُّم ما يقول، ثم تَسلُّم ما فهمه المَظرف إلى نظام التشغيل لينفذه وعرض المخرجات = هي كل ما نحتاج لبرمجة المَظرف.

هذه الخطوات الأربع اسمها حلقة القراءة والتنفيذ والعرض read–eval–print loop، اختصارًا REPL، وهي في كتاب الساحر SICP الذي بدأناه، غرضها إنشاء مفسّر أوامر تفاعلي interactive.

سنبدأ في تنجيز implementation الخطوة الأولى ثم الثانية!

جهز نفسك بكتابة:

cargo new minishell
cd minishell

كل ما سيأتي عليك كتابته في الملف main.rs داخل المجلد src.


١. قراءة المُدخل


محث الأوامر

عند فتح سطر الأوامر يطالبك المَظرف بكتابة الأمر، ويظهر لك هذا مثل هذا:

[root@MyServer ~]#

هذا اسمه المحث Prompt ويظهر عند تهيئ المَظرف لتلقي الأوامر. فأول ما سنبرمجه هو هذا المحث، حتى نخبر المستخدم بأن يدخل الأمر. الأنظمة الشبيهة بيونكس Unix-like systems تتلقى المدخل من مجرى الدخل stdin. نستعمل في لغة Rust مكتبة std::io التي تعالج الدخل والخرج، مثل قراءة ما يكتبه المستخدم ثم عرض ما نشاء له.

سنكتب بضع سطور تظهر لنا المحث، وسندخله في حلقة Loop لا نهاية لها، لأننا نريد من المَظرف أن يتلقى الأوامر بلا توقف:

// src/main.rs

use std::io::{self, Write};

fn main() {
    loop {
        // Print the prompt
        print!("BassYe> ");

        // Ensure prompt is displayed immediately
        io::stdout().flush().unwrap();

        // Read user input
        let mut input = String::new();
        match io::stdin().read_line(&mut input) {
            Ok(_) => {
                let input = input.trim();

                // For now, just echo the input
                println!("You entered: {}", input);
            }
            Err(error) => {
                eprintln!("Error reading input: {}", error);
                break;
            }
        }
    }
}

لن أشرح لك نحو اللغة syntax، فعليك أن تدرسها حتى تفهم الدرس، سأوضح لك وظيفة ما كتبته. أول سطر:

use std::io::{self, Write};

هذا السطر استدعاء لمكتبة std::io لمعالجة الدخل والخرج، واحتجنا للمسة Trait المسماة Write حتى نشغل ()flush. فيما سيأتي.

print!("BassYe> ");

استعضنا عن تعليمة println! بكتابة print! حتى لا ينطبع سطر جديد بعد عرض المحث BassYe>، لأن التعليمة الأولى (println!) تطبع سطرًا جديدًا أما الثانية (print!) فلا.

io::stdout().flush().unwrap();

استدعينا io::stdout().flush() حتى نضمن عرض المحث من لحظته دون تأخر وقبل قراءة ما سيكتبه المستخدم. وعلة هذا أن في لغة Rust شيء اسمه الصِوان buffering (ترجمة حرفية معناه: التخزين المؤقت) قد لا يعرض النص من لحظته ويتأخر، فأضفنا flush() التي تجبر البرنامج على عرض النص من الصِوان buffering إلى الشاشة مباشرة، ودونها قد لا يظهر للمستخدم المحث BassYe> حتى يضغط إدخال Enter.

مفهوم الكلام، انسخه وشغله وانظر ما الذي سيظهر لك (كل ما تكتبه سيُرجعه لك). شغله بالأمر:

cargo run

٢. تحليل المُدخل

انتهينا من أول خطوة وهي قراءة المُدخل، والآن حان تحليله، وأقصد تجزيئه إلى جزئين:

  1. الأمر command
  2. متعلقاته arguments

مثلًا الأمر ls لعرض الملفات في المجلد الحالي له محددات arguments (ترجمة الجامعة السورية، وهي جمع لفظة مُحَدِّد argument) مثل -a لعرض الملفات المخفية. كيف سنفعل هذا؟

سنجزئ المُدخل إلى كلمات بحسب المسافات، فأول كلمة ستكون الأمر، وما بعدها هي المحددات arguments. مثلًا إذا كتبنا ls -a ستكون نتيجة التجزئة:

["ls", "-a"]

هذه طريقة بدائية وسنعدل عليها لاحقًا. سنعدل على ما كتبناه سابقًا ونضيف له الخطوة الثانية (تحليل المُدخل):

// src/main.rs

use std::io::{self, Write};

fn main() {
    loop {
        print!("BassYe> ");
        io::stdout().flush().unwrap();

        let mut input = String::new();
        match io::stdin().read_line(&mut input) {
            Ok(_) => {
                let input = input.trim();

                // skip empty input
                if input.is_empty() {
                    continue;
                }

                // Parse the input into command and arguments
                let mut parts = input.split_whitespace();
                let command = parts.next().unwrap();
                let args: Vec<&str> = parts.collect();

                println!("Command: {}", command);
                println!("Arguments: {:?}", args);
            }
            Err(error) => {
                eprintln!("Error reading input: {}", error);
                break;
            }
        }
    }
}

خذه وشغله، ثم اكتب ls -a وانظر كيف سيجزئ المُدخل، ستكون النتيجة:

Command: ls
Arguments: ["-a"]

انتهينا من خطوتين ولله الحمد، وأوشكنا على الانتهاء، الخطوة الثالثة ستشمل عرض المخرجات، لأن عرضها يسير أيها المعبسس.


٣. التنفيذ


كيف تُنَفَّذ الأوامر

عرفنا كيف نحلل الأمر المُدخل، والآن كيف ننفذه (نشغله)؟

اعلم أيها المبعسس أنك عند كتابة أمر والضغط على زر إدخال يشغل المَظرف عملية process، هذه العملية هي ما كتبته. مثلًا إذا كتبت أمر ls فإن المَظرف يشغل عملية فيها الأمر الذي كتبته. والمَظرف بنفسه ليس إلا عملية، كل شيء في الحاسوب عملية، ولكل عملية مُعرِّف ID فريد يمزيها عن غيرها اسمه معرف العملية PID. وكل عملية عندما تنتهي من عملها تُرجع قيمة تُبيّن حال انتهائها exit code: أنجحت في عملها أم أخفقت؟

المَظرف في الأنظمة الشبيهة بيونكس في تشغيله العملية يسلك مسلكين اثنين:

الأول: التفرع fork: المَظرف هاهنا ينشئ العملية بانشطاره إلى نسختين متطابقتين كما تنشطر الخليَّة الحيَّة، أي يتفرع، فتصير عملية المَظرف نسختين: الأولى هي الأصل (الأب) ولها مُعرِّف فريد، والثانية هي النسخة (الابن) ولها مُعرِّف فريد أيضًا. وهذا الانشطار يحدث باستدعاء أمرٍ في نظام التشغيل اسمه fork.

الثاني: الاستبدال exec: العملية الجديدة هاهنا لا تنشطر عن عملية المَظرف الأصلية، بل أن العملية هاهنا تأخذ مُعرِّف المَظرف، كأنها تستبدل عملية المَظرف بنفسها، تتحول!
وهذا الاستبدال يحدث باستدعاء أمرٍ في نظام التشغيل اسمه exec، عند استدعائه كأنه يقول للنظام: توقف واستبدل المَظرف بالأمر المُدخل، نعم نفس المُعرِّف لكن المحتوى مُغاير.

لا تقلق إن استصعبت الشرح، فنحن في لغة Rust سنستعمل الهيكل struct المسمى Command من مكتبة std::process وهذه التفاصيل كلها مخفية واللغة نفسها ستعالج كل شيء بنفسها.

الأوامر المُضمَّنة Built-In

اعلم أيها المعبسس أن الأوامر قسمين، الأول أوامر مُضمَّنة في المَظرف نفسه، وأخرى خارجية. الأوامر المُضمَّنة هي cd والتي تعني تغيير المجلد، والأمر exit الذي يخرج من المَظرف، وهذان الأمران لا بد أن يعالجها المَظرف لا أن يمررها لنظام التشغيل حتى يعالجها هو. أتدري ما علة هذا (أي ما سببه)؟

لأنك عند تنفيذ الأمر cd وإذا سلك المَظرف مسلك التفرع fork فإن هذه العملية التي تفرعت عن الأصل هي من سينفذ الأمر cd ويغير المجلد، لكن بعد انتهاء العملية الفرعية سيبقى المجلد هو نفسه بلا تغيير، أخفق التغيير!

وبالمثل الأمر exit إذا اتبع المَظرف مسلك التفرع، فإن العملية الفرعية هي من سينفذ الأمر ثم تموت وتبقى العملية الأصل تعمل ولن يتوقف المَظرف ولن تخرج منه!

تضمين الأمرين في مَظرفنا

سنعدل على الجزء الذي بنيناه من مَظرفنا حتى الآن، وسنضيف له أذرع المطابقة match arm، ذراع السلطعون التي ستفحص أإذا كان المُدخل أحد الأمرين المضمنين أم لا قبل تشغيل أي أمر خارجي:

use std::{
    env,
    error::Error,
    io::{stdin, stdout, Write},
    path::Path,
};

fn main() -> Result<(), Box<dyn Error>> {
    loop {
        print!("BassYe> ");
        stdout().flush()?;

        let mut input = String::new();
        stdin().read_line(&mut input)?;
        let input = input.trim();

        if input.is_empty() {
            continue;
        }

        // Parse the input into command and arguments
        let mut parts = input.split_whitespace();
        let Some(command) = parts.next() else {
            continue;
        };
        let args: Vec<&str> = parts.collect();

        // Handle built-in commands first
        match command {
            "cd" => {
                // Handle cd command - must be done by shell itself
                let new_dir = args.first().unwrap_or(&"/");
                let root = Path::new(new_dir);
                if let Err(e) = env::set_current_dir(root) {
                    eprintln!("cd: {}", e);
                }
            }
            "exit" => {
                // Handle exit command - terminate the shell
                println!("Goodbye!");
                return Ok(());
            }
            // All other commands are external commands
            command => {
                println!(
                    "Executing external command: {} with args: {:?}",
                    command, args
                );
                // We'll implement this in the next step
            }
        }
    }
}

انظر في الأمر المُضمَّن cd كيف استعملنا الوظيفة function المسماة env::set_current_dir حتى نغير المجلد الحالي لعملية المَظرف. ثم استعملنا unwrap_or(&"/") حتى نغير المجلد إلى مجلد الجذر root إذا لم تكن للأمر cd أي مُحددات argument.

قد يقول متسخدم لينكس الآن: لِماذا لم تستعمل ~ حتى تشير إلى مجلد المنزل؟

اعلم أن هذه العلامة ليست ثابتة لكل مَظرف، لكن كتابتنا / أمر ثابت في الأنظمة الشبيهة بيونكس. إذا أردت من هذا المَظرف أن يقبل العلامة ~ فعليك أن تترجمها إلى مجلد المنزل باستعمال dirs::home_dir() من الصندوق crate المسمى dirs وهذا رابطه:

https://crates.io/crates/dirs

برمجها بنفسك، هذا تمرين اليوم.

الأوامر الخارجية

بعد برمجة الأوامر المضمنة فسهل علينا أن نبرمج الأوامر الخارجية، سنبرمجها بالهيكل Command من std::process. لنشغل أمرًا بالهيكل الآنف فإننا سنستدعي Command::new للنشئ أمرًا، ثم نشغله باستدعاء spawn حتى يعمل في عملية جديدة. لتنفيذ أمر ls -al جرب:

use std::process::Command;

// use Builder pattern to create a new command
let output = Command::new("ls") // create a new command
    .arg("-la") // add argument(s)
    .output() // execute the command and capture output
    .expect("Failed to execute command"); // handle any errors

أنشأنا الأمر ثم أضفنا محدده argument ثم شغلناه وحفظنا الخرج بكتابتنا .output() وهذه تُرجع Result<Output>، وoutput تحتفظ بالخرج والخطأ. سنضم هذا الجزء إلى مَظرفنا الذي كتبناه:

use std::{
    env,
    error::Error,
    io::{stdin, stdout, Write},
    path::Path,
    process::Command,
};

fn main() -> Result<(), Box<dyn Error>> {
    loop {
        print!("> ");
        stdout().flush()?;

        let mut input = String::new();
        stdin().read_line(&mut input)?;
        let input = input.trim();

        if input.is_empty() {
            continue;
        }

        // Parse the input into command and arguments
        let mut parts = input.split_whitespace();
        let Some(command) = parts.next() else {
            continue;
        };
        let args: Vec<&str> = parts.collect();

        // Handle built-in commands first
        match command {
            "cd" => {
                let new_dir = args.first().unwrap_or(&"/");
                let root = Path::new(new_dir);
                if let Err(e) = env::set_current_dir(root) {
                    eprintln!("cd: {}", e);
                }
            }
            "exit" => {
                println!("Goodbye!");
                return Ok(());
            }
            // All other commands are external commands
            command => {
                // Create a Command struct to spawn the external process
                let mut cmd = Command::new(command);
                cmd.args(&args);

                // Spawn the child process and wait for it to complete
                match cmd.spawn() {
                    Ok(mut child) => {
                        // Wait for the child process to finish
                        match child.wait() {
                            Ok(status) => {
                                if !status.success() {
                                    eprintln!("Command '{}' failed with exit code: {:?}",
                                            command, status.code());
                                }
                            }
                            Err(e) => {
                                eprintln!("Failed to wait for command '{}': {}", command, e);
                            }
                        }
                    }
                    Err(e) => {
                        eprintln!("Failed to execute command '{}': {}", command, e);
                    }
                }
            }
        }
    }
}

لاحظ أننا أنشأنا نسخة من Command بكتابة Command::new(command) وقد مررنا له الأمر بين القوسين.

ثم أضفنا المحددات إذا وجدت بكتابة cmd.args(&args)، ثم استدعينا cmd.spawn() حتى ننفذ الأمر في عملية جديدة، وهذا (cmd.spawn()) لا ينتظر العملية حتى تنتهي، والتابع method المسمى spawn يُرجع Result<Child>، وكلمة Child تمثل العملية التي بدأت العمل. وحتى نجعله ينتظر انتهاء العملية كتبنا child.wait() وتُرجع Result<ExitStatus> وهو رمز حال الانتهاء، كيف حاله عند الانتهاء، أنجح أم أخفق؟

إلى هنا انتهينا من الخطوات الأربع وصار عندنا مَظرف مُصغَّر، سنضيف له بعض التحسينات هنا وهناك بسرعة فقد طال المقال، وإن أردت أن تضيف له فأضف وبعسس!


تمرير الأوامر Piping

من أقوى ميزات مَظارف الأنظمة الشبيهة بيونكس ميزة تمرير مخرجات أمر ما إلى أمر آخر، مخرجات أمر صارت مدخلات أمر آخر، ويتم هذا بالأنبوب وعلامته | فهو يربط الأوامر معًا. مثلًا نعرض الملفات بالأمر ls ثم نمررها إلى الأمر grep حتى يبحث فيها:

ls | grep txt

من عيوب مَظرفنا أنه يتلقى أمرًا واحدًا في كل مرة، لذا سنضيف له ميزة تمرير المخرجات حتى يقبل أكثر من أمر واحد كل مرة.

أول ما سنفعله هو تعديل تحليل المدخلات، الخطوة الثانية التي تجزئ المدخلات، وسنجعلها تجزئ المدخلات بحسب علامة الأنبوب | بدلاً من المسافات، وبهذا التعديل سندخل أوامر متعددة في نفس السطر!

سنخزن هذه الأوامر في مُكرّر iterator يُتصفَّح peekable. لماذا يستحسن استعماله؟

لأننا نريد التحقق إذا كانت عندنا المزيد من الأوامر التي يجب معالجتها بعد الأمر الحالي، حتى نقرر إذا كنا سنمرر المخرجات إلى الأمر التالي أم لا.

// Split input on pipe characters to handle command chaining
let mut commands = input.trim().split(" | ").peekable();

بما أننا نتعامل الآن مع أوامر متعددة، فعلينا تتبع مخرجات الأمر السابق لنقلها إلى الأمر التالي، إن وُجد. ونريد تتبع جميع العمليات الفرعية التي نُنشئها حتى نتمكن من انتظار انتهائها لاحقًا.

let mut prev_stdout = None; // This will hold the output of the previous command
let mut children: Vec<Child> = Vec::new(); // This will hold all child processes we spawn

الآن سنتحقق من كل أمر ونحلله إلى اسمه ومتعلقاته، أي محدداته arguments، ثم ننفذه. إذا كان المدخل من الأوامر المضمنة فلن تتغير معاملتنا له، لكن إذا كان المدخل من الأوامر الخارجية سنُعد الدخل القياسي stdin والخرج القياسي stdout بناءً على وجود أمر سابق للتحويل منه أو إذا كان هو الأمر الأخير، وإذا وجد أمر سابق سنحول مخرجاته للأمر التالي وهكذا…

انظر إلى مَظرفنا الآن:

use std::{
    env,
    error::Error,
    io::{stdin, stdout, Write},
    path::Path,
    process::{Child, Command, Stdio},
};

fn main() -> Result<(), Box<dyn Error>> {
    loop {
        print!("> ");
        stdout().flush()?;

        let mut input = String::new();
        stdin().read_line(&mut input)?;
        let input = input.trim();

        if input.is_empty() {
            continue;
        }

        // Split input on pipe characters to handle command chaining
        let mut commands = input.trim().split(" | ").peekable();
        let mut prev_stdout = None;
        let mut children: Vec<Child> = Vec::new();

        // Process each command in the pipeline
        while let Some(command) = commands.next() {
            let mut parts = command.split_whitespace();
            let Some(command) = parts.next() else {
                continue;
            };
            let args = parts;

            match command {
                "cd" => {
                    // Built-in: change directory
                    let new_dir = args.peekable().peek().map_or("/", |x| *x);
                    let root = Path::new(new_dir);
                    if let Err(e) = env::set_current_dir(root) {
                        eprintln!("cd: {}", e);
                    }
                    // Reset prev_stdout since cd doesn't produce output
                    prev_stdout = None;
                }
                "exit" => {
                    println!("Goodbye!");
                    return Ok(());
                }
                command => {
                    // External command: set up stdin/stdout for piping

                    // Input: either from previous command's output or inherit from shell
                    let stdin = match prev_stdout.take() {
                        Some(output) => Stdio::from(output),
                        None => Stdio::inherit(),
                    };

                    // Output: pipe to next command if there is one, otherwise inherit
                    let stdout = if commands.peek().is_some() {
                        Stdio::piped()  // More commands follow, so pipe output
                    } else {
                        Stdio::inherit()  // Last command, output to terminal
                    };

                    // Spawn the command with configured stdin/stdout
                    let child = Command::new(command)
                        .args(args)
                        .stdin(stdin)
                        .stdout(stdout)
                        .spawn();

                    match child {
                        Ok(mut child) => {
                            // Take ownership of stdout for next command in pipeline
                            prev_stdout = child.stdout.take();
                            children.push(child);
                        }
                        Err(e) => {
                            eprintln!("Failed to execute '{}': {}", command, e);
                            break;
                        }
                    }
                }
            }
        }

        // Wait for all child processes to complete
        for mut child in children {
            let _ = child.wait();
        }
    }
}

تهانينا، المَظرف الآن ينفذ أكثر من أمر في نفس السطر، جرب أمر ls | wc -l وتمتع!


صندوق rustyline

لعلك لاحظت إذا كتبت الأوامر في المَظرف أنك لا تستطيع الرجوع بأسهم لوحة المفاتيح للخلف حتى تعدل كلمة إذا أخطأت فيها، ولن تقتدر على استرجاع الأوامر السابقة أيضًا. لحل هذه العيوب سنستعمل الصندوق rustyline لإدارة المدخلات والسجلات، وهذا رابطه:

https://crates.io/crates/rustyline

يمكننا هذا الصندوق من تحرير الأوامر التي نكتبها والاستكمال للأوامر وغيرها من الأشياء، وسأريك ما الذي تستعمله لإضافة سجل الأوامر وإضافة الإشارات Handling signals وأنت افعله بنفسك حتى تجرب بيديك. أضف الصندوق بكتابة الأمر:

cargo add rustyline

سجل الأوامر

إضافة سجل الأوامر نافع للمستخدم لأنه يسهل عليهم استرجاع الأوامر السابقة عند الحاجة، وفي المَظرف Bash تسترجع الأمر السابق بالأسهم في لوحة المفاتيح، وتضغط على Ctrl+R لتبحث في سجل الأوامر عن أمر كتبته فيما مضى. وحتى ننجز هذا بصندوق rustyline سنستعمل منه النوع DefaultEditor حتى ننشئ محرر الأسطر، وسنستعمل من الصندوق load_history وsave_history حتى نحمل السجل (أي نجلبه) ونحفظه.

لن أعيد كتابة كل شيء، سأعطيك هو وأنت أضفه وبعسس أيها المعبسس:

// src/main.rs

use rustyline::error::ReadlineError;
use rustyline::DefaultEditor;
use std::{
    env,
    error::Error,
    fs,
    path::Path,
    process::{Child, Command, Stdio},
};

fn main() -> Result<(), Box<dyn Error>> {
    // Create a new line editor instance with default settings
    let mut rl = DefaultEditor::new()?;

    // /tmp is a common place for temporary files and is writable by all users
    let history_path = "/tmp/.minishell_history";

    // Try to load existing history
    match rl.load_history(history_path) {
        Ok(_) => {}
        Err(ReadlineError::Io(_)) => {
            // History file doesn't exist, create it
            fs::File::create(history_path)?;
        }
        Err(err) => {
            eprintln!("minishell: Error loading history: {}", err);
        }
    }

    loop {
        let line = rl.readline("> ");

        match line {
            Ok(line) => {
                let input = line.trim();

                if input.is_empty() {
                    continue;
                }

                // Add command to history
                rl.add_history_entry(input)?;

                // بقية البرنامج يبقى كما هو، بلا تغيير

            }
            Err(e) => {
                eprintln!("minishell: Error: {:?}", e);
            }
        }
    }
}

ادرسه بتمعن، ولاحظ أني لم أحفظ الأوامر الفارغة في الملف إذا ضغط على زر إدخال دون كتابة أي شيء. حفظت الأوامر في ملف /tmp/.minishell_history، وهذا الملف يُحمَّل عند اشتغال المَظرف ويُحفظ عند إغلاقه. وبهذا تستطيع رؤية واسترجاع الأوامر بعد إغلاق المَظرف، وقد اخترت المجلد /tmp لأنه مجلد يكتب فيه الجميع، لكن بعد إطفاء الجهاز كل محتواه سيُحذف.

معالجة الإشارات

الإشارات المقصود بها هي إشارة توقف مثل الضغط على Ctrl+Cإذا ضغطت عليها سيطبع لك المَظرف رسالة ("minishell: Error: Interrupted"). سنضيفها بأذرع المطابقة match arm ونقول للمَظرف إذا ضغط رأيت الإشارة الفلانية افعل كذا:

use rustyline::error::ReadlineError;
// أضف الباقي هنا...

fn main() -> Result<(), Box<dyn Error>> {

    // كما السابق لا تغيير للمكتوب هنا...

    loop {
        match line {
            Ok(line) => {
                // وهنا كما السابق لا تغيير، لهذا لم أكتب ما كتبته سابقًا وأكرره
            }
            Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => {
                // Handle Ctrl-C or Ctrl-D gracefully
                println!("\nExiting minishell...");
                rl.save_history(history_path)?;
                break;
            }
            Err(e) => {
                // كما السابق، لا تغيير لما كتبته...
                eprintln!("minishell: Error: {:?}", e);
            }
        }
    }

    Ok(())
}

أضف في أماكن التعليقات ما سبق وكتبناه، فلم أكرره واكتفيت بالتعليق في المكان حتى أخبرك أن تضيفه بنفسك.

ختامًا لا تنسونا من دعائكم

ها قد وصلنا إلى نهاية المقال، وها أنا أخط بقلمي الخطوط الأخيرة لهذا المقال الشائق، وأرجو أنني قد وفّقت في الشرح.

وفي نهاية الأمر، لا يسعني سوى أن أشكرك على حسن قراءتك لهذا المقال، وأني لبشر أصيب وأخطئ، فإن وفقت في طرح الموضوع فمن اللّٰه عز وجل، وإن أخفقت فمن نفسي والشيطان.


أرجو منك تقييم كفاءة المعلومات من أجل تزويدي بالملاحظات والنقد البناء في خانة التعليقات أو عبر حساب الموقع. 

🧠🎮 سيرفرنا الموقر
ما بين فك الشفرات، الكريدت، ودهاليز الألعاب الرقمية… نحن نبعسس لخدمتك!

وجهتك الشاملة لفتح الشفرات، شحن الكريدت، إزالة الحسابات، وخدمات الألعاب الرقمية – لأنك تستحق الأفضل!
منصة موحدة – بخدمة فورية واحترافية.

جرّب السيرفر الآن

Mr.Narsus

يا مَنْ يُسَائِلُ مَنْ أَنَا؟، أَنَا كُنْتُ يَوْمًا هَاهُنَا، رَكْبُ مُبَعْسِسٍ يَضُمُّنِي، لِلعِلمِ أَبْذُلُ مُؤْمِنا، سَطَّرْتُ عِلمًا نَافِعًا، صُغْتُ المَعْلُومَات نَاشِرًا، غُرَرَ الفَوَائِدِ سُقْتُهَا، لِتَكُونَ ذُخْرًا بَعْدَنَا، فَإِذَا مَرَرْتَ بِبَعْضِهَا، مِنْ دَعْوَةٍ لَا تَنْسَنَا!
مقالات بواسطة Mr.Narsus

بسم الله الرحمن الرحيم

اليوم درس عملي لمحبي لغة السلطعون Rust ممن تعلمها ويبحث عن تطبيق ما تعلمه في بناء البرمجيات والمشاريع فيزداد تمكُّنًا منها، فخذ درس اليوم وادرس كل سطر فيه بتمعُّنٍ وأناة، وهو بإذن الله نافع للمبتدي. سنبرمج اليوم مَظرفًا shell، المشتهر بترجمته الحرفية بالصَّدَفَة، صدفة البحر. لن نبرمج مَظرفًا متكاملًا بل مَظرفًا مُصغَّرًا:

  1. ينفذ الأوامر
  2. ويقبل تمرير المخرجات piping
  3. وعنده سجل للأوامر history
  4. ويعالج الإشارات handle signals

وقبل الشروع في المقصود نُعرِّف المَظرف ما هو؟ وما معنى اسمه؟ وكيف يعمل؟ ثم ننتهي ببرمجته بلغة Rust.

المَظرف Shell

المَظرف لغةً من الظَّرف، أي الوعاء، فكل ما يستقرّ غيره فيه فهو ظرف، كقولك: “أدرجت الرسالةَ في الظرف”، وكقولك: “الكتاب ظرفٌ مُلِئ علمًا”، فالرسالة استقرت في الظرف، والكتاب وعاء العلم، أي استقر العلمُ في الكتاب.

أما اصطلاحًا فالمَظرف Shell برنامج وسيط بين المستخدم والنواة. والمَظرف في جنو/لينكس يحيط بالنواة kernel، ونواة النظام طبقة، ثم طبقة ثانية هي طبقة المَظرف shell، ثم طبقة ثالثة هي البرامج. انظر الصورة:

وضعت لك صورة الصدفة لأن معنى كلمة Shell عند الإنجليز هي الصدفة، وأتى المترجم الحاذق ونقلها كما هي. وما سماها الإنجليز صدفة إلا لأنها كالصدفة فيها لؤلؤة، واللؤلؤة هي نواة النظام المستكنة.

وأنت أيها المبرمج بلا ريب قد استعملت المَظرف Shell في ويندوز عند فتحك سطر الأوامر أو في لينكس مثل المَظرف Bash أو Zsh. فسطر الأوامر ذاك وما يلقى فيه من أوامر ينفذه المَظرف، ونهجه في ذلك في أربع خطوات:

الأولى: القراءة، وفيها يتلقَّى المَظرف ما يُلقيه إليه المرء من أوامر، كما يتلقَّى الكاتب ما يُمليه عليه سيِّده.

الثانية: التحليل، يُحلّل ما ألقي إليه من أوامر، فيميِّز بين الأمر ومتعلَّقاته، يعرف ما يُراد منه بالضبط والتحقيق.

الثالثة: التنفيذ، وفيها يمضي المَظرف في تنفيذ ما فهمه، فيخاطب نظام التشغيل بلغته، ويطلب منه ما يلزم من الأعمال. فإن كان المطلوب تشغيل برنامج أمر بتشغيله، وإن كان المطلوب تنفيذ رماز Code تولَّى تنفيذه، وإن كان المطلوب عملاً آخر قام به على الوجه المطلوب.

الرابعة: العرض، وفيها تُعرض نتيجة ما نُفِّذ على المستخدِم، كما يعرض الكاتب على سيِّده ما كتبه.

هذه أربع خطوات تعاد في كل مرة تكتب فيها أمرًا، فبعد عرض المخرجات في الخطوة الرابعة يعود المَظرف إلى خطوة القراءة وينتظر منك كتابة الأمر، فهذه الخطوات الأربع: تلقَّي أوامر المستخدِم، وتَفهُّم ما يقول، ثم تَسلُّم ما فهمه المَظرف إلى نظام التشغيل لينفذه وعرض المخرجات = هي كل ما نحتاج لبرمجة المَظرف.

هذه الخطوات الأربع اسمها حلقة القراءة والتنفيذ والعرض read–eval–print loop، اختصارًا REPL، وهي في كتاب الساحر SICP الذي بدأناه، غرضها إنشاء مفسّر أوامر تفاعلي interactive.

سنبدأ في تنجيز implementation الخطوة الأولى ثم الثانية!

جهز نفسك بكتابة:

cargo new minishell
cd minishell

كل ما سيأتي عليك كتابته في الملف main.rs داخل المجلد src.


١. قراءة المُدخل


محث الأوامر

عند فتح سطر الأوامر يطالبك المَظرف بكتابة الأمر، ويظهر لك هذا مثل هذا:

[root@MyServer ~]#

هذا اسمه المحث Prompt ويظهر عند تهيئ المَظرف لتلقي الأوامر. فأول ما سنبرمجه هو هذا المحث، حتى نخبر المستخدم بأن يدخل الأمر. الأنظمة الشبيهة بيونكس Unix-like systems تتلقى المدخل من مجرى الدخل stdin. نستعمل في لغة Rust مكتبة std::io التي تعالج الدخل والخرج، مثل قراءة ما يكتبه المستخدم ثم عرض ما نشاء له.

سنكتب بضع سطور تظهر لنا المحث، وسندخله في حلقة Loop لا نهاية لها، لأننا نريد من المَظرف أن يتلقى الأوامر بلا توقف:

// src/main.rs

use std::io::{self, Write};

fn main() {
    loop {
        // Print the prompt
        print!("BassYe> ");

        // Ensure prompt is displayed immediately
        io::stdout().flush().unwrap();

        // Read user input
        let mut input = String::new();
        match io::stdin().read_line(&mut input) {
            Ok(_) => {
                let input = input.trim();

                // For now, just echo the input
                println!("You entered: {}", input);
            }
            Err(error) => {
                eprintln!("Error reading input: {}", error);
                break;
            }
        }
    }
}

لن أشرح لك نحو اللغة syntax، فعليك أن تدرسها حتى تفهم الدرس، سأوضح لك وظيفة ما كتبته. أول سطر:

use std::io::{self, Write};

هذا السطر استدعاء لمكتبة std::io لمعالجة الدخل والخرج، واحتجنا للمسة Trait المسماة Write حتى نشغل ()flush. فيما سيأتي.

print!("BassYe> ");

استعضنا عن تعليمة println! بكتابة print! حتى لا ينطبع سطر جديد بعد عرض المحث BassYe>، لأن التعليمة الأولى (println!) تطبع سطرًا جديدًا أما الثانية (print!) فلا.

io::stdout().flush().unwrap();

استدعينا io::stdout().flush() حتى نضمن عرض المحث من لحظته دون تأخر وقبل قراءة ما سيكتبه المستخدم. وعلة هذا أن في لغة Rust شيء اسمه الصِوان buffering (ترجمة حرفية معناه: التخزين المؤقت) قد لا يعرض النص من لحظته ويتأخر، فأضفنا flush() التي تجبر البرنامج على عرض النص من الصِوان buffering إلى الشاشة مباشرة، ودونها قد لا يظهر للمستخدم المحث BassYe> حتى يضغط إدخال Enter.

مفهوم الكلام، انسخه وشغله وانظر ما الذي سيظهر لك (كل ما تكتبه سيُرجعه لك). شغله بالأمر:

cargo run

٢. تحليل المُدخل

انتهينا من أول خطوة وهي قراءة المُدخل، والآن حان تحليله، وأقصد تجزيئه إلى جزئين:

  1. الأمر command
  2. متعلقاته arguments

مثلًا الأمر ls لعرض الملفات في المجلد الحالي له محددات arguments (ترجمة الجامعة السورية، وهي جمع لفظة مُحَدِّد argument) مثل -a لعرض الملفات المخفية. كيف سنفعل هذا؟

سنجزئ المُدخل إلى كلمات بحسب المسافات، فأول كلمة ستكون الأمر، وما بعدها هي المحددات arguments. مثلًا إذا كتبنا ls -a ستكون نتيجة التجزئة:

["ls", "-a"]

هذه طريقة بدائية وسنعدل عليها لاحقًا. سنعدل على ما كتبناه سابقًا ونضيف له الخطوة الثانية (تحليل المُدخل):

// src/main.rs

use std::io::{self, Write};

fn main() {
    loop {
        print!("BassYe> ");
        io::stdout().flush().unwrap();

        let mut input = String::new();
        match io::stdin().read_line(&mut input) {
            Ok(_) => {
                let input = input.trim();

                // skip empty input
                if input.is_empty() {
                    continue;
                }

                // Parse the input into command and arguments
                let mut parts = input.split_whitespace();
                let command = parts.next().unwrap();
                let args: Vec<&str> = parts.collect();

                println!("Command: {}", command);
                println!("Arguments: {:?}", args);
            }
            Err(error) => {
                eprintln!("Error reading input: {}", error);
                break;
            }
        }
    }
}

خذه وشغله، ثم اكتب ls -a وانظر كيف سيجزئ المُدخل، ستكون النتيجة:

Command: ls
Arguments: ["-a"]

انتهينا من خطوتين ولله الحمد، وأوشكنا على الانتهاء، الخطوة الثالثة ستشمل عرض المخرجات، لأن عرضها يسير أيها المعبسس.


٣. التنفيذ


كيف تُنَفَّذ الأوامر

عرفنا كيف نحلل الأمر المُدخل، والآن كيف ننفذه (نشغله)؟

اعلم أيها المبعسس أنك عند كتابة أمر والضغط على زر إدخال يشغل المَظرف عملية process، هذه العملية هي ما كتبته. مثلًا إذا كتبت أمر ls فإن المَظرف يشغل عملية فيها الأمر الذي كتبته. والمَظرف بنفسه ليس إلا عملية، كل شيء في الحاسوب عملية، ولكل عملية مُعرِّف ID فريد يمزيها عن غيرها اسمه معرف العملية PID. وكل عملية عندما تنتهي من عملها تُرجع قيمة تُبيّن حال انتهائها exit code: أنجحت في عملها أم أخفقت؟

المَظرف في الأنظمة الشبيهة بيونكس في تشغيله العملية يسلك مسلكين اثنين:

الأول: التفرع fork: المَظرف هاهنا ينشئ العملية بانشطاره إلى نسختين متطابقتين كما تنشطر الخليَّة الحيَّة، أي يتفرع، فتصير عملية المَظرف نسختين: الأولى هي الأصل (الأب) ولها مُعرِّف فريد، والثانية هي النسخة (الابن) ولها مُعرِّف فريد أيضًا. وهذا الانشطار يحدث باستدعاء أمرٍ في نظام التشغيل اسمه fork.

الثاني: الاستبدال exec: العملية الجديدة هاهنا لا تنشطر عن عملية المَظرف الأصلية، بل أن العملية هاهنا تأخذ مُعرِّف المَظرف، كأنها تستبدل عملية المَظرف بنفسها، تتحول!
وهذا الاستبدال يحدث باستدعاء أمرٍ في نظام التشغيل اسمه exec، عند استدعائه كأنه يقول للنظام: توقف واستبدل المَظرف بالأمر المُدخل، نعم نفس المُعرِّف لكن المحتوى مُغاير.

لا تقلق إن استصعبت الشرح، فنحن في لغة Rust سنستعمل الهيكل struct المسمى Command من مكتبة std::process وهذه التفاصيل كلها مخفية واللغة نفسها ستعالج كل شيء بنفسها.

الأوامر المُضمَّنة Built-In

اعلم أيها المعبسس أن الأوامر قسمين، الأول أوامر مُضمَّنة في المَظرف نفسه، وأخرى خارجية. الأوامر المُضمَّنة هي cd والتي تعني تغيير المجلد، والأمر exit الذي يخرج من المَظرف، وهذان الأمران لا بد أن يعالجها المَظرف لا أن يمررها لنظام التشغيل حتى يعالجها هو. أتدري ما علة هذا (أي ما سببه)؟

لأنك عند تنفيذ الأمر cd وإذا سلك المَظرف مسلك التفرع fork فإن هذه العملية التي تفرعت عن الأصل هي من سينفذ الأمر cd ويغير المجلد، لكن بعد انتهاء العملية الفرعية سيبقى المجلد هو نفسه بلا تغيير، أخفق التغيير!

وبالمثل الأمر exit إذا اتبع المَظرف مسلك التفرع، فإن العملية الفرعية هي من سينفذ الأمر ثم تموت وتبقى العملية الأصل تعمل ولن يتوقف المَظرف ولن تخرج منه!

تضمين الأمرين في مَظرفنا

سنعدل على الجزء الذي بنيناه من مَظرفنا حتى الآن، وسنضيف له أذرع المطابقة match arm، ذراع السلطعون التي ستفحص أإذا كان المُدخل أحد الأمرين المضمنين أم لا قبل تشغيل أي أمر خارجي:

use std::{
    env,
    error::Error,
    io::{stdin, stdout, Write},
    path::Path,
};

fn main() -> Result<(), Box<dyn Error>> {
    loop {
        print!("BassYe> ");
        stdout().flush()?;

        let mut input = String::new();
        stdin().read_line(&mut input)?;
        let input = input.trim();

        if input.is_empty() {
            continue;
        }

        // Parse the input into command and arguments
        let mut parts = input.split_whitespace();
        let Some(command) = parts.next() else {
            continue;
        };
        let args: Vec<&str> = parts.collect();

        // Handle built-in commands first
        match command {
            "cd" => {
                // Handle cd command - must be done by shell itself
                let new_dir = args.first().unwrap_or(&"/");
                let root = Path::new(new_dir);
                if let Err(e) = env::set_current_dir(root) {
                    eprintln!("cd: {}", e);
                }
            }
            "exit" => {
                // Handle exit command - terminate the shell
                println!("Goodbye!");
                return Ok(());
            }
            // All other commands are external commands
            command => {
                println!(
                    "Executing external command: {} with args: {:?}",
                    command, args
                );
                // We'll implement this in the next step
            }
        }
    }
}

انظر في الأمر المُضمَّن cd كيف استعملنا الوظيفة function المسماة env::set_current_dir حتى نغير المجلد الحالي لعملية المَظرف. ثم استعملنا unwrap_or(&"/") حتى نغير المجلد إلى مجلد الجذر root إذا لم تكن للأمر cd أي مُحددات argument.

قد يقول متسخدم لينكس الآن: لِماذا لم تستعمل ~ حتى تشير إلى مجلد المنزل؟

اعلم أن هذه العلامة ليست ثابتة لكل مَظرف، لكن كتابتنا / أمر ثابت في الأنظمة الشبيهة بيونكس. إذا أردت من هذا المَظرف أن يقبل العلامة ~ فعليك أن تترجمها إلى مجلد المنزل باستعمال dirs::home_dir() من الصندوق crate المسمى dirs وهذا رابطه:

https://crates.io/crates/dirs

برمجها بنفسك، هذا تمرين اليوم.

الأوامر الخارجية

بعد برمجة الأوامر المضمنة فسهل علينا أن نبرمج الأوامر الخارجية، سنبرمجها بالهيكل Command من std::process. لنشغل أمرًا بالهيكل الآنف فإننا سنستدعي Command::new للنشئ أمرًا، ثم نشغله باستدعاء spawn حتى يعمل في عملية جديدة. لتنفيذ أمر ls -al جرب:

use std::process::Command;

// use Builder pattern to create a new command
let output = Command::new("ls") // create a new command
    .arg("-la") // add argument(s)
    .output() // execute the command and capture output
    .expect("Failed to execute command"); // handle any errors

أنشأنا الأمر ثم أضفنا محدده argument ثم شغلناه وحفظنا الخرج بكتابتنا .output() وهذه تُرجع Result<Output>، وoutput تحتفظ بالخرج والخطأ. سنضم هذا الجزء إلى مَظرفنا الذي كتبناه:

use std::{
    env,
    error::Error,
    io::{stdin, stdout, Write},
    path::Path,
    process::Command,
};

fn main() -> Result<(), Box<dyn Error>> {
    loop {
        print!("> ");
        stdout().flush()?;

        let mut input = String::new();
        stdin().read_line(&mut input)?;
        let input = input.trim();

        if input.is_empty() {
            continue;
        }

        // Parse the input into command and arguments
        let mut parts = input.split_whitespace();
        let Some(command) = parts.next() else {
            continue;
        };
        let args: Vec<&str> = parts.collect();

        // Handle built-in commands first
        match command {
            "cd" => {
                let new_dir = args.first().unwrap_or(&"/");
                let root = Path::new(new_dir);
                if let Err(e) = env::set_current_dir(root) {
                    eprintln!("cd: {}", e);
                }
            }
            "exit" => {
                println!("Goodbye!");
                return Ok(());
            }
            // All other commands are external commands
            command => {
                // Create a Command struct to spawn the external process
                let mut cmd = Command::new(command);
                cmd.args(&args);

                // Spawn the child process and wait for it to complete
                match cmd.spawn() {
                    Ok(mut child) => {
                        // Wait for the child process to finish
                        match child.wait() {
                            Ok(status) => {
                                if !status.success() {
                                    eprintln!("Command '{}' failed with exit code: {:?}",
                                            command, status.code());
                                }
                            }
                            Err(e) => {
                                eprintln!("Failed to wait for command '{}': {}", command, e);
                            }
                        }
                    }
                    Err(e) => {
                        eprintln!("Failed to execute command '{}': {}", command, e);
                    }
                }
            }
        }
    }
}

لاحظ أننا أنشأنا نسخة من Command بكتابة Command::new(command) وقد مررنا له الأمر بين القوسين.

ثم أضفنا المحددات إذا وجدت بكتابة cmd.args(&args)، ثم استدعينا cmd.spawn() حتى ننفذ الأمر في عملية جديدة، وهذا (cmd.spawn()) لا ينتظر العملية حتى تنتهي، والتابع method المسمى spawn يُرجع Result<Child>، وكلمة Child تمثل العملية التي بدأت العمل. وحتى نجعله ينتظر انتهاء العملية كتبنا child.wait() وتُرجع Result<ExitStatus> وهو رمز حال الانتهاء، كيف حاله عند الانتهاء، أنجح أم أخفق؟

إلى هنا انتهينا من الخطوات الأربع وصار عندنا مَظرف مُصغَّر، سنضيف له بعض التحسينات هنا وهناك بسرعة فقد طال المقال، وإن أردت أن تضيف له فأضف وبعسس!


تمرير الأوامر Piping

من أقوى ميزات مَظارف الأنظمة الشبيهة بيونكس ميزة تمرير مخرجات أمر ما إلى أمر آخر، مخرجات أمر صارت مدخلات أمر آخر، ويتم هذا بالأنبوب وعلامته | فهو يربط الأوامر معًا. مثلًا نعرض الملفات بالأمر ls ثم نمررها إلى الأمر grep حتى يبحث فيها:

ls | grep txt

من عيوب مَظرفنا أنه يتلقى أمرًا واحدًا في كل مرة، لذا سنضيف له ميزة تمرير المخرجات حتى يقبل أكثر من أمر واحد كل مرة.

أول ما سنفعله هو تعديل تحليل المدخلات، الخطوة الثانية التي تجزئ المدخلات، وسنجعلها تجزئ المدخلات بحسب علامة الأنبوب | بدلاً من المسافات، وبهذا التعديل سندخل أوامر متعددة في نفس السطر!

سنخزن هذه الأوامر في مُكرّر iterator يُتصفَّح peekable. لماذا يستحسن استعماله؟

لأننا نريد التحقق إذا كانت عندنا المزيد من الأوامر التي يجب معالجتها بعد الأمر الحالي، حتى نقرر إذا كنا سنمرر المخرجات إلى الأمر التالي أم لا.

// Split input on pipe characters to handle command chaining
let mut commands = input.trim().split(" | ").peekable();

بما أننا نتعامل الآن مع أوامر متعددة، فعلينا تتبع مخرجات الأمر السابق لنقلها إلى الأمر التالي، إن وُجد. ونريد تتبع جميع العمليات الفرعية التي نُنشئها حتى نتمكن من انتظار انتهائها لاحقًا.

let mut prev_stdout = None; // This will hold the output of the previous command
let mut children: Vec<Child> = Vec::new(); // This will hold all child processes we spawn

الآن سنتحقق من كل أمر ونحلله إلى اسمه ومتعلقاته، أي محدداته arguments، ثم ننفذه. إذا كان المدخل من الأوامر المضمنة فلن تتغير معاملتنا له، لكن إذا كان المدخل من الأوامر الخارجية سنُعد الدخل القياسي stdin والخرج القياسي stdout بناءً على وجود أمر سابق للتحويل منه أو إذا كان هو الأمر الأخير، وإذا وجد أمر سابق سنحول مخرجاته للأمر التالي وهكذا…

انظر إلى مَظرفنا الآن:

use std::{
    env,
    error::Error,
    io::{stdin, stdout, Write},
    path::Path,
    process::{Child, Command, Stdio},
};

fn main() -> Result<(), Box<dyn Error>> {
    loop {
        print!("> ");
        stdout().flush()?;

        let mut input = String::new();
        stdin().read_line(&mut input)?;
        let input = input.trim();

        if input.is_empty() {
            continue;
        }

        // Split input on pipe characters to handle command chaining
        let mut commands = input.trim().split(" | ").peekable();
        let mut prev_stdout = None;
        let mut children: Vec<Child> = Vec::new();

        // Process each command in the pipeline
        while let Some(command) = commands.next() {
            let mut parts = command.split_whitespace();
            let Some(command) = parts.next() else {
                continue;
            };
            let args = parts;

            match command {
                "cd" => {
                    // Built-in: change directory
                    let new_dir = args.peekable().peek().map_or("/", |x| *x);
                    let root = Path::new(new_dir);
                    if let Err(e) = env::set_current_dir(root) {
                        eprintln!("cd: {}", e);
                    }
                    // Reset prev_stdout since cd doesn't produce output
                    prev_stdout = None;
                }
                "exit" => {
                    println!("Goodbye!");
                    return Ok(());
                }
                command => {
                    // External command: set up stdin/stdout for piping

                    // Input: either from previous command's output or inherit from shell
                    let stdin = match prev_stdout.take() {
                        Some(output) => Stdio::from(output),
                        None => Stdio::inherit(),
                    };

                    // Output: pipe to next command if there is one, otherwise inherit
                    let stdout = if commands.peek().is_some() {
                        Stdio::piped()  // More commands follow, so pipe output
                    } else {
                        Stdio::inherit()  // Last command, output to terminal
                    };

                    // Spawn the command with configured stdin/stdout
                    let child = Command::new(command)
                        .args(args)
                        .stdin(stdin)
                        .stdout(stdout)
                        .spawn();

                    match child {
                        Ok(mut child) => {
                            // Take ownership of stdout for next command in pipeline
                            prev_stdout = child.stdout.take();
                            children.push(child);
                        }
                        Err(e) => {
                            eprintln!("Failed to execute '{}': {}", command, e);
                            break;
                        }
                    }
                }
            }
        }

        // Wait for all child processes to complete
        for mut child in children {
            let _ = child.wait();
        }
    }
}

تهانينا، المَظرف الآن ينفذ أكثر من أمر في نفس السطر، جرب أمر ls | wc -l وتمتع!


صندوق rustyline

لعلك لاحظت إذا كتبت الأوامر في المَظرف أنك لا تستطيع الرجوع بأسهم لوحة المفاتيح للخلف حتى تعدل كلمة إذا أخطأت فيها، ولن تقتدر على استرجاع الأوامر السابقة أيضًا. لحل هذه العيوب سنستعمل الصندوق rustyline لإدارة المدخلات والسجلات، وهذا رابطه:

https://crates.io/crates/rustyline

يمكننا هذا الصندوق من تحرير الأوامر التي نكتبها والاستكمال للأوامر وغيرها من الأشياء، وسأريك ما الذي تستعمله لإضافة سجل الأوامر وإضافة الإشارات Handling signals وأنت افعله بنفسك حتى تجرب بيديك. أضف الصندوق بكتابة الأمر:

cargo add rustyline

سجل الأوامر

إضافة سجل الأوامر نافع للمستخدم لأنه يسهل عليهم استرجاع الأوامر السابقة عند الحاجة، وفي المَظرف Bash تسترجع الأمر السابق بالأسهم في لوحة المفاتيح، وتضغط على Ctrl+R لتبحث في سجل الأوامر عن أمر كتبته فيما مضى. وحتى ننجز هذا بصندوق rustyline سنستعمل منه النوع DefaultEditor حتى ننشئ محرر الأسطر، وسنستعمل من الصندوق load_history وsave_history حتى نحمل السجل (أي نجلبه) ونحفظه.

لن أعيد كتابة كل شيء، سأعطيك هو وأنت أضفه وبعسس أيها المعبسس:

// src/main.rs

use rustyline::error::ReadlineError;
use rustyline::DefaultEditor;
use std::{
    env,
    error::Error,
    fs,
    path::Path,
    process::{Child, Command, Stdio},
};

fn main() -> Result<(), Box<dyn Error>> {
    // Create a new line editor instance with default settings
    let mut rl = DefaultEditor::new()?;

    // /tmp is a common place for temporary files and is writable by all users
    let history_path = "/tmp/.minishell_history";

    // Try to load existing history
    match rl.load_history(history_path) {
        Ok(_) => {}
        Err(ReadlineError::Io(_)) => {
            // History file doesn't exist, create it
            fs::File::create(history_path)?;
        }
        Err(err) => {
            eprintln!("minishell: Error loading history: {}", err);
        }
    }

    loop {
        let line = rl.readline("> ");

        match line {
            Ok(line) => {
                let input = line.trim();

                if input.is_empty() {
                    continue;
                }

                // Add command to history
                rl.add_history_entry(input)?;

                // بقية البرنامج يبقى كما هو، بلا تغيير

            }
            Err(e) => {
                eprintln!("minishell: Error: {:?}", e);
            }
        }
    }
}

ادرسه بتمعن، ولاحظ أني لم أحفظ الأوامر الفارغة في الملف إذا ضغط على زر إدخال دون كتابة أي شيء. حفظت الأوامر في ملف /tmp/.minishell_history، وهذا الملف يُحمَّل عند اشتغال المَظرف ويُحفظ عند إغلاقه. وبهذا تستطيع رؤية واسترجاع الأوامر بعد إغلاق المَظرف، وقد اخترت المجلد /tmp لأنه مجلد يكتب فيه الجميع، لكن بعد إطفاء الجهاز كل محتواه سيُحذف.

معالجة الإشارات

الإشارات المقصود بها هي إشارة توقف مثل الضغط على Ctrl+Cإذا ضغطت عليها سيطبع لك المَظرف رسالة ("minishell: Error: Interrupted"). سنضيفها بأذرع المطابقة match arm ونقول للمَظرف إذا ضغط رأيت الإشارة الفلانية افعل كذا:

use rustyline::error::ReadlineError;
// أضف الباقي هنا...

fn main() -> Result<(), Box<dyn Error>> {

    // كما السابق لا تغيير للمكتوب هنا...

    loop {
        match line {
            Ok(line) => {
                // وهنا كما السابق لا تغيير، لهذا لم أكتب ما كتبته سابقًا وأكرره
            }
            Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => {
                // Handle Ctrl-C or Ctrl-D gracefully
                println!("\nExiting minishell...");
                rl.save_history(history_path)?;
                break;
            }
            Err(e) => {
                // كما السابق، لا تغيير لما كتبته...
                eprintln!("minishell: Error: {:?}", e);
            }
        }
    }

    Ok(())
}

أضف في أماكن التعليقات ما سبق وكتبناه، فلم أكرره واكتفيت بالتعليق في المكان حتى أخبرك أن تضيفه بنفسك.

ختامًا لا تنسونا من دعائكم

ها قد وصلنا إلى نهاية المقال، وها أنا أخط بقلمي الخطوط الأخيرة لهذا المقال الشائق، وأرجو أنني قد وفّقت في الشرح.

وفي نهاية الأمر، لا يسعني سوى أن أشكرك على حسن قراءتك لهذا المقال، وأني لبشر أصيب وأخطئ، فإن وفقت في طرح الموضوع فمن اللّٰه عز وجل، وإن أخفقت فمن نفسي والشيطان.


أرجو منك تقييم كفاءة المعلومات من أجل تزويدي بالملاحظات والنقد البناء في خانة التعليقات أو عبر حساب الموقع. 

هل لديك إستفسار ؟

اكتب رسالتك

14 + 6 =