الفصل 09

التعابير النمطية

بعض الناس، حين يواجهون مشكلة، يفكرون: «أعرف، سأستخدم التعبيرات النمطية». والآن أصبحت لديهم مشكلتان.

— جيمي زاوينسكي

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

— المعلم يوان-ما، كتاب البرمجة

رسم توضيحي لنظام سكك حديدية يمثل البنية النحوية للتعبيرات النمطية

أدوات البرمجة وتقنياتها تبقى وتنتشر بطريقة فوضوية تطورية. فليست دائماً أفضل الأدوات أو ألمعها هي التي تفوز، بل تلك التي تعمل بشكل كافٍ ضمن المكان المناسب، أو التي تصادف أنها اندمجت مع قطعة تقنية ناجحة أخرى.

في هذا الفصل، سأناقش إحدى هذه الأدوات، التعبيرات النمطية (regular expressions). التعبيرات النمطية طريقة لوصف الأنماط في بيانات النصوص. وهي تشكّل لغة صغيرة مستقلة جزءاً من JavaScript وكثير من اللغات والأنظمة الأخرى.

التعبيرات النمطية بالغة الإرباك وبالغة الفائدة في آن واحد. فصياغتها غامضة وواجهة البرمجة التي توفرها JavaScript لها ركيكة. لكنها أداة قوية لفحص النصوص ومعالجتها. وفهم التعبيرات النمطية فهماً صحيحاً سيجعلك مبرمجاً أكثر فعالية.

إنشاء تعبير نمطي

التعبير النمطي نوع من الكائنات. ويمكن إما إنشاؤه بالبانية RegExp أو كتابته كقيمة حرفية بتطويق نمط بين محرفي الشرطة المائلة (/).

let re1 = new RegExp("abc");
let re2 = /abc/;

كلا كائني التعبير النمطي يمثلان النمط نفسه: محرف a متبوعاً بـ b متبوعاً بـ c.

عند استخدام البانية RegExp، يُكتب النمط كنص عادي، لذا تنطبق القواعد المعتادة على الشرطات العكسية.

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

let aPlus = /A\+/;

اختبار التطابقات

لكائنات التعبيرات النمطية عدد من الطرائق. أبسطها test. وإذا مررت لها نصاً، فستعيد قيمة منطقية تخبرك ما إذا كان النص يحتوي على تطابق للنمط في التعبير.

console.log(/abc/.test("abcde"));
// → true
console.log(/abc/.test("abxde"));
// → false

التعبير النمطي المكوَّن من محارف غير خاصة فقط يمثل ببساطة ذلك التسلسل من المحارف. وإذا ظهر abc في أي مكان من النص الذي نختبره (وليس في البداية فقط)، فستعيد test القيمة true.

مجموعات المحارف

معرفة ما إذا كان نص يحتوي على abc يمكن أن تُنجز بالطريقة نفسها باستدعاء indexOf. والتعبيرات النمطية مفيدة لأنها تتيح لنا وصف أنماط أكثر تعقيداً.

لنفترض أننا نريد مطابقة أي عدد. في التعبير النمطي، وضع مجموعة محارف بين قوسين مربعين يجعل ذلك الجزء من التعبير يطابق أي محرف من المحارف بين القوسين.

كلا التعبيرين التاليين يطابق كل النصوص التي تحتوي على رقم:

console.log(/[0123456789]/.test("in 1992"));
// → true
console.log(/[0-9]/.test("in 1992"));
// → true

داخل القوسين المربعين، يمكن استخدام شرطة (-) بين محرفين للإشارة إلى مدى من المحارف، حيث يحدد ترتيبها الرقم في معيار Unicode للمحرف. والمحارف من 0 إلى 9 تقع متجاورة تماماً في هذا الترتيب (الرموز من 48 إلى 57)، لذا يغطي [0-9] جميعها ويطابق أي رقم.

لعدد من مجموعات المحارف الشائعة اختصارات مدمجة خاصة بها. والأرقام إحداها: \d تعني الشيء نفسه الذي تعنيه [0-9].

\d أي محرف رقمي
\w محرف أبجدي رقمي («محرف كلمة»)
\s أي محرف فراغ (مسافة، جدولة، سطر جديد، وما شابه)
\D محرف ليس رقماً
\W محرف غير أبجدي رقمي
\S محرف غير فراغ
. أي محرف عدا السطر الجديد

يمكنك مطابقة صيغة تاريخ ووقت مثل 01-30-2003 15:20 بالتعبير التالي:

let dateTime = /\d\d-\d\d-\d\d\d\d \d\d:\d\d/;
console.log(dateTime.test("01-30-2003 15:20"));
// → true
console.log(dateTime.test("30-jan-2003 15:20"));
// → false

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

يمكن استخدام رموز الشرطة العكسية هذه داخل القوسين المربعين أيضاً. فمثلاً، [\d.] تعني أي رقم أو محرف نقطة. والنقطة نفسها، بين القوسين المربعين، تفقد معناها الخاص. وينطبق الشيء نفسه على المحارف الخاصة الأخرى، مثل علامة الجمع (+).

لـعكس مجموعة محارف — أي للتعبير عن أنك تريد مطابقة أي محرف عدا المحارف الموجودة في المجموعة — يمكنك كتابة محرف العناية (^) بعد قوس الفتح.

let nonBinary = /[^01]/;
console.log(nonBinary.test("1100100010100110"));
// → false
console.log(nonBinary.test("0111010112101001"));
// → true

المحارف الدولية

بسبب التنفيذ المبسّط الأولي في JavaScript، وبسبب أن هذا الأسلوب المبسّط جُمّد لاحقاً كسلوك قياسي، فإن التعبيرات النمطية في JavaScript غبية نوعاً ما تجاه المحارف التي لا تظهر في اللغة الإنجليزية. فمثلاً، بحسب ما تعنيه التعبيرات النمطية في JavaScript، «محرف الكلمة» هو فقط أحد 26 محرفاً في الأبجدية اللاتينية (كبيرة أو صغيرة)، والأرقام العشرية، ولسبب ما، محرف الشرطة السفلية. وأشياء مثل é أو β، وهي بالتأكيد محارف كلمات، لن تطابق \w (وستطابق بالفعل \W الكبيرة، فئة غير الكلمات).

