ميزات Dart المتقدمة

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

45 دقيقة الدرس 11 من 16

مقدمة في التعبيرات النمطية

التعبيرات النمطية (regex) هي أنماط قوية تُستخدم لمطابقة النصوص والبحث فيها ومعالجتها. في Dart، توفر فئة RegExp دعماً كاملاً للتعبيرات النمطية بناءً على مواصفات ECMAScript (JavaScript). سواء كنت تحتاج التحقق من المدخلات أو استخراج البيانات أو تحويل النصوص، فإن regex أداة أساسية.

إنشاء RegExp

void main() {
  // الطريقة 1: منشئ RegExp
  final digitPattern = RegExp(r'\d+');

  // الطريقة 2: مع الأعلام
  final caseInsensitive = RegExp(r'hello', caseSensitive: false);
  final multiLine = RegExp(r'^\w+', multiLine: true);
  final dotAll = RegExp(r'start.*end', dotAll: true);  // . تطابق أسطر جديدة

  // الطريقة 3: وضع يونيكود
  final unicode = RegExp(r'\p{L}+', unicode: true);  // فئات أحرف يونيكود
}
ملاحظة: استخدم دائماً النصوص الخام (r'...') لأنماط regex. بدون البادئة r، يفسّر Dart الخطوط المائلة العكسية كأحرف هروب، لذا '\d' يجب كتابتها كـ '\\d'. النصوص الخام تمرر الخط المائل العكسي حرفياً.

بناء الأنماط الأساسي

إليك مرجعاً سريعاً لأكثر أنماط regex شيوعاً في Dart:

عناصر الأنماط الشائعة

void main() {
  // فئات الأحرف
  RegExp(r'\d');      // أي رقم (0-9)
  RegExp(r'\D');      // أي غير رقم
  RegExp(r'\w');      // حرف كلمة (a-z, A-Z, 0-9, _)
  RegExp(r'\W');      // حرف غير كلمة
  RegExp(r'\s');      // مسافة بيضاء (مسافة، تبويب، سطر جديد)
  RegExp(r'\S');      // غير مسافة بيضاء
  RegExp(r'[aeiou]'); // أي حرف علة
  RegExp(r'[^aeiou]');// أي غير حرف علة
  RegExp(r'[a-zA-Z]');// أي حرف

  // المُحددات الكمية
  RegExp(r'a*');      // صفر أو أكثر من 'a'
  RegExp(r'a+');      // واحد أو أكثر من 'a'
  RegExp(r'a?');      // صفر أو واحد 'a'
  RegExp(r'a{3}');    // بالضبط 3 'a'
  RegExp(r'a{2,5}');  // بين 2 و 5 'a'
  RegExp(r'a{2,}');   // 2 أو أكثر 'a'

  // المراسي
  RegExp(r'^start');  // تطابق في البداية
  RegExp(r'end$');    // تطابق في النهاية
  RegExp(r'\bhello\b'); // حدود الكلمة

  // التناوب
  RegExp(r'cat|dog'); // تطابق 'cat' أو 'dog'
}

المطابقة: hasMatch و firstMatch و allMatches

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

طرق المطابقة الثلاث

void main() {
  final pattern = RegExp(r'\d+');
  final text = 'Order 42 has 3 items worth 150 dollars';

  // hasMatch — تُرجع true/false
  print(pattern.hasMatch(text));  // true
  print(pattern.hasMatch('no numbers here'));  // false

  // firstMatch — تُرجع أول RegExpMatch أو null
  final first = pattern.firstMatch(text);
  if (first != null) {
    print('تطابق: ${first.group(0)}');  // تطابق: 42
    print('البداية: ${first.start}');      // البداية: 6
    print('النهاية: ${first.end}');          // النهاية: 8
  }

  // allMatches — تُرجع جميع التطابقات كـ Iterable
  final all = pattern.allMatches(text);
  print('وُجد ${all.length} تطابقات:');
  for (final match in all) {
    print('  "${match.group(0)}" في الموضع ${match.start}');
  }
  // وُجد 3 تطابقات:
  //   "42" في الموضع 6
  //   "3" في الموضع 13
  //   "150" في الموضع 26
}

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

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

