Jarek i Wiktor udostępnili swój kod na githubie. Pierwsza rzecz, która rzuciła mi się w oczy, to ilość pracy, jaką wykonali poza kamerą. Ich rozwiązanie wspiera już kod w wielu linijkach oraz liczby. To nadal są proste zagadnienia, ale nie są trywialne. Dlatego też, w tym wpisie zajmiemy się pewnymi preoptymalizacjami, które ułatwią nam pracę w przyszłości.

Motywacja

Oryginalne założenie było takie, by implementacja w Elixirze była jak najbliższa tej w Javie. Dzięki temu osoby, które czytają mój kod, a nie znają Elixira, będą w stanie zrozumieć konstrukcję programu, patrząc i porównując go z programem napisanym w Javie. Jednak nie jest to dobra strategia. Lekser zaczyna się komplikować, a przenoszenie kodu strukturalno-obiektowego do świata funkcyjnego nie jest dobrym pomysłem. Z wielu powodów, ale najważniejszy to, że w efekcie otrzymujemy kod, który jest słaby. Dlatego ugryzę ten problem trochę inaczej. Kod będę tworzył w Elixirze. Zachowam spójność nazewniczą, o ile będzie to możliwe. Jednak przede wszystkim postaram się, by kolejne kroki były małe. Pozwoli to na lepsze wytłumaczenie różnych zagadnień i łatwiejszą analizę kodu przez czytelników.

Zacznijmy więc zabawę, ale najpierw małe przypomnienie. Kod wyjściowy wygląda w następujący sposób:

Listing 1. Kod wyjściowy

defmodule Ego.Lexer do
  
  def tokenize(program) when is_binary(program) do
    program
    |> String.split("", trim: true)
    |> token
  end

  defp token(charlist, accumulator \\ [], buffer \\ [], mode \\ :common)
  defp token([], accumulator, buffer, _), do: accumulator ++ [:eof]

  defp token(charlist, accumulator, buffer, :common) do
    [h | t] = charlist

    case h do
      "(" -> token(t, accumulator ++ read_buffer(buffer) ++ [:open_bracket], [])
      ")" -> token(t, accumulator ++ read_buffer(buffer) ++ [:close_bracket], [])
      " " -> token(t, accumulator ++ read_buffer(buffer), [])
      "\"" -> token(t, accumulator ++ read_buffer(buffer), [], :text)
      _ -> token(t, accumulator, buffer ++ [h])
    end
  end

  defp token(charlist, accumulator, buffer, :text) do
    [h | t] = charlist

    case h do
      "\"" -> token(t, accumulator ++ read_buffer(buffer), [], :common)
      _ -> token(t, accumulator, buffer ++ [h], :text)
    end
  end

  defp read_buffer([]), do: []

  defp read_buffer(buffer) do
    [buffer |> Enum.join("") |> String.to_atom()]
  end
end

Odwrotna kolejność

Pierwszą drobną optymalizacją będzie sposób, w jaki tworzę listę tokenów. Dotychczasowy kod (Listing 1.) działał w taki sposób, że gdy mamy już gotowy token, to zostaje on dołączony na końcu akumulatora. Jest to łatwa do zrozumienia konstrukcja, która jednak nie jest optymalna. W Erlangu operator ++ działa w ten sposób, że kopiuje argument po lewej stronie, by wykorzystać go w nowej liście. Lepszym rozwiązaniem jest dodawanie nowych elementów na początku listy, a następnie wywołanie Enum.reverse, by uzyskać końcowy rezultat. Opisane jest to w artykule The Seven Myths of Erlang Performance. W efekcie otrzymamy:

Listing 2. Zmiana sposobu tworzenia akumulatora

defmodule Ego.Lexer do

  def tokenize(program) when is_binary(program) do
    program
    |> String.split("", trim: true)
    |> token
    |> Enum.reverse
  end

  defp token(charlist, accumulator \\ [], buffer \\ [], mode \\ :common)
  defp token([], accumulator, buffer, _), do: [:eof] ++ accumulator

  defp token(charlist, accumulator, buffer, :common) do
    [h | t] = charlist

    case h do
      "(" -> token(t, [:open_bracket] ++ read_buffer(buffer) ++ accumulator, [])
      ")" -> token(t, [:close_bracket] ++ read_buffer(buffer) ++ accumulator   , [])
      " " -> token(t, read_buffer(buffer) ++ accumulator, [])
      "\"" -> token(t, read_buffer(buffer) ++ accumulator, [], :text)
      _ -> token(t, accumulator, [h] ++ buffer)
    end
  end

  defp token(charlist, accumulator, buffer, :text) do
    [h | t] = charlist

    case h do
      "\"" -> token(t, read_buffer(buffer) ++ accumulator, [], :common)
      _ -> token(t, accumulator, [h] ++ buffer, :text)
    end
  end

  defp read_buffer([]), do: []

  defp read_buffer(buffer) do
    [buffer |> Enum.reverse |> Enum.join("") |> String.to_atom()]
  end
