23.02.2009Регулярные выражения: радость победы
С регулярными выражениями я знаком не очень хорошо. Поэтому каждый раз, когда нужно что-то сделать, приходится собираться с силами. Но зато когда это сделать удается, наступает радость и счастье.
Задача
Сделать форматирование текста для блога, чтобы:
- Можно было вставлять подзаголовки;
- Текст разбивался на параграфы и просто переносы строки;
- Со вставками кода ничего не происходило;
- Было написано на ruby. Использовать RedCloth не хотелось, а стандартное форматирование не подходило. Поэтому приступим.
Вытащить код
Для того, чтобы не делать лишних проверок, вытаскиваем код из страницы. Код находится внутри тэга <pre>. Первое, что приходит на ум, это выражение типа «<pre> слева, </pre> справа и ни одного </pre> посередине.». Но оказалось, что исключить выражение невозможно (по крайней мере, я не нашёл способа). Выражение типа
/<pre>[^(<\/pre>)]+<\/pre>/
По крайней мере в ruby, интерпретируется как «тэг <pre>, внутри которого не встревается ни "<", ни "p", ни "r"... и т.д.»
Для этого понадобится концепция «жадности». То есть:
/<pre>.+<\/pre>/
Cоответствует куску от первого «<pre>» до последнего «</pre>». А нам нужно жадное:
/<pre>.+?<\/pre>/
То есть до ближайшего.
Теперь про wild card. Оказалось, что точка не включает перенос строки. Поэтому нам понадобится что-то более дикое. Wild, wild card. На эту роль подходит /[\s\S]/: пробельный символ или непробельный.
Итак, вытаскивание кусков кода выглядит так:
codes = []
res.gsub!(/<pre>[\s\S]+?<\/pre>/) do |s|
codes.push(s)
"code#{codes.length - 1}"
end
Вокруг кусков кода
Дальше задачи попроще. Приведение переноса строки к единому виду, замена выбранных выражений для заголовков на тэги заголовков, замена двух и более переносов строки на параграф. Это не представляет особых сложностей. Меня интересует, чтобы параграф кончился до кода и начался после, даже если там всего один перенос строки.
Что касается «кончился до», то тут используется lookahead (то есть операция при условии, что впереди есть что-то):
res.gsub!(/\n(?=code\d+)/, "</p><p>")
А чтобы начать параграф после куска кода, нам понадобится lookbehind (то есть операция при условии, что перед совпадением есть что-то), который в ruby не работает (по крайней мере в версии 1.8.7). поэтому здесь мы используем группы. И включим группу в результат:
res.gsub!(/(code\d+)\n/, '\1</p><p>')
Видите, вот этот \1?
Остались мелочи: вставить обратно куски кода. Убрать пустые параграфы и параграфы, окружающие куски кода. И вы видите то, что обрабатывает текст этого сообщения.
application_helper.rb:
...
def lonelyelk_format(text)
res = "<p>" + text.to_s.dup
codes = []
res.gsub!(/<pre>[\s\S]+?<\/pre>/) do |s| # вытаскиваем куски кода
codes.push(s)
"code#{codes.length - 1}"
end
res.gsub!(/\r\n?/, "\n") # приводим перево каретки к одному виду
res.gsub!(/\n*\[h\]\n*/, "</p><h2>") # заголовки начало [h]
res.gsub!(/\n*\[\/h\]\n*/, "</h2><p>") # заголовки конец [/h]
res.gsub!(/\n\n+/, "</p><p>") # более одного переноса строки - параграф
res.gsub!(/\n(?=code\d+)/, "</p><p>") # параграф перед кодом
res.gsub!(/(code\d+)\n/, '\1</p><p>') # параграф после кода
res.gsub!("\n", "<br />") # единичный перенос строки
res.gsub!(/(<p>)?code\d+(<\/p>)?/) do |s| # вставляем код обратно
codes[s[4,1].to_i] # здесь ошибка :)
end
res.gsub!("<p></p>", "") # убираем пустые параграфы
res += "</p>"
end
...
Остается одна проблема. Нельзя написать в тексте поста выражение «сode{цифры}». Но для этого просто можно генерировать случайный маркер, которого точно нет в тексте вместо «code».
Обновление
После того, как я попытался написать данный пост, я обнаружил ещё ряд интересных особенностей поведения кода и браузера. А так же нашёл ошибку. Публикую финальный код без пояснений:
application_helper.rb:
...
def lonelyelk_format(text)
res = "<p>" + text.to_s.dup
codes = []
res.gsub!(/<pre><code>[\s\S]+?<\/code><\/pre>/) do |s|
codes.push(s.gsub(/(^<pre><code>|<\/code><\/pre>$)/, ""))
"code#{codes.length - 1}"
end
res.gsub!(/\r\n?/, "\n")
res.gsub!(/\n*\[h\]\n*/, "</p><h2>")
res.gsub!(/\n*\[\/h\]\n*/, "</h2><p>")
res.gsub!(/\n\n+/, "</p><p>")
res.gsub!(/\n(?=code\d+)/, "</p><p>")
res.gsub!(/(code\d+)\n/, '\1</p><p>')
res.gsub!("\n", "<br />")
res.gsub!(/(<p>)?code\d+(<\/p>)?/) do |s|
"<pre><code>" + codes[s.gsub(/\D/, "").to_i].to_s.gsub("<", "<").gsub(">", ">") + "</code></pre>"
end
res.gsub!("<p></p>", "")
res += "</p>"
end
...