استخدام مجموعات الالتقاط

void main() {
  // نمط مع مجموعات التقاط لتحليل التاريخ
  final datePattern = RegExp(r'(\d{4})-(\d{2})-(\d{2})');
  final text = 'تاريخ الحدث: 2024-03-15';

  final match = datePattern.firstMatch(text);
  if (match != null) {
    print('التطابق الكامل: ${match.group(0)}');  // 2024-03-15
    print('السنة: ${match.group(1)}');          // 2024
    print('الشهر: ${match.group(2)}');         // 03
    print('اليوم: ${match.group(3)}');           // 15
  }

  // مجموعة غير ملتقطة (?:...) — تجمع بدون التقاط
  final timePattern = RegExp(r'(\d{1,2}):(\d{2})(?:\s)?(AM|PM)?', caseSensitive: false);
  final time = 'الاجتماع في 2:30 PM';

  final timeMatch = timePattern.firstMatch(time);
  if (timeMatch != null) {
    print('الساعة: ${timeMatch.group(1)}');    // 2
    print('الدقيقة: ${timeMatch.group(2)}');  // 30
    print('الفترة: ${timeMatch.group(3)}');  // PM
  }
}

مجموعات الالتقاط المسمّاة

المجموعات المسمّاة تجعل regex أكثر قراءة بإعطاء أسماء ذات معنى للأجزاء الملتقطة، باستخدام الصيغة (?<name>...).

المجموعات المسمّاة للوضوح

void main() {
  final urlPattern = RegExp(
    r'(?<protocol>https?)://(?<host>[\w.-]+)(?::(?<port>\d+))?(?<path>/[\w/.-]*)?'
  );

  final url = 'https://api.example.com:8080/v2/users';
  final match = urlPattern.firstMatch(url);

  if (match != null) {
    print('البروتوكول: ${match.namedGroup('protocol')}');  // https
    print('المضيف: ${match.namedGroup('host')}');          // api.example.com
    print('المنفذ: ${match.namedGroup('port')}');          // 8080
    print('المسار: ${match.namedGroup('path')}');          // /v2/users
  }

  // تحليل عدة روابط
  final text = '''
زر http://example.com أو https://secure.site.org:443/login
  ''';

  for (final m in urlPattern.allMatches(text)) {
    print('${m.namedGroup('protocol')}://${m.namedGroup('host')}');
  }
  // http://example.com
  // https://secure.site.org
}
نصيحة: فضّل المجموعات المسمّاة على المجموعات المرقمة للأنماط المعقدة. match.namedGroup('host') أكثر قراءة بكثير من match.group(2)، ويبقى النمط صحيحاً حتى لو أضفت أو أزلت مجموعات في وقت سابق من التعبير.

النظر للأمام والنظر للخلف

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

النظر للأمام والنظر للخلف

