Случилось мне задание: проверить, есть ли XML-имя правильным. Что может быть проще? Смотрим стандарт, где четко описано, какими символами может имя начинатся, а какими — продолжаться, все просто и понятно:
[4] NameStartChar ::= ":" | [A-Z] | "_" | [a-z] | [#xC0-#xD6] | [#xD8-#xF6] | [#xF8-#x2FF] | [#x370-#x37D] | [#x37F-#x1FFF] | [#x200C-#x200D] | [#x2070-#x218F] | [#x2C00-#x2FEF] | [#x3001-#xD7FF] | [#xF900-#xFDCF] | [#xFDF0-#xFFFD] | [#x10000-#xEFFFF]
[4a] NameChar ::= NameStartChar | "-" | "." | [0-9] | #xB7 | [#x0300-#x036F] | [#x203F-#x2040]
[5] Name ::= NameStartChar (NameChar)*
Практически готовое регулярное выражение, легкая обработка напильником Ctrl+H…
public const string NameStartCharPattern = @":|[A-Z]|_|[a-z]|[u00C0-u00D6]|[u00D8-u00F6]|[u00F8-u02FF]|[u0370-u037D]|[u037F-u1FFF]|[u200C-u200D]|[u2070-u218F]|[u2C00-u2FEF]|[u3001-uD7FF]|[uF900-uFDCF]|[uFDF0-uFFFD]|[u10000-uEFFFF]";
public const string NameCharPattern = NameStartCharPattern + @"|-|.|[0-9]|u00B7|[u0300-u036F]|[u203F-u2040]";
public const string NamePattern = @"(?:" + NameStartCharPattern + @")(?:" + NameCharPattern + @")*";
* This source code was highlighted with Source Code Highlighter.
Пишем тест…
Assert.That(Regex.Match("1a", Patterns.NamePattern), Is.False);
* This source code was highlighted with Source Code Highlighter.
Чисто, просто, понятно… Упал!
Корнем зла оказался последний компонент в первой строке: [u10000-uEFFFF]. Он ловит все символы, хотя и не должен… Стоп, как ловит? У нас же UTF-16, символ ограничен двумя байтами?.. Или не ограничен?..
Мне пришлось срочно занятся ликвидацией собственной безграмотности в области кодировок, и результаты своего образования я привожу в короткой форме здесь. Если кому-то эти факты окажутся давно знакомыми — смело пропускайте следующий абзац.
Оказывается, Unicode имеет возможность кодировать гораздо больше, чем 65536 символов. Символы Unicode поделены на так называемые плоскости, и каждая из них ёмкостью в 0x10000 символов. Всего стандарт определяет их 17. И такое «кривое» с точки зрения программиста число здесь неспроста: по сути мы имеем одну плоскость, которая обрабатывается одним способом, и 16 — другим. Первая, так называемая базовая многоязыковая плоскость, известная также под аббревиатурой BMP, содержит подавляющее большинство всех используемых на сегодня символов. Все символы из неё при кодировании в UTF-16 записываются двумя байтами, одним словом, прямо соответствующими коду символа в них. В этой же плоскости определен специальный диапазон кодов, 0xD800-0xDFFF. Он содержит 2048 значений, которые называются суррогатами. Сами по себе эти значения в UTF-16 встречатся не могут, только парами — два слова (два по два байта) задают значение из следующих шестнадцати панелей следующим образом: от кода символа отнимается 0x10000, что дает нам чистое двадцатибитное число. Эти 20 бит пишутся по 10 в первое и второе слово, занимая таким образом 2048 выделенных кодов. Более того, поскольку первое слово пишется с префиксом 0b110110 (давая при этом значения 0xD800-0xDBFF, называемые высоким или ведущим суррогатом), а второе — 0b110111(0xDC00-0xDFFF, соответственно заключительный или низкий суррогат), это гарантирует однозначное определение предназначения каждого слова вне зависимости от контекста.
… Так вот, казалось бы при чем тут .Net? А при том, что хотя в нем предусмотрены инструменты для работы с суррогатами, движок регулярных выражений игнорирует их. Тоесть игнорирует вообще, работая с с ними как парами символов. Как обычно в таких случаях, я был не первым, нашедшим эту проблему. Опять-таки, как обычно, резолюция Microsoft — Won't fix.
Значит, придется как-то с этим жить. Как предложено в багрепорте вызывать через PInvoke сторонний движок — из пушки по воробьям. Вторая идея — выбросить к черту вообще поддержку этих суррогатов была соблазнительной, но я решил не сдаватся… И тут вдруг понял, что баг можна использовать как фичу!
Структура группы, которая должна работать с суррогатами в нашем случае очень проста — по сути она разрешает любые символы из первых 14 плоскостей, запрещая две последние… Тоесть, запрещает некоторый диапазон значений из области высокого суррогата, и мы можем заменить наше выражение на следующее:
[u10000-uEFFFF] -> (?:[uD800-uDB7F][uDC00-uDFFF])
Этот способ не очень универсален, и задавать ним более узкие будет ужасно неудобно, мне он показался красивым, и поэтому я решил им поделится.
Автор: professor_k