بحادث تاريخي غريب، لا تعاني \s (الفراغ) من هذه المشكلة وتطابق كل المحارف التي يعتبرها معيار Unicode فراغاً، بما في ذلك أشياء مثل المسافة غير الفاصلة وفاصل العلة المنغولي.

من الممكن استخدام \p في تعبير نمطي لمطابقة كل المحارف التي يسنِد إليها معيار Unicode خاصية معينة. وهذا يتيح لنا مطابقة أشياء مثل الحروف بطريقة أكثر عالمية. لكن، مرة أخرى بسبب التوافق مع معايير اللغة الأصلية، لا يُتعرف على تلك إلا عندما تضع محرف u (لـ Unicode) بعد التعبير النمطي.

\p{L} أي حرف
\p{N} أي محرف عددي
\p{P} أي محرف ترقيم
\P{L} أي محرف غير حرف (P الكبيرة تعكس)
\p{Script=Hangul} أي محرف من الكتابة المعطاة (انظر الفصل 5)

استخدام \w لمعالجة نصوص قد تحتاج إلى التعامل مع نص غير إنجليزي (أو حتى نص إنجليزي بكلمات مستعارة مثل «cliché») عبء، لأنه لن يعامل محارف مثل «é» كحروف. ورغم أن مجموعات الخصائص \p تميل إلى الإسهاب قليلاً، فإنها أكثر متانة.

console.log(/\p{L}/u.test("α"));
// → true
console.log(/\p{L}/u.test("!"));
// → false
console.log(/\p{Script=Greek}/u.test("α"));
// → true
console.log(/\p{Script=Arabic}/u.test("α"));
// → false

من ناحية أخرى، إن كنت تطابق أعداداً لتفعل بها شيئاً، فغالباً ما تريد فعلاً \d للأرقام، لأن تحويل محارف رقمية اعتباطية إلى عدد في JavaScript ليس شيئاً تستطيع دالة مثل Number فعله نيابة عنك.

تكرار أجزاء من النمط

نعرف الآن كيف نطابق رقماً واحداً. فماذا لو أردنا مطابقة عدد كامل — تسلسل من رقم واحد أو أكثر؟

عندما تضع علامة جمع (+) بعد شيء ما في تعبير نمطي، فإنها تشير إلى أن العنصر قد يتكرر أكثر من مرة. وهكذا، /\d+/ تطابق محرفاً رقمياً واحداً أو أكثر.

console.log(/'\d+'/.test("'123'"));
// → true
console.log(/'\d+'/.test("''"));
// → false
console.log(/'\d*'/.test("'123'"));
// → true
console.log(/'\d*'/.test("''"));
// → true

للنجمة (*) معنى مشابه لكنها تتيح للنمط أن يطابق صفر مرات أيضاً. فالشيء المتبوع بنجمة لا يمنع النمط من المطابقة أبداً — سيطابق صفر حالات فقط إن لم يجد أي نص مناسب للمطابقة.

وعلامة الاستفهام (?) تجعل جزءاً من النمط اختيارياً، أي أنه قد يظهر صفر مرة أو مرة واحدة. في المثال التالي، يُسمح بظهور محرف u، لكن النمط يطابق أيضاً عند غيابه:

let neighbor = /neighbou?r/;
console.log(neighbor.test("neighbour"));
// → true
console.log(neighbor.test("neighbor"));
// → true

للإشارة إلى أن نمطاً ينبغي أن يظهر عدداً محدداً بدقة من المرات، استخدم الأقواس المعقوفة. فمثلاً، وضع {4} بعد عنصر يقتضي ظهوره أربع مرات بالضبط. ومن الممكن أيضاً تحديد مدى بهذه الطريقة: {2,4} تعني أن العنصر يجب أن يظهر مرتين على الأقل وأربع مرات على الأكثر.

وإليك نسخة أخرى من نمط التاريخ والوقت تتيح أياماً وشهوراً وساعات من خانة واحدة أو خانتين. وهي أيضاً أسهل قليلاً في فكّ رموزها.

let dateTime = /\d{1,2}-\d{1,2}-\d{4} \d{1,2}:\d{2}/;
console.log(dateTime.test("1-30-2003 8:45"));
// → true

يمكنك أيضاً تحديد مديات مفتوحة عند استخدام الأقواس المعقوفة بحذف العدد بعد الفاصلة. فمثلاً، {5,} تعني خمس مرات أو أكثر.

تجميع التعبيرات الفرعية

لاستخدام معامل مثل * أو + على أكثر من عنصر في وقت واحد، يجب استخدام الأقواس الهلالية. فالجزء من التعبير النمطي المطوَّق بأقواس هلالية يُحتسب عنصراً واحداً فيما يتعلق بالمعاملات التي تليه.

let cartoonCrying = /boo+(hoo+)+/i;
console.log(cartoonCrying.test("Boohoooohoohooo"));
// → true

تسري علامتا + الأولى والثانية على الحرف o الثاني في boo وhoo على التوالي. أما علامة + الثالثة فتسري على المجموعة (hoo+) كاملة، فتطابق تسلسلاً واحداً أو أكثر من ذلك النوع.

والمحرف i في نهاية التعبير في المثال يجعل هذا التعبير النمطي غير حساس لحالة الأحرف، فيتيح له مطابقة B الكبيرة في النص المدخل، رغم أن النمط نفسه مكتوب كله بحروف صغيرة.

التطابقات والمجموعات

الطريقة test هي أبسط طريقة على الإطلاق لمطابقة تعبير نمطي. فهي تخبرك فقط هل تطابق أم لا، ولا شيء غير ذلك. وللتعبيرات النمطية أيضاً طريقة exec (execute) التي ستعيد null إن لم يُعثر على تطابق، وتعيد كائناً يحوي معلومات عن التطابق في غير ذلك.

let match = /\d+/.exec("one two 100");
console.log(match);
// → ["100"]
console.log(match.index);
// → 8

الكائن المُعاد من exec له خاصية index تخبرنا بأين يبدأ التطابق الناجح في النص. عدا ذلك، يشبه الكائن (وهو في الواقع كذلك) مصفوفة نصوص، عنصرها الأول هو النص الذي طوبق. في المثال السابق، هذا هو تسلسل الأرقام الذي كنا نبحث عنه.

