Статьи

Code Safari: HAML, от компиляции до завершения

Haml: неделя 2

На прошлой неделе мы начали работать над реализацией HAML , популярного языка шаблонов для HTML. Мы прошли через этап синтаксического анализа, оставив этап компиляции на этой неделе. Если вы не читали предыдущую статью , я призываю вас сделать это сейчас. В этой статье будет гораздо больше смысла. Для справки, вот пример шаблона, который мы использовали:

%article %h1 My great article %p Here is the text of my article 

А вот конкретная строка, на которой мы остановились:

 # lib/haml/engine.rb:124 compile(parse) 

составление

Мы нашли эту строку в прошлый раз и исследовали метод parse . Давайте прыгнем прямо в другую сторону: compile . В поисках проекта для def compile мы находим этот метод в ожидаемом модуле Haml::Compiler .

 # lib/haml/compiler.rb:444 def compile(node) parent, @node = @node, node block = proc {node.children.each {|c| compile c}} send("compile_#{node.type}", &(block unless node.children.empty?)) ensure @node = parent end 

Это аккуратная маленькая функция, которая компилирует узел, предоставляя блок для компиляции всех дочерних узлов, которые могут быть получены по мере необходимости. Таким образом, компилируя тег с вложенными дочерними элементами, компилятор может выдавать открывающий тег, давать для компиляции (и генерировать) дочерние элементы, а затем после завершения yield добавить закрывающий тег.

Интересным звонком является send на третью линию. Это вызывает другой метод для компиляции каждого типа узла. Это симпатичный способ реализации шаблона посетителя в Ruby, который позволяет обойти традиционный вызов алгоритма accept . Вместо того, чтобы создавать иерархию классов для каждого типа узла, который знает, как компилировать себя, HAML хранит всю логику компилятора в одном месте. Это позволяет концептуально группировать подобный код, а также позволяет компилятору легко поддерживать состояние (переменные экземпляра) без необходимости выставлять их вне класса.

Давайте посмотрим на методы компиляции для двух типов узлов, которые мы имеем в нашем примере: tag и plain .

В методе compile_tag происходит довольно много, поэтому нам нужно применить наши навыки, чтобы отфильтровать его до самого необходимого. Существует множество условий для работы с различными типами форматирования: мы убираем пробелы? Мы выводим HTML5 или XHTML? Мы в уродливом режиме? Это не важно для нас, чтобы понять в данный момент, поскольку они не очень важны для алгоритма.

 # lib/haml/compiler.rb:91 # Heavily edited def compile_tag t = @node.value object_ref = t[:object_ref] value = t[:value] tag_closed = !block_given? open_tag = prerender_tag(t[:name], t[:self_closing], t[:attributes]) if tag_closed &amp;&amp; value open_tag << "#{value}</#{t[:name]}>" # Point A end push_merged_text(open_tag, 0, true) return if tag_closed if value.nil? # Point B @output_tabs += 1 unless t[:nuke_inner_whitespace] yield if block_given? @output_tabs -= 1 unless t[:nuke_inner_whitespace] push_merged_text("</#{t[:name]}>", 0, true) end end 

Я значительно сократил предыдущий список, чтобы выделить только два пути кода. Сравните с исходным кодом, чтобы понять, как я «извлек» суть метода, удалив посторонний код. Это сложный навык, но он становится легче с практикой.

В новой версии скелета мы можем видеть две основные ветви: одну для того, когда значение предоставляется узлу (точка A), и одну для того, когда значение вложено под тегом (точка B). Они соответствуют по порядку тегам h1 и p в нашем примере шаблона. yield чуть ниже точки B запустит процесс, определенный в строке 2 метода compile (листинг выше), который, в свою очередь, скомпилирует любые дочерние узлы (наши текстовые узлы для p ). push_merged_text значительной степени просто выводит текст в буфер, с небольшой дополнительной логикой для таких вещей, как обеспечение сохранения соответствующего отступа.

Это немного, но логика станет яснее ниже, когда мы попытаемся применить эту концепцию к нашему собственному интерпретатору HAML.

После compile_tag метода compile_plain очень короток:

 def compile_plain push_text @node.value[:text] end 

Да, не очень к этому. Это помогает проиллюстрировать ценность создания дерева разбора перед попыткой создания какого-либо вывода — сложность синтаксического анализа может сильно отличаться от сложности компиляции, поэтому она помогает выделить эти проблемы.

Строим свой