end

Jak widać, zmiana dotknęła wiele miejsc w kodzie. Jednak dzięki temu, że Elixir opiera się o dopasowanie wzorców, nie musieliśmy zmieniać API. Operację odwrócenia realizujemy w dwóch miejscach. W funkcji tokenize odwracamy listę tokenów, by otrzymać wynik. W funkcji ` read_buffer` odwracamy bufor przed jego zrzuceniem, bo bufor też jest akumulatorem, tyle tylko, że lokalnym.

No właśnie…

Nazewnictwo

Kolejny etap to doprowadzenie do porządku nazewnictwa. Funkcja read_buffer wydaje się idealnym przykładem. Zadanie tej funkcji to odczytanie zawartości bufora, bo chcemy go opróżnić, ale… opróżniamy bufor w innym miejscu. Taka mała podpucha, dla kogoś, kto lubi refaktoryzaować nazwy dla samej refaktoryzacji nazw. Prawdziwym problemem jest funkcja token, która produkuje listę tokenów. Odwróconą. Jak ją nazwać? Na obecnym etapie tokens będzie ok. Kolejnym drobnym problemem jest zmienna buffer, w metodzie token(s) dopasowanej do pustego ciągu znaków. Jest nieużywana, więc można ją zastąpić znakiem _. Ostatnia rzecz związana z nazewnictwem, będzie polegać na pozbyciu się niepotrzebnego przypisania [h | t] = charlist. To są w sumie drobne poprawki, które nie wpływają na kod.

Listing 3. Zmiana nazw

defmodule Ego.Lexer do

  def tokenize(program) when is_binary(program) do
    program
    |> String.split("", trim: true)
    |> tokens
    |> Enum.reverse
  end

  defp tokens(charlist, accumulator \\ [], buffer \\ [], mode \\ :common)
  defp tokens([], accumulator, _, _), do: [:eof] ++ accumulator

  defp tokens([h | t], accumulator, buffer, :common) do
    case h do
      "(" -> tokens(t, [:open_bracket] ++ read_buffer(buffer) ++ accumulator, [])
      ")" -> tokens(t, [:close_bracket] ++ read_buffer(buffer) ++ accumulator   , [])
      " " -> tokens(t, read_buffer(buffer) ++ accumulator, [])
      "\"" -> tokens(t, read_buffer(buffer) ++ accumulator, [], :text)
      _ -> tokens(t, accumulator, [h] ++ buffer)
    end
  end

  defp tokens([h | t], accumulator, buffer, :text) do
    case h do
      "\"" -> tokens(t, read_buffer(buffer) ++ accumulator, [], :common)
      _ -> tokens(t, accumulator, [h] ++ buffer, :text)
    end
  end

  defp read_buffer([]), do: []

  defp read_buffer(buffer) do
    [buffer |> Enum.reverse |> Enum.join("") |> String.to_atom()]
  end
end

Nie wygląda to źle. Jednak jak zawsze w przypadku nazw można tu coś usprawnić. Kolejna zmiana jest znacznie ciekawsza.

Reprezentacja znaków – Charlisty

W Javie jest tak, że String i Character rozróżniamy na podstawie rodzaju cudzysłowu użytego do stworzenia obiektu. ` String to cudzysłów podwójny, zwany polskim cudzysłowem apostrofowym. Character to cudzysłów pojedynczy, zwany brytyjskim. Istotne jest tutaj to, że String może zawierać wiele znaków, a Charakter` to pojedynczy znak. W Elixirze jest trochę inaczej.

Co to jest Charlist?