void main() {
  // النظر للأمام الإيجابي (?=...) — تطابق فقط إذا تلاها النمط
  final beforeDollar = RegExp(r'\d+(?=\s*dollars)');
  print(beforeDollar.firstMatch('Price is 50 dollars')?.group(0));  // 50
  print(beforeDollar.firstMatch('Count is 50 items')?.group(0));    // null

  // النظر للأمام السلبي (?!...) — تطابق فقط إذا لم يتلوها النمط
  final notDollar = RegExp(r'\d+(?!\s*dollars)');
  final matches = notDollar.allMatches('50 dollars and 30 items');
  for (final m in matches) {
    print(m.group(0));  // 30 (50 متبوعة بـ dollars لذا استُبعدت)
  }

  // النظر للخلف الإيجابي (?<=...) — تطابق فقط إذا سبقها النمط
  final afterDollar = RegExp(r'(?<=\$)\d+');
  print(afterDollar.firstMatch('السعر: \$150')?.group(0));  // 150

  // النظر للخلف السلبي (?<!...) — تطابق فقط إذا لم يسبقها النمط
  final notAfterHash = RegExp(r'(?<!#)\b\w+\b');
  final tags = 'hello #world foo #bar';
  for (final m in notAfterHash.allMatches(tags)) {
    print(m.group(0));  // hello, foo (كلمات لم يسبقها #)
  }
}
تحذير: تأكيدات النظر للخلف في Dart (عبر محرك regex JavaScript) تتطلب أنماطاً ذات طول ثابت في بعض البيئات. النظر للخلف بطول متغير مثل (?<=a+) قد لا يعمل في جميع منصات Dart. استخدم النظر للخلف بطول ثابت عند الإمكان، مثل (?<=abc) أو (?<=\d{3}).

replaceAll و replaceAllMapped

استبدال النصوص مع regex هو أحد أكثر الاستخدامات العملية. replaceAll يقوم باستبدال بسيط، بينما replaceAllMapped يمنحك الوصول لكل تطابق للاستبدال الديناميكي.

الاستبدال البسيط والمُعيّن

void main() {
  // replaceAll بسيط مع regex
  final text = 'Hello   World   Dart';
  final cleaned = text.replaceAll(RegExp(r'\s+'), ' ');
  print(cleaned);  // Hello World Dart

  // replaceAllMapped — استبدال ديناميكي باستخدام بيانات التطابق
  final template = 'لدي 3 قطط و 12 كلباً';
  final doubled = template.replaceAllMapped(
    RegExp(r'\d+'),
    (match) => '${int.parse(match.group(0)!) * 2}',
  );
  print(doubled);  // لدي 6 قطط و 24 كلباً

  // تنسيق أرقام الهاتف
  final phone = '5551234567';
  final formatted = phone.replaceAllMapped(
    RegExp(r'(\d{3})(\d{3})(\d{4})'),
    (m) => '(${m.group(1)}) ${m.group(2)}-${m.group(3)}',
  );
  print(formatted);  // (555) 123-4567

  // تحويل camelCase إلى snake_case
  final camel = 'myVariableName';
  final snake = camel.replaceAllMapped(
    RegExp(r'[A-Z]'),
    (m) => '_${m.group(0)!.toLowerCase()}',
  );
  print(snake);  // my_variable_name
}

التقسيم مع Regex

طريقة String.split() تقبل Pattern، الذي يشمل RegExp. هذا يسمح بمنطق تقسيم معقد يتجاوز نصوص الفاصل البسيطة.

تقسيم النصوص المتقدم

void main() {
  // تقسيم على أي مسافة بيضاء (يعالج مسافات متعددة، تبويبات، إلخ)
  final messy = 'Hello\t\tWorld   Dart\nFlutter';
  final words = messy.split(RegExp(r'\s+'));
  print(words);  // [Hello, World, Dart, Flutter]

  // تقسيم على علامات الترقيم
  final sentence = 'Hello,World;Dart.Flutter!Rocks';
  final parts = sentence.split(RegExp(r'[,;.!]'));
  print(parts);  // [Hello, World, Dart, Flutter, Rocks]

  // تقسيم CSV مع مراعاة الحقول المقتبسة (مبسط)
  final csv = 'name,age,"city, state",score';
  final fields = RegExp(r'(?:^|,)("(?:[^"]*)"|\w[^,]*)')
      .allMatches(csv)
      .map((m) => m.group(1)!.replaceAll('"', ''))
      .toList();
  print(fields);  // [name, age, city, state, score]

  // تقسيم مع الاحتفاظ بالفواصل
  final expr = '3+5*2-1';
  final tokens = expr.split(RegExp(r'(?=[+*-])|(?<=[+*-])'));
  print(tokens);  // [3, +, 5, *, 2, -, 1]
}