На прошлой неделе мы создали начало нашего собственного интерпретатора HAML. Мы дошли до того, что смогли создать дерево узлов разбора, и на этой неделе нам нужно скомпилировать эти узлы. Для справки, вот полный список того, куда мы попали в прошлый раз:

 class HamlParser class Node < Struct.new(:type, :data) attr_accessor :children attr_accessor :parent def initialize(*args) super self.children = [] end end def initialize(string) @string = string end def parse @root = Node.new(:root, {}) @parent = @root @depth = 0 @string.lines.each do |line| process_indent(line) push parse_line(line.strip) end @root end def process_indent(line) indent = line[/^s+/].to_s.length / 2 if indent > @depth @parent = @parent.children.last @depth = indent end end def push(node) @parent.children << node node.parent = @parent end def parse_line(line) case line[0] when ?% name, value = line[1..-1].split(' ') Node.new(:tag, :name => name, :value => value) else Node.new(:plain, :value => line) end end end 

Мы не будем изменять этот код, скорее мы добавим новый класс HamlCompiler для преобразования из дерева разбора, которое мы сгенерировали, в HTML. Прежде всего, мы просто реализуем метод compile_plain чтобы преобразовать ввод простого текста в вывод простого текста. Как и на прошлой неделе, мы используем test/unit чтобы убедиться, что все правильно. Запустите этот скрипт, и вы увидите, что тесты запускаются автоматически.

 require 'test/unit' class HamlCompilerTest < Test::Unit::TestCase def compile(input) HamlCompiler.new.compile(HamlParser.new(input).parse) end def test_compile_one_line_plain assert_equal 'hello', compile("hello") end end class HamlCompiler def compile(node) block = if node.children.any? proc { node.children.each {|x| compile(x) }} end send("compile_#{node.type}", node, &amp;block) @output end def compile_root(node) @output = "" yield end def compile_plain(node) @output << node.data[:value] end end 

Обратите внимание, что мы пользуемся преимуществом использования шаблона посетителя, сохраняя состояние в переменной @output . Это означает, что нам не нужно передавать его в качестве аргументов всем методам компиляции, что нам пришлось бы делать, если бы они были методами в ParseNode . Поскольку требуется больше состояния (например, отслеживание отступов), такой подход действительно начинает приносить дивиденды. Вы также можете увидеть, как всегда запуск с корневого узла допускает действительно простой алгоритм, который не требует особых случаев для начала работы, и даже обеспечивает дополнительные преимущества, такие как предоставление нам хорошего места для инициализации вывода.

Давайте добавим метод compile_tag чтобы мы могли полностью интерпретировать наш пример шаблона. Надеюсь, это будет несколько проще, чем тот, который использует HAML!

 class HamlCompilerTest < Test::Unit::TestCase # ... as above def test_compile_one_line_tag_with_value assert_equal '<em>hello there</em>', compile("%em hello there") end def test_compile_tag_with_nested_value assert_equal '<em>hello there</em>', compile("%em 

Привет»)
конец
конец

 class HamlCompiler # ... as above def compile_tag(node) tag_name = node.data[:name] @output << "<#{tag_name}>" if block_given? yield else @output << node.data[:value].to_s end @output << "</#{tag_name}>" end end 

Из-за более простой природы нашего интерпретатора мы можем избавиться от простого условия, чтобы справиться как с одной строкой, так и с тегами с вложенным текстом. А из-за рекурсивной природы алгоритма он также работает с произвольными вложенными узлами, такими как теги h1 и p внутри article , без специальной обработки!

Завершение

В ходе этой серии из двух частей мы углубились в код, который управляет механизмом шаблонов HAML, и обнаружили, что он использует двухпроходный метод для визуализации шаблонов. На первом проходе он создает абстрактное дерево узлов разбора, представляющих документ, а на втором — преобразовывает эти узлы в HTML. Такое разделение обязанностей учитывает аккуратную архитектуру.

В процессе мы взяли то, что узнали, и создали собственный интерпретатор mini-HAML, который был способен анализировать и компилировать простые документы.

Для получения бонусных баллов, вот некоторые дополнительные задачи, которые вы можете решить:

  • Добавить поддержку атрибутов HTML для нашего интерпретатора.
  • Наш переводчик не выделяет никаких значительных пробелов. Исправьте это, чтобы красиво сделать отступ для вложенных тегов.
  • ARel , реляционная алгебра для Ruby, которая используется Rails и ActiveRecord, также использует шаблон посетителя для компиляции SQL. Сравните это с компилятором HAML.

Настройтесь на следующую неделю для новых приключений в джунглях кода.