Podwójny cudzysłów definiuje nam String. Pojedynczy definiuje tzw. Charlist, czyli listę znaków. Jest to lista, która zawiera liczby całkowite odpowiadające kodom poszczególnych znaków. I tak znaki ( i ) mają kody odpowiednio 40 i 41, więc zapis () będzie odpowiadać [40, 41]. Pojedynczy znak może być reprezentowany jako liczba lub też jako lista. Jest to trochę zagmatwane i jak zaraz zobaczycie, może prowadzić do „dziwnych” konstrukcji. Najłatwiej jednak przyjąć, że w Javie będzie to po prostu List<Character>. Ale przecież String, który podzielimy na znaki, też jest listą. Tak, jest listą. Jednak w tym przypadku nie mamy rozróżnienia na znaki wielobajtowe i jednobajtowe. W efekcie nie możemy łatwo odsiać pewnych znaków. Z drugiej strony nie musimy tak przejmować się znakami wymagającymi znaku ucieczki np. znak końca linii.

Refaktoryzacja do Charlist

Zmiana jest dość prosta, ale znowuż, dotykamy praktycznie całego kodu.

Listing 4. Wprowadzamy charlist

defmodule Ego.Lexer do
  def tokenize(program) when is_binary(program) do
    program
    |> String.to_charlist()
    |> tokens
    |> Enum.reverse()
  end

  defp tokens(charlist, accumulator \\ [], buffer \\ [], mode \\ :common)
  defp tokens([], accumulator, _, _), do: [:eof] ++ accumulator

  defp tokens([h | t], accumulator, buffer, :common) do
    case [h] do
      '(' -> tokens(t, [:open_bracket] ++ read_buffer(buffer) ++ accumulator, [])
      ')' -> tokens(t, [:close_bracket] ++ read_buffer(buffer) ++ accumulator, [])
      ' ' -> tokens(t, read_buffer(buffer) ++ accumulator, [])
      '"' -> tokens(t, read_buffer(buffer) ++ accumulator, [], :text)
      _ -> tokens(t, accumulator, [h] ++ buffer)
    end
  end

  defp tokens([h | t], accumulator, buffer, :text) do
    case [h] do
      '"' -> tokens(t, read_buffer(buffer) ++ accumulator, [], :common)
      _ -> tokens(t, accumulator, [h] ++ buffer, :text)
    end
  end

  defp read_buffer([]), do: []

  defp read_buffer(buffer) do
    [buffer |> Enum.reverse() |> List.to_string() |> String.to_atom()]
  end
end

Clue zmiany leży w warunku case. Można ją przeprowadzić na dwa sposoby. W powyższym zapisałem case [h], ponieważ chcę dopasować h do kolejnych list znaków stworzonych za pomocą pojedynczego cudzysłowu. Zamiast tego mogę użyć dopasowania do wartości liczbowych, ale takie rozwiązanie oznacza, że będę musiał znać kody znaków. Ta zmiana prowadzi nas do kolejnej, która jest pierwszą z dwóch zmian „na przyszłość”.

Case na cond i co to jest if

W Elixirze istnieje kilka różnych struktur, które można zbiorczo nazwać instrukcjami warunkowymi. Różnią się one składnią i możliwościami, ale docelowo są to różne odmiany dobrze znanego if. Sam if istnieje w Elixirze i służy do zapisu pojedynczego warunku. Jego negacją jest unless. Nie interesują nas one, bo nie mają tu zastosowania. Kolejną instrukcją jest case. Dopasowuje on argument, do kolejnych wzorców. Możemy wykorzystywać strażników do opisania wzorca. Jest to chyba najpowszechniejsza instrukcja warunkowa. Rzecz w tym, że pracuje ona na dopasowaniu, a nie na warunku więc nie zawsze możemy ją wykorzystać. Mówiąc prościej jest ona odpowiednikiem javowego ` switch (upraszczając), a my chcemy mieć coś w rodzaju if else if i pracować na funkcjach zwracających wartość logiczną. Do tego właśnie służy cond. Wykona on kolejne funkcje do momentu aż nie otrzyma wartości true`. Czasami może oznaczać to spadek wydajności, ale w naszym przypadku nie będzie to aż tak istotne.

Kod po zmianach będzie więc wyglądać następująco:

Listing 5. Zmiana case na cond

defp tokens([h | t], accumulator, buffer, :common) do
  cond do
    '(' === [h]-> tokens(t, [:open_bracket] ++ read_buffer(buffer) ++ accumulator, [])
    ')' === [h]-> tokens(t, [:close_bracket] ++ read_buffer(buffer) ++ accumulator, [])
    ' ' === [h]-> tokens(t, read_buffer(buffer) ++ accumulator, [])
    '"' === [h]-> tokens(t, read_buffer(buffer) ++ accumulator, [], :text)
    true -> tokens(t, accumulator, [h] ++ buffer)
  end