ولقيم النصوص طريقة match تتصرف تصرفاً مشابهاً.

console.log("one two 100".match(/\d+/));
// → ["100"]

عندما يحتوي التعبير النمطي على تعبيرات فرعية مجمَّعة بأقواس هلالية، سيظهر النص الذي طابق تلك المجموعات في المصفوفة أيضاً. والتطابق الكامل هو دائماً العنصر الأول. والعنصر التالي هو الجزء الذي طابقته المجموعة الأولى (تلك التي يأتي قوسها الفتح أولاً في التعبير)، ثم المجموعة الثانية، وهكذا.

let quotedText = /'([^']*)'/;
console.log(quotedText.exec("she said 'hello'"));
// → ["'hello'", "hello"]

عندما لا تُطابَق مجموعة إطلاقاً (مثلاً، عندما تتبعها علامة استفهام)، سيشغل موضعها في مصفوفة الخرج القيمة undefined. وعندما تُطابَق مجموعة عدة مرات (مثلاً، عندما تتبعها +)، فإن آخر تطابق فقط هو الذي يظهر في المصفوفة.

console.log(/bad(ly)?/.exec("bad"));
// → ["bad", undefined]
console.log(/(\d)+/.exec("123"));
// → ["123", "3"]

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

console.log(/(?:na)+/.exec("banana"));
// → ["nana"]

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

لكن أولاً سنأخذ انحرافاً وجيزاً لمناقشة الطريقة المدمجة لتمثيل قيم التاريخ والوقت في JavaScript.

صنف Date

لدى JavaScript صنف قياسي Date لتمثيل التواريخ، أو بالأحرى نقاط في الزمن. وإن أنشأت كائن تاريخ ببساطة باستخدام new، فستحصل على التاريخ والوقت الحاليين.

console.log(new Date());
// → Fri Feb 02 2024 18:03:06 GMT+0100 (CET)

يمكنك أيضاً إنشاء كائن لزمن محدد.

console.log(new Date(2009, 11, 9));
// → Wed Dec 09 2009 00:00:00 GMT+0100 (CET)
console.log(new Date(2009, 11, 9, 12, 59, 59, 999));
// → Wed Dec 09 2009 12:59:59 GMT+0100 (CET)

تستخدم JavaScript اصطلاحاً تبدأ فيه أرقام الشهور من الصفر (فديسمبر هو 11)، بينما تبدأ أرقام الأيام من واحد. هذا مربك وسخيف. فكن حذراً.

والمعطيات الأربعة الأخيرة (الساعات والدقائق والثواني والميلي ثواني) اختيارية وتُعتبر صفراً عند عدم إعطائها.

تُخزَّن الطوابع الزمنية كعدد الميلي ثواني منذ بداية عام 1970، في المنطقة الزمنية UTC. وهذا يتبع اصطلاحاً وضعته «زمن يونكس»، الذي اخترع في ذلك الوقت تقريباً. ويمكنك استخدام أعداد سالبة للأزمنة السابقة لـ 1970. والطريقة getTime على كائن تاريخ تعيد هذا العدد. وهو كبير، كما يمكنك أن تتخيل.

console.log(new Date(2013, 11, 19).getTime());
// → 1387407600000
console.log(new Date(1387407600000));
// → Thu Dec 19 2013 00:00:00 GMT+0100 (CET)

إن أعطيت البانية Date معطى واحداً، فسيُعامَل ذلك المعطى كعدد ميلي ثوانٍ كهذا. ويمكنك الحصول على عدد الميلي ثواني الحالي بإنشاء كائن Date جديد واستدعاء getTime عليه أو باستدعاء الدالة Date.now.

توفر كائنات التاريخ طرائق مثل getFullYear وgetMonth وgetDate وgetHours وgetMinutes وgetSeconds لاستخراج مكوّناتها. وإلى جانب getFullYear هناك أيضاً getYear، التي تعطيك السنة ناقص 1900 (مثل 98 أو 125) وهي عديمة الفائدة غالباً.

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

function getDate(string) {
  let [_, month, day, year] =
    /(\d{1,2})-(\d{1,2})-(\d{4})/.exec(string);
  return new Date(year, month - 1, day);
}
console.log(getDate("1-30-2003"));
// → Thu Jan 30 2003 00:00:00 GMT+0100 (CET)

ارتباط الشرطة السفلية (_) يُتجاهل ويُستخدم فقط لتخطي عنصر التطابق الكامل في المصفوفة التي يعيدها exec.

الحدود والاستطلاع الأمامي

للأسف، ستستخرج getDate بسعادة أيضاً تاريخاً من النص "100-1-30000". فقد يحدث تطابق في أي مكان من النص، لذا في هذه الحالة، سيبدأ من المحرف الثاني وينتهي عند المحرف قبل الأخير.

إن أردنا فرض أن يمتد التطابق على النص كله، يمكننا إضافة العلامتين ^ و$. فمحرف العناية يطابق بداية النص المدخل، بينما تطابق علامة الدولار نهايته. وهكذا، /^\d+$/ تطابق نصاً يتكوّن كله من رقم واحد أو أكثر، و/^!/ تطابق أي نص يبدأ بعلامة تعجب، و/x^/ لا تطابق أي نص (إذ لا يمكن أن يكون هناك x قبل بداية النص).

وهناك أيضاً العلامة \b التي تطابق حدود الكلمات، أي المواضع التي يكون فيها محرف كلمة على جانب، ومحرف غير كلمة على الجانب الآخر. وللأسف، تستخدم هذه المفهوم المبسّط نفسه لمحارف الكلمات الذي تستخدمه \w، ولذلك فهي ليست موثوقة كثيراً.

لاحظ أن علامات الحدود هذه لا تطابق أي محارف فعلية. إنها تفرض فقط أن شرطاً معيناً يتحقق في المكان الذي تظهر فيه في النمط.

وتفعل اختبارات الاستطلاع الأمامي (look-ahead) شيئاً مشابهاً. فهي توفر نمطاً وتجعل التطابق يفشل إن لم يطابق المدخل ذلك النمط، لكنها لا تحرّك موضع التطابق فعلياً إلى الأمام. وتُكتب بين (?= و).

console.log(/a(?=e)/.exec("braeburn"));
// → ["a"]
console.log(/a(?! )/.exec("a b"));
// → null