مثال عملي: التحقق من البريد الإلكتروني

بينما التحقق المثالي من البريد يتطلب الامتثال لـ RFC 5322، فإن regex عملي يغطي الغالبية العظمى من عناوين البريد في العالم الحقيقي.

مُحقق البريد الإلكتروني

class EmailValidator {
  // regex عملي للبريد (يغطي 99%+ من رسائل البريد الحقيقية)
  static final _pattern = RegExp(
    r'^[a-zA-Z0-9.!#$%&\x27*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$',
  );

  static bool isValid(String email) => _pattern.hasMatch(email);

  static String? validate(String email) {
    if (email.isEmpty) return 'البريد مطلوب';
    if (!isValid(email)) return 'تنسيق البريد غير صالح';
    if (email.length > 254) return 'البريد طويل جداً';
    return null;  // صالح
  }
}

void main() {
  final testEmails = [
    'user@example.com',
    'first.last@company.co.uk',
    'invalid@',
    '@no-local.com',
    'spaces in@email.com',
    'valid+tag@gmail.com',
  ];

  for (final email in testEmails) {
    final error = EmailValidator.validate(email);
    print('$email: ${error ?? "صالح"}');
  }
}

مثال عملي: محرك القوالب

Regex مثالي لبناء محرك قوالب بسيط يستبدل العناصر النائبة بالقيم.

محرك قوالب بسيط

/// محرك قوالب بسيط يستخدم regex لاستبدال العناصر النائبة.
class TemplateEngine {
  // يطابق {{variableName}} أو {{ variableName }}
  static final _placeholder = RegExp(r'\{\{\s*(\w+)\s*\}\}');

  // يطابق {{#if condition}}...{{/if}}
  static final _conditional = RegExp(
    r'\{\{#if\s+(\w+)\}\}(.*?)\{\{/if\}\}',
    dotAll: true,
  );

  static String render(String template, Map<String, dynamic> data) {
    // معالجة الشروط أولاً
    var result = template.replaceAllMapped(_conditional, (match) {
      final condition = match.group(1)!;
      final content = match.group(2)!;
      final value = data[condition];
      if (value == true || (value != null && value != false && value != '')) {
        return content;
      }
      return '';
    });

    // ثم استبدال العناصر النائبة
    result = result.replaceAllMapped(_placeholder, (match) {
      final key = match.group(1)!;
      return data[key]?.toString() ?? '';
    });

    return result;
  }
}

void main() {
  final template = '''
مرحباً {{ name }}!
{{#if isPremium}}مرحباً بعودتك، عضو مميز!{{/if}}
رصيدك \${{ balance }}.
  ''';

  print(TemplateEngine.render(template, {
    'name': 'Edrees',
    'isPremium': true,
    'balance': '250.00',
  }));
  // مرحباً Edrees!
  // مرحباً بعودتك، عضو مميز!
  // رصيدك $250.00.
}

مثال عملي: محلل السجلات

تحليل ملفات السجلات المنظمة هو حالة استخدام كلاسيكية لـ regex مع مجموعات الالتقاط المسمّاة.

محلل ملفات السجلات

class LogEntry {
  final DateTime timestamp;
  final String level;
  final String component;
  final String message;

  LogEntry(this.timestamp, this.level, this.component, this.message);

  @override
  String toString() => '[$level] $component: $message';
}

class LogParser {
  // يطابق: [2024-03-15 14:30:00] ERROR (Auth): Login failed
  static final _pattern = RegExp(
    r'\[(?<date>\d{4}-\d{2}-\d{2})\s+(?<time>\d{2}:\d{2}:\d{2})\]\s+'
    r'(?<level>DEBUG|INFO|WARN|ERROR|FATAL)\s+'
    r'\((?<component>\w+)\):\s+(?<message>.+)',
  );