end

defp tokens([h | t], accumulator, buffer, :text) do
  cond do
    '"' === [h] -> tokens(t, read_buffer(buffer) ++ accumulator, [], :common)
    true -> tokens(t, accumulator, [h] ++ buffer, :text)
  end
end

Na tym kończą sie proste refaktoryzacje, które nie dodawały nowych elementów do kodu. Jeden z użytkowników 4programmers, WeiXiao, stwierdził w komentarzu na mikro, że kod jest zwięzły. Czas go trochę rozsmarować i dodać struktury, które będą przechowywać nam więcej informacji.

Token jako struktura

Do tej pory w implementacji Jarka i Wiktora poszczególne tokeny były reprezentowane przez konkretne obiekty. Moja implementacja była pozbawiona tego elementu, ponieważ w znaczny sposób upraszczało to pisanie posta. Ot lenistwo… Teraz jednak wprowadzę odpowiednią strukturę, która będzie przechowywać dane.

Struktura Token

Elixir nie ma klas. Jest funkcyjny, więc ich nie potrzebuje. W zamian ma jednak struktury, które pozwalają na operowanie nazwanymi składowymi mapy. Efektywnie jest to mapa doposażona w kilka udogodnień jak np. ograniczenie nazw kluczy do pewnego zbioru. Struktury definiuje się w modułach, dzięki czemu nadal możemy w ramach tej samej przestrzeni nazw tworzyć funkcje. Zanim jednak utworzymy jakieś funkcje, stwórzmy strukturę.

Listing 6. Moduł Ego.Token

defmodule Ego.Token do
  @enforce_keys  [:kind, :value]
  defstruct [:kind, :value]
end

Na razie nie potrzeba nam nic więcej. Javowy enum Kind zastąpimy atomami i być może walidacją. Jedna uwaga. Atrybut modułu @enforce_keys pozwala na określenie obowiązkowych pól w strukturze. Bez niego jeżeli stworzymy strukturę, to nieprzypisane pola będą ` nil`.

Refaktoryzacja

Czas na wprowadzenie nowego formatu tokenów do kodu. Zaczniemy od przepisania testów, tak by obsługiwały nowy format.

Listing 7. Poprawione testy

defmodule Ego.LexerTest do
  use ExUnit.Case
  import Assertions
  alias Ego.Lexer
  alias Ego.Token

  @moduletag :capture_log

  doctest Lexer

  test "()" do
    result = Lexer.tokenize("()")

    assert_lists_equal(result, [
      %Token{kind: :open_bracket, value: '('},
      %Token{kind: :close_bracket, value: ')'},
      %Token{kind: :eof, value: ''}
    ])
  end

  test "( )" do
    result = Lexer.tokenize("( )")

    assert_lists_equal(result, [
      %Token{kind: :open_bracket, value: '('},
      %Token{kind: :close_bracket, value: ')'},
      %Token{kind: :eof, value: ''}
    ])
  end

  test "(Print Hello)" do
    result = Lexer.tokenize("(Print Hello)")

    assert_lists_equal(result, [
      %Token{kind: :open_bracket, value: '('},
      %Token{kind: :atom, value: 'Print'},
      %Token{kind: :atom, value: 'Hello'},
      %Token{kind: :close_bracket, value: ')'},
      %Token{kind: :eof, value: ''}
    ])
  end

  test "(Print \"Hello World\")" do
    result = Lexer.tokenize("(Print \"Hello World\")")

    assert_lists_equal(result, [
      %Token{kind: :open_bracket, value: '('},
      %Token{kind: :atom, value: 'Print'},
      %Token{kind: :string, value: 'Hello World'},
      %Token{kind: :close_bracket, value: ')'},
      %Token{kind: :eof, value: ''}
    ])
  end

  test "(Print \"Hello ( ) World\")" do
    result = Lexer.tokenize("(Print \"Hello ( ) World\")")

    assert_lists_equal(result, [
      %Token{kind: :open_bracket, value: '('},
      %Token{kind: :atom, value: 'Print'},
      %Token{kind: :string, value: 'Hello ( ) World'},
      %Token{kind: :close_bracket, value: ')'},
      %Token{kind: :eof, value: ''}
    ])
  end

  test "(Print \"Hello (\n) World\")" do
    result =
      Lexer.tokenize("""
      (Print \"Hello (
      ) World\")
      """)

    assert_lists_equal(result, [
      %Token{kind: :open_bracket, value: '('},
      %Token{kind: :atom, value: 'Print'},
      %Token{kind: :string, value: 'Hello (\n) World'},
      %Token{kind: :close_bracket, value: ')'},
      %Token{kind: :eof, value: ''}
    ])
  end

  test "(\u0061\u0301)" do
    result = Lexer.tokenize("(\u0061\u0301)")

    assert_lists_equal(result, [
      %Token{kind: :open_bracket, value: '('},
      %Token{kind: :atom, value: '\u0061\u0301'},
      %Token{kind: :close_bracket, value: ')'},
      %Token{kind: :eof, value: ''}
    ])
  end