المحرف e في المثال الأول ضروري للمطابقة، لكنه ليس جزءاً من النص المطابَق. وصيغة (?! ) تعبّر عن استطلاع أمامي سلبي. وهذا يطابق فقط إن لم يطابق النمط الموجود بين القوسين، مما يجعل المثال الثاني يطابق فقط محارف a التي ليس بعدها مسافة.

أنماط الاختيار

لنفترض أننا نريد معرفة ما إذا كان نص ما يحتوي لا على عدد فحسب، بل على عدد متبوعاً بإحدى الكلمات pig أو cow أو chicken، أو أي من صيغ جمعها.

يمكننا كتابة ثلاثة تعبيرات نمطية واختبارها بالتتابع، لكن هناك طريقة أجمل. فمحرف الأنبوب (|) يدل على اختيار بين النمط الواقع إلى يساره والنمط الواقع إلى يمينه. ويمكننا استخدامه في تعبيرات كهذه:

let animalCount = /\d+ (pig|cow|chicken)s?/;
console.log(animalCount.test("15 pigs"));
// → true
console.log(animalCount.test("15 pugs"));
// → false

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

ميكانيكا المطابقة

من حيث المفهوم، عند استخدام exec أو test، يبحث محرّك التعبيرات النمطية عن تطابق في نصك بمحاولة مطابقة التعبير أولاً من بداية النص، ثم من المحرف الثاني، وهكذا حتى يجد تطابقاً أو يصل إلى نهاية النص. وسيعيد إما أول تطابق يمكن العثور عليه أو يفشل في العثور على أي تطابق إطلاقاً.

ولإجراء المطابقة الفعلية، يعامل المحرّك التعبير النمطي كأنه مخطط انسيابي. وهذا مخطط تعبير الماشية في المثال السابق:

مخطط سكك حديدية يمر أولاً عبر صندوق مكتوب عليه 'digit'، وله حلقة تعود من بعده إلى قبله، ثم صندوق لمحرف مسافة. بعد ذلك يتفرع الخط إلى ثلاثة، ماراً بصناديق لـ 'pig' و'cow' و'chicken'. بعدها يلتقي من جديد، ويمر عبر صندوق مكتوب عليه 's'، وهو بما أنه اختياري، له أيضاً خط يمر به متجاوزاً إياه. وأخيراً يصل الخط إلى حالة القبول.

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

التتبع الخلفي

التعبير النمطي /^([01]+b|[\da-f]+h|\d+)$/ يطابق إما عدداً ثنائياً متبوعاً بـ b، أو عدداً سادس عشرياً (أي الأساس 16، حيث تمثل الحروف من a إلى f الأرقام من 10 إلى 15) متبوعاً بـ h، أو عدداً عشرياً عادياً بلا محرف لاحقة. وهذا المخطط المقابل:

مخطط سكك حديدية للتعبير النمطي '^([01]+b|+|[a-f]+h)$'

عند مطابقة هذا التعبير، كثيراً ما يُدخل الفرع العلوي (الثنائي) رغم أن المدخل لا يحتوي فعلاً على عدد ثنائي. فمثلاً، عند مطابقة النص "103"، لا يتضح إلا عند 3 أننا في الفرع الخطأ. والنص يطابق التعبير بالفعل، لكن ليس الفرع الذي نحن فيه حالياً.

لذا يتتبع المطابق خلفياً. فعند دخول فرع، يتذكر موضعه الحالي (في هذه الحالة، عند بداية النص، بعد أول صندوق حدود في المخطط مباشرة) ليتمكن من العودة ومحاولة فرع آخر إن لم ينجح الفرع الحالي. وبالنسبة للنص "103"، بعد مصادفة المحرف 3، يبدأ المطابق في تجربة فرع الأعداد السادسة عشرية، الذي يفشل بدوره لأنه لا يوجد h بعد العدد. ثم يجرب فرع العدد العشري. وهذا يناسب، ويُبلَّغ عن تطابق في نهاية الأمر.

يتوقف المطابق بمجرد أن يجد تطابقاً كاملاً. وهذا يعني أنه إن أمكن لعدة فروع أن تطابق نصاً ما، فلن يُستخدم إلا الفرع الأول (مرتباً حسب موضع الفروع في التعبير النمطي).

يحدث التتبع الخلفي أيضاً مع معاملات التكرار مثل + و*. فإن طابقت /^.*x/ مع "abcxe"، سيحاول الجزء .* أولاً استهلاك النص كله. ثم سيدرك المحرّك أنه يحتاج إلى x لمطابقة النمط. وبما أنه لا يوجد x بعد نهاية النص، تحاول معاملة النجمة مطابقة محرف واحد أقل. لكن المطابق لا يجد x بعد abcx أيضاً، فيتتبع خلفياً مرة أخرى، مطابقاً معاملة النجمة مع abc فقط. والآن يجد x حيث يحتاجه ويبلّغ عن تطابق ناجح من الموضعين 0 إلى 4.

من الممكن كتابة تعبيرات نمطية ستُجري قدراً كبيراً جداً من التتبع الخلفي. وتحدث هذه المشكلة عندما يستطيع نمط مطابقة قطعة من المدخل بطرق مختلفة كثيرة. فمثلاً، إن ارتبكنا أثناء كتابة تعبير نمطي للأعداد الثنائية، فقد نكتب عن غير قصد شيئاً مثل /([01]+)+b/.

مخطط سكك حديدية للتعبير النمطي '([01]+)+b'

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

الطريقة replace

لقيم النصوص طريقة replace يمكن استخدامها لاستبدال جزء من النص بنص آخر.

console.log("papa".replace("p", "m"));
// → mapa

يمكن أن يكون المعطى الأول أيضاً تعبيراً نمطياً، وفي هذه الحالة يُستبدل أول تطابق للتعبير النمطي. وعندما يُضاف خيار g (لـ global) بعد التعبير النمطي، تُستبدل كل التطابقات في النص، لا الأول فقط.

console.log("Borobudur".replace(/[ou]/, "a"));
// → Barobudur
console.log("Borobudur".replace(/[ou]/g, "a"));
// → Barabadar