  static LogEntry? parse(String line) {
    final match = _pattern.firstMatch(line);
    if (match == null) return null;

    final dateStr = match.namedGroup('date')!;
    final timeStr = match.namedGroup('time')!;
    final timestamp = DateTime.parse('$dateStr $timeStr');

    return LogEntry(
      timestamp,
      match.namedGroup('level')!,
      match.namedGroup('component')!,
      match.namedGroup('message')!,
    );
  }

  static List<LogEntry> parseAll(String logText) {
    return logText
        .split('\n')
        .map(parse)
        .whereType<LogEntry>()
        .toList();
  }
}

void main() {
  final logText = '''
[2024-03-15 14:30:00] INFO (Auth): تسجيل دخول المستخدم
[2024-03-15 14:30:05] ERROR (Database): انتهاء مهلة الاتصال
[2024-03-15 14:30:10] WARN (Cache): فقدان ذاكرة مؤقتة للمفتاح user_123
  ''';

  final entries = LogParser.parseAll(logText);
  final errors = entries.where((e) => e.level == 'ERROR');
  print('الأخطاء الموجودة: ${errors.length}');
  for (final error in errors) {
    print(error);  // [ERROR] Database: انتهاء مهلة الاتصال
  }
}
نصيحة: عند بناء أنماط regex معقدة في Dart، يمكنك تقسيمها عبر أسطر متعددة باستخدام ربط النصوص. كل جزء يمكن أن يحتوي على تعليق يشرح غرضه، مما يجعل النمط الكلي أسهل في الصيانة.

اعتبارات الأداء

يمكن أن يكون Regex قوياً لكنه بطيء أيضاً إذا أُسيء استخدامه. إليك نصائح أداء رئيسية:

أفضل ممارسات أداء Regex

// سيء: إنشاء RegExp جديد في حلقة
for (var line in lines) {
  if (RegExp(r'\d+').hasMatch(line)) {  // يترجم regex كل تكرار!
    // ...
  }
}

// جيد: ترجمة مرة واحدة وإعادة الاستخدام
final digitPattern = RegExp(r'\d+');
for (var line in lines) {
  if (digitPattern.hasMatch(line)) {  // يعيد استخدام regex المترجم
    // ...
  }
}

// سيء: التراجع الكارثي
// final bad = RegExp(r'(a+)+b');  // يمكن أن يتوقف على 'aaaaaaaaaaac'

// جيد: إعادة كتابة لتجنب المحددات الكمية المتداخلة
final good = RegExp(r'a+b');

// نصيحة: استخدم المحددات الكمية الممتلكة أو المجموعات الذرية عند التوفر
// لمنع التراجع على الأنماط التي لا يجب أن تعيد المحاولة
تحذير: التراجع الكارثي يحدث عندما يحتوي regex على محددات كمية متداخلة مثل (a+)+ والمدخل لا يتطابق. يحاول محرك regex عدداً أسياً من التركيبات قبل الاستسلام. تجنب دائماً أنماط التكرار المتداخلة واختبر regex الخاص بك مع أسوأ المدخلات الممكنة.

الملخص

التعبيرات النمطية أداة متعددة الاستخدامات وقوية في Dart. النقاط الرئيسية:

  • استخدم النصوص الخام (r'...') لأنماط regex لتجنب التخليص المزدوج.
  • hasMatch للفحوصات المنطقية، firstMatch للنتيجة الأولى، allMatches لجميع النتائج.
  • مجموعات الالتقاط (...) والمجموعات المسمّاة (?<name>...) تستخرج أجزاء محددة من التطابقات.
  • النظر للأمام (?=...) والنظر للخلف (?<=...) يؤكدان الموضع بدون استهلاك الأحرف.
  • replaceAllMapped يمكّن استبدال النصوص الديناميكي والمدرك للسياق.
  • ترجم regex مرة واحدة وأعد الاستخدام؛ تجنب المحددات الكمية المتداخلة لمنع التراجع الكارثي.