end

Oczywiście wszystko będzie od razu czerwone, bo zmiana jest bardzo głęboka. Poprawny więc kod. Będziemy to robić po kawałku, ponieważ zmian jest dużo i warto je omówić.

Na pierwszy ogień idzie przywrócenie do życia testów związanych z obsługą nawiasów oraz znaku końca pliku. To są bardzo proste zmiany, które szybko rozwiązują nam problem.

Listing 8. Podstawowe elmenty

defmodule Ego.Lexer do

  alias Ego.Token

  def tokenize(program) when is_binary(program) do
    program
    |> String.to_charlist()
    |> tokens
    |> Enum.reverse()
  end

  defp tokens(charlist, accumulator \\ [], buffer \\ [], mode \\ :common)
  defp tokens([], accumulator, _, _), do: [%Token{kind: :eof, value: ''}] ++ accumulator

  defp tokens([h | t], accumulator, buffer, :common) do
    cond do
      '(' === [h] -> tokens(t, [%Token{kind: :open_bracket, value: '('}] ++ read_buffer(buffer) ++ accumulator, [])
      ')' === [h] -> tokens(t, [%Token{kind: :close_bracket, value: ')'}] ++ read_buffer(buffer) ++ accumulator, [])
      ' ' === [h] -> tokens(t, read_buffer(buffer) ++ accumulator, [])
      '"' === [h] -> tokens(t, read_buffer(buffer) ++ accumulator, [], :text)
      true -> tokens(t, accumulator, [h] ++ buffer)
    end
  end

  defp tokens([h | t], accumulator, buffer, :text) do
    cond do
      '"' === [h] -> tokens(t, read_buffer(buffer) ++ accumulator, [], :common)
      true -> tokens(t, accumulator, [h] ++ buffer, :text)
    end
  end

  defp read_buffer([]), do: []

  defp read_buffer(buffer) do
    [buffer |> Enum.reverse() |> List.to_string() |> String.to_atom()]
  end
end

Kolejny krok to poprawki w funkcji read_buffer, tak by zamiast ciągu znaków emitowała odpowiedni token.

Listing 9. Obsługa atomów i zrzut bufora

defp read_buffer([]), do: []

defp read_buffer(buffer) do
  value = buffer |> Enum.reverse()
  [%Token{kind: :atom, value: value}]
end

No i nie działa… Dotychczas wykorzystywaliśmy tę funkcję do produkcji elixirowych atomów. To było OK, bo implementacja była prosta, ale z drugiej strony ukrywaliśmy w ten sposób klasę atomów, które reprezentują ciąg znaków. Wszystko było atomem! Teraz mamy kilka różnych klas, do których przyporządkowujemy byty, więc nasza funkcja pełniąca rolę emitera musi to jakoś obsługiwać. Na całe szczęście jest to bardzo prosta zmiana, ponieważ potrafimy opisać z jaką klasą mamy do czynienia. Gdzie? Mówi nam o tym parametr mode funkcji tokens. W efekcie kod będzie wyglądał w następujący sposób:

Listing 10. Działający kod

defmodule Ego.Lexer do

  alias Ego.Token

  def tokenize(program) when is_binary(program) do
    program
    |> String.to_charlist()
    |> tokens
    |> Enum.reverse()
  end

  defp tokens(charlist, accumulator \\ [], buffer \\ [], mode \\ :common)
  defp tokens([], accumulator, _, _), do: [%Token{kind: :eof, value: ''}] ++ accumulator

  defp tokens([h | t], accumulator, buffer, :common) do
    cond do
      '(' === [h] -> tokens(t, [%Token{kind: :open_bracket, value: '('}] ++ read_buffer(buffer, :atom) ++ accumulator, [])
      ')' === [h] -> tokens(t, [%Token{kind: :close_bracket, value: ')'}] ++ read_buffer(buffer, :atom) ++ accumulator, [])
      ' ' === [h] -> tokens(t, read_buffer(buffer, :atom) ++ accumulator, [])
      '"' === [h] -> tokens(t, read_buffer(buffer, :atom) ++ accumulator, [], :text)
      true -> tokens(t, accumulator, [h] ++ buffer)
    end
  end

  defp tokens([h | t], accumulator, buffer, :text) do
    cond do
      '"' === [h] -> tokens(t, read_buffer(buffer, :string) ++ accumulator, [], :common)
      true -> tokens(t, accumulator, [h] ++ buffer, :text)
    end
  end

  defp read_buffer([], _), do: []

  defp read_buffer(buffer, kind) do
    value = buffer |> Enum.reverse()
    [%Token{kind: kind, value: value}]
  end