القوة الحقيقية لاستخدام التعبيرات النمطية مع replace تأتي من كوننا نستطيع الإشارة إلى المجموعات المطابَقة في نص الاستبدال. فمثلاً، لنفترض أن لدينا نصاً كبيراً يحتوي على أسماء أشخاص، اسم واحد في كل سطر، بصيغة Lastname, Firstname. وإن أردنا تبديل هذين الاسمين وإزالة الفاصلة للحصول على صيغة Firstname Lastname، يمكننا استخدام الشيفرة التالية:

console.log(
  "Liskov, Barbara\nMcCarthy, John\nMilner, Robin"
    .replace(/(\p{L}+), (\p{L}+)/gu, "$2 $1"));
// → Barbara Liskov
//   John McCarthy
//   Robin Milner

يشير $1 و$2 في نص الاستبدال إلى المجموعتين المطوَّقتين بأقواس في النمط. فيُستبدل $1 بالنص الذي طابق المجموعة الأولى، و$2 بالثانية، وهكذا، حتى $9. ويمكن الإشارة إلى التطابق الكامل بـ $&.

من الممكن تمرير دالة — بدلاً من نص — كمعطى ثانٍ لـ replace. ولكل استبدال، ستُستدعى الدالة بالمجموعات المطابَقة (إضافة إلى التطابق الكامل) كمعطيات، وستُدرج قيمة إرجاعها في النص الجديد.

وإليك مثالاً:

let stock = "1 lemon, 2 cabbages, and 101 eggs";
function minusOne(match, amount, unit) {
  amount = Number(amount) - 1;
  if (amount == 1) { // بقي واحد فقط، أزل حرف الجمع 's'
    unit = unit.slice(0, unit.length - 1);
  } else if (amount == 0) {
    amount = "no";
  }
  return amount + " " + unit;
}
console.log(stock.replace(/(\d+) (\p{L}+)/gu, minusOne));
// → no lemon, 1 cabbage, and 100 eggs

تأخذ هذه الشيفرة نصاً، وتجد كل حالات ظهور عدد متبوع بكلمة أبجدية رقمية، وتعيد نصاً ينقص فيه واحد من كل كمية كهذه.

تنتهي المجموعة (\d+) كمعطى amount للدالة، وتُربط المجموعة (\p{L}+) بـ unit. وتحوّل الدالة amount إلى عدد — وهو ما ينجح دائماً، لأنها طابقت \d+ سابقاً — وتُجري بعض التعديلات في حال لم يبق إلا واحد أو صفر.

الشراهة

يمكننا استخدام replace لكتابة دالة تزيل كل التعليقات من قطعة من شيفرة JavaScript. وإليك محاولة أولى:

function stripComments(code) {
  return code.replace(/\/\/.*|\/\*[^]*\*\//g, "");
}
console.log(stripComments("1 + /* 2 */3"));
// → 1 + 3
console.log(stripComments("x = 10;// ten!"));
// → x = 10;
console.log(stripComments("1 /* a */+/* b */ 1"));
// → 1  1

الجزء الواقع قبل معامل | يطابق محرفي شرطة مائلة متبوعين بأي عدد من المحارف عدا محرف السطر الجديد. أما الجزء الخاص بالتعليقات متعددة الأسطر فأكثر تعقيداً. نستخدم [^] (أي محرف ليس في المجموعة الفارغة من المحارف) كطريقة لمطابقة أي محرف. ولا يمكننا استخدام نقطة هنا فقط لأن تعليقات الكتل يمكن أن تستمر على سطر جديد، ومحرف النقطة لا يطابق محارف السطر الجديد.

لكن الخرج في السطر الأخير يبدو وقد أخطأ. لماذا؟

الجزء [^]* من التعبير، كما وصفت في القسم عن التتبع الخلفي، سيطابق أولاً أكبر قدر ممكن. وإن تسبب ذلك في فشل الجزء التالي من النمط، يتحرك المطابق محرفاً واحداً إلى الخلف ويحاول مجدداً من هناك. في المثال، يحاول المطابق أولاً مطابقة بقية النص كله ثم يتحرك إلى الخلف من هناك. وسيجد حالة ظهور لـ */ بعد الرجوع أربعة محارف ويطابقها. هذا ليس ما أردناه — كانت النية مطابقة تعليق واحد، لا الذهاب إلى نهاية الشيفرة وإيجاد نهاية آخر تعليق كتلة.

بسبب هذا السلوك، نقول إن معاملات التكرار (+ و* و? و{}) شرهة، أي أنها تطابق أكبر قدر ممكن ثم تتتبع خلفياً من هناك. وإن وضعت علامة استفهام بعدها (+? و*? و?? و{}?)، تصبح غير شرهة وتبدأ بمطابقة أقل قدر ممكن، فلا تطابق أكثر إلا عندما لا يناسب النمط المتبقي التطابق الأصغر.

وهذا بالضبط ما نريده في هذه الحالة. فبجعل النجمة تطابق أصغر امتداد من المحارف يوصلنا إلى */، نستهلك تعليق كتلة واحداً ولا شيء أكثر.

function stripComments(code) {
  return code.replace(/\/\/.*|\/\*[^]*?\*\//g, "");
}
console.log(stripComments("1 /* a */+/* b */ 1"));
// → 1 + 1

يمكن ردّ كثير من العلل في برامج التعبيرات النمطية إلى استخدام معامل شره عن غير قصد حيث يعمل معامل غير شره بشكل أفضل. فعند استخدام معامل تكرار، فضّل الصيغة غير الشرهة.

إنشاء كائنات RegExp ديناميكياً

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

let name = "harry";
let regexp = new RegExp("(^|\\s)" + name + "($|\\s)", "gi");
console.log(regexp.test("Harry is a dodgy character."));
// → true

عند إنشاء الجزء \s من النص، علينا استخدام شرطتين عكسيتين لأننا نكتبهما في نص عادي، لا في تعبير نمطي مطوَّق بشرطات مائلة. ويحتوي المعطى الثاني للبانية RegExp على خيارات التعبير النمطي — في هذه الحالة، "gi" للعام وغير الحساس لحالة الأحرف.

لكن ماذا لو كان الاسم "dea+hl[]rd" لأن مستخدمنا مراهق مهووس بالتقنية؟ سيؤدي ذلك إلى تعبير نمطي لا معنى له ولن يطابق اسم المستخدم فعلاً.

للتحايل على هذا، يمكننا إضافة شرطات عكسية قبل أي محرف له معنى خاص.

let name = "dea+hl[]rd";
let escaped = name.replace(/[\\[.+*?(){|^$]/g, "\\$&");
let regexp = new RegExp("(^|\\s)" + escaped + "($|\\s)",
                        "gi");
let text = "This dea+hl[]rd guy is super annoying.";
console.log(regexp.test(text));
// → true

بينما لا يمكن استدعاء الطريقة indexOf على النصوص بتعبير نمطي، هناك طريقة أخرى، search، تتوقع فعلاً تعبيراً نمطياً. ومثل indexOf، تعيد أول فهرس وُجد فيه التعبير، أو -1 عندما لم يُوجد.

console.log("  word".search(/\S/));
// → 2
console.log("    ".search(/\S/));
// → -1

للأسف، لا توجد طريقة للإشارة إلى أن المطابقة ينبغي أن تبدأ عند إزاحة معطاة (كما يمكننا مع المعطى الثاني لـ indexOf)، وهو ما سيكون مفيداً غالباً.

خاصية lastIndex

لا توفر الطريقة exec بالمثل طريقة مريحة لبدء البحث من موضع معطى في النص. لكنها توفر طريقة غير مريحة.

لكائنات التعبيرات النمطية خصائص. وإحدى هذه الخصائص source، التي تحتوي على النص الذي أُنشئ منه التعبير. وخاصية أخرى هي lastIndex، التي تتحكم، في ظروف محدودة، في المكان الذي سيبدأ منه التطابق التالي.

وهذه الظروف هي أن يكون التعبير النمطي مفعّلاً فيه الخيار العام (g) أو اللاصق (y)، وأن تحدث المطابقة عبر الطريقة exec. ومرة أخرى، كان الحل الأقل إرباكاً هو مجرد السماح بتمرير معطى إضافي إلى exec، لكن الإرباك سمة أساسية في واجهة التعبيرات النمطية في JavaScript.

let pattern = /y/g;
pattern.lastIndex = 3;
let match = pattern.exec("xyzzy");
console.log(match.index);
// → 4
console.log(pattern.lastIndex);
// → 5

إن نجحت المطابقة، يحدّث الاستدعاء لـ exec خاصية lastIndex تلقائياً لتشير إلى ما بعد التطابق. وإن لم يُعثر على تطابق، تُعاد lastIndex إلى 0، وهي القيمة التي تحملها أيضاً في كائن تعبير نمطي مُنشأ حديثاً.

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

let global = /abc/g;
console.log(global.exec("xyz abc"));
// → ["abc"]
let sticky = /abc/y;
console.log(sticky.exec("xyz abc"));
// → null

عند استخدام قيمة تعبير نمطي مشتركة لعدة استدعاءات لـ exec، يمكن أن تسبب هذه التحديثات التلقائية لخاصية lastIndex مشكلات. فقد يبدأ تعبيرك النمطي عن غير قصد عند فهرس متبقٍ من استدعاء سابق.

let digit = /\d/g;
console.log(digit.exec("here it is: 1"));
// → ["1"]
console.log(digit.exec("and now: 1"));
// → null

وثمة تأثير مثير آخر للخيار العام هو أنه يغيّر طريقة عمل الطريقة match على النصوص. فعند استدعائها بتعبير عام، بدلاً من إرجاع مصفوفة شبيهة بتلك التي يعيدها exec، ستجد match كل تطابقات النمط في النص وتعيد مصفوفة تحتوي على النصوص المطابَقة.

console.log("Banana".match(/an/g));
// → ["an", "an"]

فكن حذراً مع التعبيرات النمطية العامة. والحالات التي تكون فيها ضرورية — استدعاءات replace والأماكن التي تريد فيها استخدام lastIndex صراحة — هي عادةً المواقف التي تريد استخدامها فيها.

من الأمور الشائعة إيجاد كل تطابقات تعبير نمطي في نص. ويمكننا فعل ذلك باستخدام الطريقة matchAll.

let input = "A string with 3 numbers in it... 42 and 88.";
let matches = input.matchAll(/\d+/g);
for (let match of matches) {
  console.log("Found", match[0], "at", match.index);
}
// → Found 3 at 14
//   Found 42 at 33
//   Found 88 at 40

تعيد هذه الطريقة مصفوفة من مصفوفات التطابق. ويجب أن يكون التعبير النمطي المعطى لـ matchAll مفعّلاً فيه g.

تحليل ملف INI

لنختم الفصل بمشكلة تستدعي التعبيرات النمطية. تخيّل أننا نكتب برنامجاً لجمع معلومات عن أعدائنا من الإنترنت تلقائياً. (لن نكتب ذلك البرنامج فعلاً هنا، فقط الجزء الذي يقرأ ملف الإعدادات. آسف.) يبدو ملف الإعدادات هكذا:

searchengine=https://duckduckgo.com/?q=$1
spitefulness=9.7

; التعليقات تسبقها فاصلة منقوطة...
; كل قسم يخص عدواً واحداً
[larry]
fullname=Larry Doe
type=kindergarten bully
website=http://www.geocities.com/CapeCanaveral/11451

[davaeorn]
fullname=Davaeorn
type=evil wizard
outputdir=/home/marijn/enemies/davaeorn

القواعد الدقيقة لهذه الصيغة — وهي صيغة ملفات مستخدمة على نطاق واسع، تسمى عادة ملف INI — هي كما يلي:

  • تُتجاهل الأسطر الفارغة والأسطر التي تبدأ بفواصل منقوطة.
  • الأسطر المطوَّقة بـ [ و] تبدأ قسماً جديداً.
  • الأسطر التي تحتوي على معرّف أبجدي رقمي متبوع بمحرف = تضيف إعداداً إلى القسم الحالي.
  • أي شيء آخر غير صالح.

مهمتنا تحويل نص كهذا إلى كائن تحمل خصائصه نصوصاً للإعدادات المكتوبة قبل أول ترويسة قسم، وكائنات فرعية للأقسام، تحمل تلك الكائنات الفرعية إعدادات القسم.

بما أن الصيغة يجب أن تُعالَج سطراً بسطر، فإن تقسيم الملف إلى أسطر منفصلة بداية جيدة. رأينا الطريقة split في الفصل 4. لكن بعض أنظمة التشغيل تستخدم لفصل الأسطر ليس محرف السطر الجديد فقط بل محرف إرجاع عربة متبوعاً بسطر جديد ("\r\n"). وبما أن الطريقة split تتيح أيضاً تعبيراً نمطياً كمعطى لها، يمكننا استخدام تعبير نمطي مثل /\r?\n/ للتقسيم بطريقة تتيح كلاً من "\n" و"\r\n" بين الأسطر.

function parseINI(string) {
  // ابدأ بكائن لحمل الحقول في المستوى الأعلى
  let result = {};
  let section = result;
  for (let line of string.split(/\r?\n/)) {
    let match;
    if (match = line.match(/^(\w+)=(.*)$/)) {
      section[match[1]] = match[2];
    } else if (match = line.match(/^\[(.*)\]$/)) {
      section = result[match[1]] = {};
    } else if (!/^\s*(;|$)/.test(line)) {
      throw new Error("Line '" + line + "' is not valid.");
    }
  };
  return result;
}

console.log(parseINI(`
name=Vasilis
[address]
city=Tessaloniki`));
// → {name: "Vasilis", address: {city: "Tessaloniki"}}

تمر الشيفرة على أسطر الملف وتبني كائناً. وتُخزَّن الخصائص في المستوى الأعلى مباشرة في ذلك الكائن، بينما تُخزَّن الخصائص الموجودة في الأقسام في كائن قسم منفصل. ويربط ارتباط section الكائن الخاص بالقسم الحالي.

هناك نوعان من الأسطر المهمة — ترويسات الأقسام أو أسطر الخصائص. وعندما يكون السطر خاصية عادية، يُخزَّن في القسم الحالي. وعندما يكون ترويسة قسم، يُنشأ كائن قسم جديد، ويُضبط section ليشير إليه.

لاحظ الاستخدام المتكرر لـ ^ و$ للتأكد من أن التعبير يطابق السطر كله، لا جزءاً منه فقط. وإغفال هذين يؤدي إلى شيفرة تعمل غالباً لكنها تتصرف بغرابة مع بعض المدخلات، وهو ما قد يكون علّة صعبة التتبع.

يستفيد النمط if (match = string.match(...)) من كون قيمة تعبير الإسناد (=) هي القيمة المُسندة. فكثيراً ما لا تكون متأكداً من أن استدعاءك لـ match سينجح، لذا لا يمكنك الوصول إلى الكائن الناتج إلا داخل جملة if تختبر ذلك. ولكي لا نكسر السلسلة الجميلة من صيغ else if، نُسند ناتج المطابقة إلى ارتباط ونستخدم ذلك الإسناد فوراً كاختبار لجملة if.

إن لم يكن السطر ترويسة قسم أو خاصية، تتحقق الدالة مما إذا كان تعليقاً أو سطراً فارغاً باستخدام التعبير /^\s*(;|$)/ لمطابقة الأسطر التي تحتوي إما على فراغ فقط، أو على فراغ متبوع بفاصلة منقوطة (مما يجعل بقية السطر تعليقاً). وعندما لا يطابق سطر أيّاً من الصيغ المتوقعة، ترمي الدالة استثناءً.

وحدات الشيفرة والمحارف

ثمة خطأ تصميمي آخر جُمّد كمعيار في التعبيرات النمطية في JavaScript، وهو أن معاملات مثل . أو ? تعمل افتراضياً على وحدات الشيفرة (كما نوقش في الفصل 5)، لا على المحارف الفعلية. وهذا يعني أن المحارف المكوّنة من وحدتي شيفرة تتصرف بغرابة.

console.log(/🍎{3}/.test("🍎🍎🍎"));
// → false
console.log(/<.>/.test("<🌹>"));
// → false
console.log(/<.>/u.test("<🌹>"));
// → true

المشكلة أن 🍎 في السطر الأول تُعامَل كوحدتي شيفرة، ويُطبَّق {3} على الوحدة الثانية فقط. وبالمثل، تطابق النقطة وحدة شيفرة واحدة، لا الوحدتين اللتين تكوّنان رمز الوردة التعبيري.

يجب إضافة خيار u (Unicode) إلى تعبيرك النمطي ليجعله يعامل هذه المحارف معاملة سليمة.

console.log(/🍎{3}/u.test("🍎🍎🍎"));
// → true

الملخص

التعبيرات النمطية كائنات تمثل أنماطاً في النصوص. وهي تستخدم لغتها الخاصة للتعبير عن هذه الأنماط.

/abc/ تسلسل من المحارف
/[abc]/ أي محرف من مجموعة محارف
/[^abc]/ أي محرف ليس في مجموعة محارف
/[0-9]/ أي محرف في مدى من المحارف
/x+/ ظهور واحد أو أكثر للنمط x
/x+?/ ظهور واحد أو أكثر، غير شره
/x*/ صفر ظهور أو أكثر
/x?/ صفر أو ظهور واحد
/x{2,4}/ من ظهورين إلى أربعة ظهورات
/(abc)/ مجموعة
`/a b
/\d/ أي محرف رقمي
/\w/ محرف أبجدي رقمي («محرف كلمة»)
/\s/ أي محرف فراغ
/./ أي محرف عدا أسطر الجديدة
/\p{L}/u أي محرف حرف
/^/ بداية المدخل
/$/ نهاية المدخل
/(?=a)/ اختبار استطلاع أمامي

للتعبير النمطي طريقة test لاختبار ما إذا كان نص معطى يطابقه. وله أيضاً طريقة exec التي تعيد، عند العثور على تطابق، مصفوفة تحتوي على كل المجموعات المطابَقة. ولهذه المصفوفة خاصية index تشير إلى المكان الذي بدأ منه التطابق.

وللنصوص طريقة match لمطابقتها مع تعبير نمطي وطريقة search للبحث عن واحد، تعيد موضع بداية التطابق فقط. وطريقتهما replace يمكنها استبدال تطابقات نمط بنص استبدال أو دالة استبدال.

يمكن أن يكون للتعبيرات النمطية خيارات، تُكتب بعد الشرطة المائلة الختامية. فالخيار i يجعل المطابقة غير حساسة لحالة الأحرف. والخيار g يجعل التعبير عاماً، مما يؤدي، بين أمور أخرى، إلى أن الطريقة replace تستبدل كل الحالات لا الأولى فقط. والخيار y يجعل التعبير لاصقاً، أي أنه لن يبحث إلى الأمام ويتخطى جزءاً من النص عند البحث عن تطابق. والخيار u يشغّل وضع Unicode، الذي يتيح صيغة \p ويصلح عدداً من المشكلات المتعلقة بالتعامل مع المحارف التي تشغل وحدتي شيفرة.

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

التمارين

يكاد يكون لا مفر من أن ترتبك وتُصاب بالإحباط، أثناء العمل على هذه التمارين، بسبب سلوك بعض التعبيرات النمطية الذي لا يمكن تفسيره. وأحياناً يساعد إدخال تعبيرك في أداة على الإنترنت مثل debuggex.com لترى ما إذا كان تصويرها البصري يوافق ما قصدته ولتجرب كيف تستجيب لمختلف النصوص المدخلة.

غولف التعبيرات النمطية

غولف الشيفرة مصطلح يُستخدم للعبة محاولة التعبير عن برنامج معين بأقل عدد ممكن من المحارف. وبالمثل، غولف التعبيرات النمطية هو ممارسة كتابة أصغر تعبير نمطي ممكن لمطابقة نمط معطى وفقط ذلك النمط.

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

  1. car وcat
  2. pop وprop
  3. ferret وferry وferrari
  4. أي كلمة تنتهي بـ ious
  5. محرف فراغ متبوع بنقطة أو فاصلة أو نقطتين رأسيتين أو فاصلة منقوطة
  6. كلمة أطول من ستة أحرف
  7. كلمة بدون الحرف e (أو E)

راجع الجدول في ملخص الفصل للمساعدة. واختبر كل حل ببضعة نصوص اختبارية.

// املأ التعبيرات النمطية

verify(/.../,
       ["my car", "bad cats"],
       ["camper", "high art"]);

verify(/.../,
       ["pop culture", "mad props"],
       ["plop", "prrrop"]);

verify(/.../,
       ["ferret", "ferry", "ferrari"],
       ["ferrum", "transfer A"]);

verify(/.../,
       ["how delicious", "spacious room"],
       ["ruinous", "consciousness"]);

verify(/.../,
       ["bad punctuation ."],
       ["escape the period"]);

verify(/.../,
       ["Siebentausenddreihundertzweiundzwanzig"],
       ["no", "three small words"]);

verify(/.../,
       ["red platypus", "wobbling nest"],
       ["earth bed", "bedrøvet abe", "BEET"]);

function verify(regexp, yes, no) {
  // تجاهل التمارين غير المكتملة
  if (regexp.source == "...") return;
  for (let str of yes) if (!regexp.test(str)) {
    console.log(`Failure to match '${str}'`);
  }
  for (let str of no) if (regexp.test(str)) {
    console.log(`Unexpected match for '${str}'`);
  }
}

أسلوب الاقتباس

تخيّل أنك كتبت قصة واستخدمت علامات اقتباس مفردة في كل مكان لتمييز قطع الحوار. والآن تريد استبدال كل علامات اقتباس الحوار بعلامات اقتباس مزدوجة، مع الإبقاء على العلامات المفردة المستخدمة في الانقباضات مثل aren't.

فكّر في نمط يميّز هذين النوعين من استخدام الاقتباس واكتب استدعاءً للطريقة replace يقوم بالاستبدال الصحيح.

let text = "'I'm the cook,' he said, 'it's my job.'";
// غيّر هذا الاستدعاء.
console.log(text.replace(/A/g, "B"));
// → "I'm the cook," he said, "it's my job."
إظهار التلميح

الحل الأكثر وضوحاً هو استبدال علامات الاقتباس التي على جانب واحد منها على الأقل محرف غير حرف فقط — شيء مثل /\P{L}'|'\P{L}/u. لكن عليك أيضاً أن تأخذ بداية السطر ونهايته في الحسبان.

وإضافة إلى ذلك، يجب أن تتأكد من أن الاستبدال يتضمن أيضاً المحارف التي طابقها النمط \P{L} حتى لا تُسقط. ويمكن فعل ذلك بتطويقها بأقواس هلالية وتضمين مجموعاتها في نص الاستبدال ($1، $2). والمجموعات غير المطابَقة سيُستبدل بها لا شيء.

الأعداد مرة أخرى

اكتب تعبيراً يطابق أعداد JavaScript فقط. ويجب أن يدعم إشارة سالب أو زائد اختيارية أمام العدد، والنقطة العشرية، وصيغة الأس — 5e-3 أو 1E10 — مع إشارة اختيارية أمام الأس أيضاً. ولاحظ أيضاً أنه ليس ضرورياً وجود أرقام قبل النقطة أو بعدها، لكن العدد لا يمكن أن يكون نقطة وحدها. أي أن .5 و5. عددان صالحان في JavaScript، أما نقطة وحيدة فليست كذلك.

// املأ هذا التعبير النمطي.
let number = /^...$/;

// اختبارات:
for (let str of ["1", "-1", "+15", "1.55", ".5", "5.",
                 "1.3e2", "1E-4", "1e+12"]) {
  if (!number.test(str)) {
    console.log(`Failed to match '${str}'`);
  }
}
for (let str of ["1a", "+-1", "1.2.3", "1+1", "1e4.5",
                 ".5.", "1f5", "."]) {
  if (number.test(str)) {
    console.log(`Incorrectly accepted '${str}'`);
  }
}
إظهار التلميح

أولاً، لا تنسَ الشرطة العكسية قبل النقطة.

يمكن مطابقة الإشارة الاختيارية أمام العدد، وكذلك أمام الأس، بـ [+\-]? أو (\+|-|) (زائد، ناقص، أو لا شيء).

الجزء الأكثر تعقيداً في التمرين هو مشكلة مطابقة كل من "5." و".5" دون مطابقة "." أيضاً. ولهذا، الحل الجيد هو استخدام معامل | للفصل بين الحالتين — إما رقم واحد أو أكثر متبوع اختيارياً بنقطة وصفر أرقام أو أكثر أو نقطة متبوعة برقم واحد أو أكثر.

وأخيراً، لجعل e غير حساسة لحالة الأحرف، إما أضف خيار i إلى التعبير النمطي أو استخدم [eE].