end

Jest prawie dobrze. Ostatnią refaktoryzacją, którą można zrobić, to wyciągnięcie funkcji fabrykujących do modułu Ego.Token. Tym samym zmieni się też funkcja read_buffer, która będzie tak jak dotychczas zwracać jedynie wartość bufora. Niestety od razu widać pewien problem. Pierwotnie read_buffer zwracało wartość opakowaną w listę albo pustą listę. Dzięki temu łatwo było nam dodawać listy. Teraz zwracamy wartość lub pustą listę (sic!). Zatem możemy doprowadzić do sytuacji, gdzie zaczniemy emitować tokeny, które będą reprezentować puste listy ( bufor był pusty) i nie będą EOF. Dotyczy to tokenów reprezentujących atom i ciąg znaków. Ale i na to będzie prosta rada. Widać ją w funkcji tokenize. Wypłaszczamy listę tokenów. W efekcie nasz końcowy kod wygląda w następujący sposób:

Listing 11. Ego.Token po zmianach

defmodule Ego.Token do
  @enforce_keys [:kind, :value]
  defstruct [:kind, :value]

  def open_bracket(), do: %Ego.Token{kind: :open_bracket, value: '('}
  def close_bracket(), do: %Ego.Token{kind: :close_bracket, value: ')'}
  def eof(), do: %Ego.Token{kind: :eof, value: ''}
  def atom([]), do: []
  def atom(value), do: %Ego.Token{kind: :atom, value: value}
  def string([]), do: []
  def string(value), do: %Ego.Token{kind: :string, value: value}

end

Listing 11. Ego.Lexer po zmianach

defmodule Ego.Lexer do
  import Ego.Token

  def tokenize(program) when is_binary(program) do
    program
    |> String.to_charlist()
    |> tokens
    |> Enum.reverse()
    |> List.flatten()
  end

  defp tokens(charlist, accumulator \\ [], buffer \\ [], mode \\ :common)
  defp tokens([], accumulator, _, _), do: [eof()] ++ accumulator

  defp tokens([h | t], accumulator, buffer, :common) do
    cond do
      '(' === [h] -> tokens(t, [open_bracket()] ++ [atom(read_buffer(buffer))] ++ accumulator, [])
      ')' === [h] -> tokens(t, [close_bracket()] ++ [atom(read_buffer(buffer))] ++ accumulator, [])
      ' ' === [h] -> tokens(t, [atom(read_buffer(buffer))] ++ accumulator, [])
      '"' === [h] -> tokens(t, [atom(read_buffer(buffer))] ++ accumulator, [], :text)
      true -> tokens(t, accumulator, [h] ++ buffer)
    end
  end

  defp tokens([h | t], accumulator, buffer, :text) do
    cond do
      '"' === [h] -> tokens(t, [string(read_buffer(buffer))] ++ accumulator, [], :common)
      true -> tokens(t, accumulator, [h] ++ buffer, :text)
    end
  end

  defp read_buffer([]), do: []

  defp read_buffer(buffer) do
    buffer |> Enum.reverse()
  end
end

I gotowe.

Podsumowanie

Refaktoryzacje były proste i skomplikowane. Dały one elastyczniejszy kod, który będzie można znacznie łatwiej rozszerzać. Taka mała ciekawostka. Wczorajsza wersja miała 37 linii kodu. Dzisiejsza ma tyle samo. Przy czym nie pociągnąłem jej elixirowrym formaterem w domyślnych ustawieniach. Zmieniłem długość linii na 120 znaków.

Kolejne kroki to uzupełnienie mojego kodu o to, co Jarek i Wiktor dodali poza kamerą. Będzie wsparcie dla liczb i komentarzy. Kod jak zwykle na githubie.