Powered by AppSignal & Oban Pro

Relatório Técnico: Compilador Homi

relatorio.livemd

Relatório Técnico: Compilador Homi

0. Antes de começar

É necessário usar o comando “ea”, essas duas letras em seguida, para “evaluate all”, executando todo o código desse relatório. Caso esteja vendo esse relatório em pdf, recomendo a execução pelo projeto, com o comando “make livebook”.

Garanta que você tenha o docker instalado e configurado o usuário docker para que não haja necessidades do comando sudo.

1. Visão Geral da Arquitetura

O compilador Homi é estruturado em quatro fases encadeadas, cada uma consumindo a saída da anterior:

flowchart TD
    SRC["Código-fonte (.homi)"]
    LEX["Lexer (DFA)"]
    PAR["Parser (LL(1) RD)"]
    SEM["Análise Semântica"]
    COD["Geração de código (AST → YAML)"]
    OUT["YAML Home Assistant"]

    SRC --> LEX
    LEX -->|"{:ok, tokens}"| PAR
    LEX -->|"{:error, errs, tokens}"| PAR
    PAR -->|"{:ok, %AST.Program{}}"| SEM
    PAR -->|"{:error, errs, ast}"| SEM
    SEM --> COD
    COD --> OUT

Para a implementação, escolhi o Elixir por algumas razões concretas, como a minha familiaridade com a linguagem e entender o quanto a direção desse trabalho casa com as ferramentas que a linguagem oferecem, entre elas, o casamento de padrão sobre binários (<>) mapeia diretamente para transições de um DFA sem camadas de abstração intermediárias, sendo que o estado do autômato é implícito na cláusula que está executando. Também, o modelo funcional sem estado mutável torna o parser recursivo descendente seguro por construção: cada função recebe e devolve um state explícito, tornando o fluxo de tokens e erros auditável.


Configuração do ambiente

project_root = Enum.find([__DIR__, "/apps", "/home/livebook/homi", "/data"], &amp;File.exists?("#{&amp;1}/mix.exs")) || __DIR__
Mix.install([{:homi, path: project_root}])
alias Homi.{Lexer, Parser, Semantic, Codegen}

2. Definição da Linguagem

2.1 Motivação e público-alvo

Homi é uma DSL (Domain-Specific Language) projetada neste trabalho para abstrair a criação de automações residenciais. A ideia é que um usuário sem conhecimento técnico consiga descrever uma automação em frases próximas ao inglês cotidiano, sem precisar conhecer a estrutura YAML do Home Assistant.

Comparação entre um script Homi e o YAML equivalente:

Homi:

automation "Hallway - motion" {
    when state of
      binary_sensor.hallway_motion
      becomes "on";

    if light.hallway is off;

    then {
        turn_on light.hallway;
        wait 45s;
        turn_off light.hallway;
    }

    mode restart;
}

Yaml

- alias: Hallway - motion
  triggers:
  - trigger: state
    entity_id: binary_sensor.hallway_motion
    to: 'on'
  conditions:
  - condition: state
    entity_id: light.hallway
    state: 'off'
  actions:
  - action: light.turn_on
    target:
      entity_id: light.hallway
  - delay:
      hours: 0
      minutes: 0
      seconds: 45
  - action: light.turn_off
    target:
      entity_id: light.hallway
  mode: restart

2.2 Gramática Livre de Contexto (GLC)

A gramática de Homi foi definida no formato LL(1): sem recursão à esquerda, sem ambiguidade, e com a decisão de qual produção aplicar tomada com um único token de lookahead. Essa propriedade viabiliza o parser recursivo descendente implementado na seção 4, onde cada não-terminal corresponde diretamente a uma função.

São 14 não-terminais no total, organizados em torno das quatro construções principais de uma automação: o gatilho (Trigger), as condições opcionais (Conditions, CondList, CondRest, Cond), as ações (Actions, ActionList, Action) e o modo de execução (Mode). Os terminais cobrem palavras reservadas, literais tipados (entity, duration, time, string, number) e delimitadores estruturais.

Terminais

Categoria Tokens
Estrutura automation when if then mode { } ;
Triggers state of becomes at motion in
Condições is on off armed disarmed and or
Ações turn_on turn_off toggle wait notify speak start stop run repeat
Modos single restart queued parallel
Literais entity string number duration time ident
Controle eof

Não-terminais

Não-terminal Descrição
Program Ponto de entrada: sequência de automações
Automation Bloco completo de uma automação
Trigger Cláusula when: gatilho do evento
TriggerKind Variante do gatilho (estado, horário, movimento)
Conditions Cláusula if opcional
CondList Lista de condições com conectivo
CondRest Cauda recursiva de condições
Cond Condição unitária (entity is state)
CondState Estado esperado da entidade
Actions Cláusula then: sequência de ações
ActionList Lista de ações
Action Ação unitária
Mode Cláusula mode opcional
ModeKind Variante do modo de execução

Produções

Program      → Automation Program | ε

Automation   → automation string { Trigger Conditions Actions Mode }

Trigger      → when TriggerKind ;

TriggerKind  → state of entity becomes string
             | at time
             | motion in entity

Conditions   → if CondList ; | ε

CondList     → Cond CondRest

CondRest     → and Cond CondRest
             | or  Cond CondRest
             | ε

Cond         → entity is CondState

CondState    → on | off | armed | disarmed | ident

Actions      → then { ActionList }

ActionList   → Action ; ActionList | ε

Action       → turn_on  entity
             | turn_off entity
             | toggle   entity
             | wait     duration
             | notify   string
             | speak    string
             | start    entity
             | stop     entity
             | run      string
             | repeat   number

Mode         → mode ModeKind ; | ε

ModeKind     → single | restart | queued | parallel

2.3 Conjuntos FIRST e FOLLOW: prova de LL(1)

Para que uma gramática seja LL(1), é necessário que, para cada par (não-terminal, token de lookahead), exista no máximo uma produção aplicável. Isso exige que os conjuntos FIRST das alternativas de cada não-terminal sejam disjuntos, e que quando uma produção pode derivar ε, seu conjunto FIRST não intercepte o FOLLOW do não-terminal.

Conjuntos FIRST das produções relevantes:

Não-terminal Produção FIRST
Program Automation P { automation }
Program ε (via FOLLOW: { eof })
TriggerKind state of … { state }
TriggerKind at time { at }
TriggerKind motion in … { motion }
Conditions if CondList ; { if }
Conditions ε (via FOLLOW: { then })
CondRest and Cond … { and }
CondRest or Cond … { or }
CondRest ε (via FOLLOW: { ; })
ActionList Action ; … { turn_on, turn_off, toggle, wait, notify, speak, start, stop, run, repeat }
ActionList ε (via FOLLOW: { } })
CondState on/off/armed/disarmed/ident disjuntos entre si
Mode mode ModeKind ; { mode }
Mode ε (via FOLLOW: { } })

Observação sobre conflitos potenciais e como foram evitados:

  • Program → ε é selecionada quando lookahead ∈ FOLLOW(Program) = { eof }, disjunto de { automation }.
  • Conditions → ε quando lookahead ∈ FOLLOW(Conditions) = { then }, disjunto de { if }.
  • CondRest → ε quando lookahead ∉ { and, or } e ∈ FOLLOW(CondRest) = { ; }.
  • ActionList → ε quando lookahead ∈ FOLLOW(ActionList) = { } }, disjunto do FIRST de Action.

Em nenhum caso duas produções do mesmo não-terminal têm conjuntos FIRST intersectantes, portanto a gramática é LL(1).

2.4 Tabela Preditiva LL(1)

A tabela abaixo codifica, para cada (não-terminal, token), qual produção aplicar. Esta é a tabela que o parser implementa; cada entrada corresponde a um braço do case peek(state) do:

Não-terminal automation when state at motion if and/or then turn_onrepeat mode } eof
Program → Auto P → ε
TriggerKind → state… → at… → motion…
Conditions → if… → ε
CondRest → and/or…
CondRest → ε(;)
ActionList → Action… → ε
Mode → mode… → ε

Células vazias são erros sintáticos: o parser entra em Modo Pânico.


3. Analisador Léxico

3.1 Especificação e decisão de implementação

O scanner é um DFA (Autômato Finito Determinístico) escrito à mão em Elixir, operando diretamente sobre o binário UTF-8 da entrada.

Por que não usar leex (equivalente Erlang do Flex)?

O leex exige que o código auxiliar seja escrito em Erlang puro; o projeto inteiro está em Elixir, e misturar os dois criaria fricção desnecessária. Mais importante: o leex não oferece recuperação de erros léxicos, pois aborta no primeiro caractere inválido. O requisito do trabalho é que o compilador acumule todos os erros sem abortar; isso exige controle explícito do fluxo, possível apenas com o DFA manual.

Como o DFA é representado em Elixir:

O DFA tem seus estados implícitos nas cláusulas da função scan/5. Cada cláusula inspeciona os próximos bytes do binário e decide a transição. As guards is_letter/1, is_digit/1 e is_word/1 definem as classes de caracteres (equivalentes às classes do Flex):

is_letter(c) ≡ a-z | A-Z | _
is_digit(c)  ≡ 0-9
is_word(c)   ≡ a-z | A-Z | _ | 0-9

3.2 Estados e transições do DFA

O diagrama abaixo mostra os estados principais e as transições. “Aceitar X” significa emitir o token X e voltar ao estado inicial:

Estado atual Entrada Próximo estado Token emitido / ação
INICIAL \s \t \r \n INICIAL descarta, atualiza linha/coluna
INICIAL # INICIAL descarta até \n
INICIAL /* INICIAL descarta até */, conta \n
INICIAL /* … EOF INICIAL LexError: comentário não-terminado
INICIAL " STR (nenhum)
INICIAL dígito NUMERIC (nenhum)
INICIAL >= INICIAL :ge
INICIAL <= INICIAL :le
INICIAL > INICIAL :gt
INICIAL < INICIAL :lt
INICIAL = INICIAL :eq
INICIAL { } ( ) ; , : . INICIAL token simples correspondente
INICIAL letra / _ WORD (nenhum)
INICIAL outro INICIAL LexError: caractere inesperado
STR \" INICIAL :string
STR \\c STR acumula char escapado
STR \n / EOF INICIAL LexError: string não-terminada
STR outro STR acumula char
WORD is_word WORD acumula
WORD . + is_word ENTITY acumula sufixo
WORD outro INICIAL :kw_* (palavra reservada) ou :ident
ENTITY is_word ENTITY acumula objeto
ENTITY outro INICIAL :entity com valor {domínio, objeto}
NUMERIC dígito NUMERIC acumula
NUMERIC : + dígito TIME (nenhum)
NUMERIC letra DURATION (nenhum)
NUMERIC . + dígito NUMERIC acumula parte fracionária (float)
NUMERIC outro INICIAL :number (inteiro ou float)
TIME 2 dígitos (MM) INICIAL :time com valor {h, m, 0}
TIME : + 2 dígitos (SS) INICIAL :time com valor {h, m, s}
TIME formato inválido INICIAL LexError: literal de tempo malformado
DURATION unidade válida (ms s min h …) INICIAL ou DURATION acumula segundos; se há mais dígitos, volta a DURATION
DURATION unidade inválida INICIAL LexError: unidade de duração desconhecida

Casos especiais de maior complexidade:

Entity ID (light.hallway): após varrer uma palavra, o lexer testa se o próximo caractere é . seguido de is_word. Se sim, varre o sufixo e emite :entity com valor {domain, object}. Caso contrário, consulta a tabela de palavras reservadas e emite :kw_* ou :ident.

Duration composta (1h30min): o lexer lê os dígitos iniciais, depois tenta ler uma unidade (ms|s|sec|min|m|h|hour|hours) sem deixar uma letra is_word logo após (para não confundir min com minutes). Se houver mais dígitos em seguida, continua acumulando componentes via scan_duration_loop, somando os valores em segundos. O token final carrega o total em segundos (ex: 1h30minvalue: 5400).

Time (07:00, 07:00:00): após os dígitos iniciais, se o próximo char é : seguido de dígito, o lexer muda para scan_time e lê exatamente dois dígitos para minutos (e opcionalmente dois para segundos). O token carrega uma tupla {h, m, s}.

Comentários de bloco (/* ... */): a função skip_block_comment conta newlines ao percorrer o bloco para manter o número de linha correto nos tokens seguintes. Se o arquivo terminar sem */, emite um LexError de comentário não-terminado.

3.3 Tratamento de erros e recuperação

Quando o lexer encontra um caractere inválido, ele:

  1. Cria um %LexError{message: ..., line: ..., column: ...}
  2. Adiciona ao acumulador errs (sem lançar exceção)
  3. Avança um byte e continua a varredura, sem abortar

Ao final, se errs está vazio, retorna {:ok, tokens}. Se não, retorna {:error, errs, tokens}, com os tokens válidos reconhecidos até então disponíveis para o parser continuar.

3.4 Tokens reconhecidos

Tipo Exemplos Descrição
:kw_* automation, when, turn_on Palavras reservadas (≈ 35 keywords)
:entity light.hallway, binary_sensor.door Identificador domínio.objeto
:duration 45s, 5min, 1h30min Duração com unidade; valor em segundos
:time 07:00, 05:00:00 Horário HH:MM ou HH:MM:SS
:number 20, 3.14 Inteiro ou ponto flutuante
:string "texto" Cadeia com suporte a \" \\ \n \t
:ge :le :gt :lt :eq >= <= > < = Comparadores
:lbrace :rbrace :semicolon { } ; Delimitadores
:eof (n/a) Fim de entrada (sempre emitido)

3.5 Demonstração: fase léxica

source = File.read!("#{project_root}/examples/hallway_motion.homi")

{:ok, tokens} = Lexer.tokenize(source)

tokens
|> Enum.reject(&amp;(&amp;1.type == :eof))
|> Enum.map(fn t ->
  "L#{String.pad_leading(to_string(t.line), 2)} C#{String.pad_leading(to_string(t.column), 2)}  #{String.pad_leading(to_string(t.type), 16)}  #{inspect(t.lexeme)}"
end)
|> Enum.join("\n")
|> IO.puts()

O formato de saída acima mostra: linha, coluna, tipo do token e lexema. Isso demonstra que o rastreamento de posição funciona corretamente, essencial para o reporte preciso de erros nas fases seguintes.

3.6 Demonstração: recuperação de erro léxico

source_com_erro = """
automation "Teste" {
    when at 10:00;
    then { turn_on light.sala@invalido; }
}
"""

case Lexer.tokenize(source_com_erro) do
  {:error, errors, tokens} ->
    IO.puts("=== Erros léxicos ===")
    Enum.each(errors, fn e ->
      IO.puts("  [lex] linha #{e.line}, col #{e.column}: #{e.message}")
    end)
    IO.puts("\nTokens reconhecidos mesmo com erro: #{length(tokens) - 1}")
    # -1 para descontar o :eof

  {:ok, _} ->
    IO.puts("Sem erros")
end

O @ em light.sala@invalido gera um LexError, mas o lexer continua e reconhece invalido e os demais tokens. O parser recebe os tokens válidos e consegue construir uma AST parcial.


4. Analisador Sintático

4.1 Especificação

O parser é recursivo descendente LL(1) preditivo com recuperação de erros em Modo Pânico.

Implementação da tabela preditiva:

Cada não-terminal da GLC corresponde a uma função privada. A seleção da produção é feita por peek/1 (leitura do token de lookahead sem consumir) e case sobre o tipo do token:

# Tabela LL(1) para TriggerKind, implementada como case sobre peek:
defp parse_trigger_kind(state, line) do
  case peek(state) do
    %Token{type: :kw_state}  -> ...   # TriggerKind → state of entity becomes string
    %Token{type: :kw_at}     -> ...   # TriggerKind → at time
    %Token{type: :kw_motion} -> ...   # TriggerKind → motion in entity
    tok -> add_error(...) ; panic_until(...)  # erro
  end
end

O casamento de padrão %Token{type: :kw_state} corresponde exatamente a consultar a célula (TriggerKind, state) da tabela preditiva.

Estrutura do estado do parser:

O estado do parser é um mapa simples %{tokens: [...], errors: [...]} passado explicitamente entre todas as funções. Não há estado global nem mutable. Isso torna o fluxo de tokens e erros completamente auditável.

flowchart TD
    PP[parse_program] --> PA[parse_automation]
    PA --> PT[parse_trigger]
    PT --> PTK[parse_trigger_kind]
    PA --> PC[parse_conditions]
    PC --> PCL[parse_cond_list]
    PCL --> PCR["parse_cond_rest (recursivo)"]
    PA --> PAC[parse_actions]
    PAC --> PAL["parse_action_list (recursivo)"]
    PA --> PM[parse_mode]

Função expect: tenta consumir um token do tipo esperado. Se o tipo bate, consome e devolve o token. Se não bate, registra o erro sem consumir o token, evitando desvios em cascata ao manter o lookahead válido para a próxima decisão.

4.2 Modo Pânico: recuperação de erros sintáticos

Quando uma situação não-prevista ocorre (célula de erro na tabela), o parser:

  1. Chama add_error/3 para registrar o erro com linha e coluna
  2. Chama panic_until/2 com um conjunto de tokens de sincronização

panic_until descarta tokens do fluxo até encontrar um dos tokens de sincronização (; ou }). A partir daí, o parser retoma a análise normalmente.

Por que ; e } como tokens de sincronização?

  • ; termina qualquer cláusula (when, if, mode, uma action)
  • } termina qualquer bloco (then { }, o próprio automation { })

Esses tokens aparecem no FOLLOW de quase todos os não-terminais da gramática. Ao sincronizar neles, o parser consegue “pular” a cláusula malformada e continuar com a próxima, permitindo reportar múltiplos erros em uma única passagem.

Exemplo concreto de recuperação:

automation "Bad trigger" {
    when at 10:00          ← falta ;
    then { ... }
}

Ao esperar ; após 10:00 e encontrar then, o parser:

  • Registra erro: expected :semicolon, got kw_then ('then') at line 2
  • Continua sem consumir then (lookahead preservado)
  • O não-terminal parse_trigger termina retornando o trigger incompleto
  • parse_conditions verifica peek → não é if → retorna {:and, []}
  • parse_actions encontra then → prossegue normalmente

O resultado é uma AST parcialmente válida com a action corretamente parseada.

4.3 AST gerada

A AST é composta por structs Elixir com campos correspondentes às construções da linguagem:

%AST.Program{
  automations: [
    %AST.Automation{
      name: "Hallway - motion",
      trigger: %AST.Trigger{
        kind: :state,
        entity: {"binary_sensor", "hallway_motion"},
        state_value: "on"
      },
      conditions: [
        %AST.Condition{entity: {"switch", "main"},                state: :kw_on},
        %AST.Condition{entity: {"alarm_control_panel", "home"},   state: :kw_disarmed},
        %AST.Condition{entity: {"light", "hallway"},              state: :kw_off}
      ],
      conditions_op: :and,
      actions: [
        %AST.Action{kind: :turn_on,  entity: {"light", "hallway"}},
        %AST.Action{kind: :wait,     duration: 45},
        %AST.Action{kind: :turn_off, entity: {"light", "hallway"}}
      ],
      mode: :restart
    }
  ]
}

Decisão de design: o entity ID é armazenado como tupla {domain, object} desde o token, não como string. Isso elimina a necessidade de parsear strings "light.hallway" nas fases seguintes, pois o domínio já está extraído, o que beneficia tanto a análise semântica (que precisa do domínio) quanto o gerador de código (que precisa de ambos separadamente).

Tratamento de conditions_op: como a gramática não suporta mistura de and e or na mesma lista de condições, o parser coleta os conectores usados e define o operador da lista inteira como :or se houver pelo menos um or, ou :and caso contrário. O gerador usa isso para decidir entre uma lista plana de conditions (and) ou um bloco condition: or aninhado (or).

4.4 Demonstração: AST de script válido

source = File.read!("#{project_root}/examples/hallway_motion.homi")
{:ok, tokens} = Lexer.tokenize(source)
{:ok, ast} = Parser.parse(tokens)

[auto] = ast.automations

IO.puts("=== AST: Hallway Motion ===")
IO.puts("Nome:       #{auto.name}")
IO.puts("Modo:       #{auto.mode}")
IO.puts("Trigger:    #{auto.trigger.kind} | entidade: #{elem(auto.trigger.entity, 0)}.#{elem(auto.trigger.entity, 1)}")
IO.puts("Cond-op:    #{auto.conditions_op} (#{length(auto.conditions)} condições)")
Enum.each(auto.conditions, fn c ->
  {d, o} = c.entity
  IO.puts("  - #{d}.#{o} is #{c.state}")
end)
IO.puts("Ações (#{length(auto.actions)}):")
Enum.each(auto.actions, fn a ->
  info = cond do
    a.entity -> "entity=#{elem(a.entity,0)}.#{elem(a.entity,1)}"
    a.duration -> "duration=#{a.duration}s"
    a.message -> "msg=#{inspect(a.message)}"
    true -> ""
  end
  IO.puts("  - #{a.kind}  #{info}")
end)

4.5 Demonstração: recuperação de erro sintático (Modo Pânico)

source_com_erro = File.read!("#{project_root}/examples/errors_demo.homi")

{:ok, tokens} = Lexer.tokenize(source_com_erro)
{:error, errors, ast} = Parser.parse(tokens)

IO.puts("=== Erros sintáticos (Modo Pânico) ===")
IO.puts("Total de erros: #{length(errors)}")
Enum.each(errors, fn e ->
  IO.puts("  [syn] linha #{e.line}: #{e.message}")
end)

IO.puts("\nAutomações que o parser conseguiu recuperar: #{length(ast.automations)}")
Enum.each(ast.automations, fn a ->
  IO.puts("  - \"#{a.name}\" (trigger: #{a.trigger &amp;&amp; a.trigger.kind}, ações: #{length(a.actions)})")
end)

Este exemplo demonstra a recuperação em ação: mesmo com erros na segunda automação, o parser consegue recuperar e continuar para a terceira.


5. Analisador Semântico

5.1 Especificação

A análise semântica percorre a AST.Program e realiza três categorias de verificação:

A. Tabela de símbolos

Construída por collect_symbols/1 ao percorrer trigger, condições e ações de cada automação. Mapeia cada entity_id ao seu domínio:

"binary_sensor.hallway_motion" → "binary_sensor"
"switch.main"                  → "switch"
"alarm_control_panel.home"     → "alarm_control_panel"
"light.hallway"                → "light"

A tabela é construída por automação e acumulada ao longo do programa. Isso garante que, se a mesma entidade aparecer em automações diferentes, seu domínio seja consistente no mapa global.

Por que a tabela de símbolos não precisa verificar redeclarações? Em Homi, entidades não são declaradas explicitamente pelo usuário; elas são referenciadas diretamente pelo entity_id. O domínio é inferido do prefixo do entity_id (a parte antes do .), que é a convenção da API do Home Assistant. Logo, uma entidade só pode ter um domínio, e o mapa simplesmente sobrescreve com o mesmo valor.

B. Verificação de tipos (estados)

Cada domínio aceita apenas um conjunto de estados válidos em condições e triggers. A tabela:

Domínio Estados válidos
light, switch, fan, input_boolean, cover on, off
alarm_control_panel armed, disarmed, armed_home, armed_away
binary_sensor, sensor, input_sensor qualquer (read-only, valor arbitrário)
outros qualquer (domínio desconhecido, sem restrição)

Erro típico: usar alarm_control_panel.home is on, pois o domínio alarm_control_panel não suporta on, apenas armed/disarmed.

C. Consistência domínio × ação

Impede que ações sejam aplicadas a domínios incompatíveis:

Ação Restrição
turn_on, turn_off, toggle proibido em binary_sensor, sensor, input_sensor (read-only)
start, stop exclusivo do domínio timer

A restrição de binary_sensor captura erros como turn_on binary_sensor.door, já que sensores são somente leitura na API do HA e não aceitam comandos de controle.

O analisador não aborta no primeiro erro: usa Enum.flat_map para acumular todos os erros de todas as verificações antes de retornar.

5.2 Fluxo da análise semântica

flowchart TD
    A["analyse(program)"] --> B["para cada Automation"]

    B --> C["check_automation(auto)"]
    C --> CT["check_trigger\nverifica estado vs domínio"]
    C --> CC["check_conditions\nverifica estado vs domínio"]
    C --> CA["check_actions\nverifica domínio vs ação"]

    B --> S["collect_symbols(auto)"]
    S --> ST["entidade do trigger"]
    S --> SC["entidades das conditions"]
    S --> SA["entidades das actions"]
    ST & SC & SA --> SYM["%{'entity_id' => 'domain'}"]

5.3 Demonstração: análise de programa válido

source = File.read!("#{project_root}/examples/morning_routine.homi")
{:ok, tokens} = Lexer.tokenize(source)
{:ok, ast} = Parser.parse(tokens)

IO.puts("=== Análise semântica: rotina matinal ===")
case Semantic.analyse(ast) do
  {:ok, _} ->
    IO.puts("Resultado: OK, nenhum erro semântico.")
  {:error, errors, _} ->
    IO.puts("Erros inesperados:")
    Enum.each(errors, &amp;IO.puts("  [sem] #{&amp;1.message}"))
end

5.4 Demonstração: erros semânticos

source = File.read!("#{project_root}/examples/semantic_errors.homi")
{:ok, tokens} = Lexer.tokenize(source)
{:ok, ast} = Parser.parse(tokens)

{:error, errors, _} = Semantic.analyse(ast)

IO.puts("=== Erros semânticos detectados ===")
IO.puts("Total: #{length(errors)}\n")
Enum.each(errors, fn e ->
  IO.puts("  [sem] linha #{e.line}: #{e.message}")
end)

O arquivo semantic_errors.homi contém 5 automações com erros deliberados:

  1. Estado "armed" para trigger em domínio light (aceita apenas on/off)
  2. Condição is on para alarm_control_panel (aceita armed/disarmed)
  3. turn_on binary_sensor.door (domínio read-only)
  4. start light.hallway (start é exclusivo do domínio timer)
  5. Automação com múltiplos erros combinados (erros 1+2+3)

6. Geração de Código (YAML)

6.1 Especificação

O gerador percorre a AST.Program e produz YAML compatível com o Home Assistant. A arquitetura é puramente funcional: cada nó da AST tem uma função correspondente que retorna uma string com a indentação YAML correta.

Decisão de projeto: strings vs biblioteca YAML

O YAML é gerado por interpolação de strings direta, não por uma biblioteca de serialização. A razão é que o YAML do Home Assistant tem requisitos específicos de formatação (campos na ordem exata, indentação de 2 espaços, aspas simples em valores boolean-like) que uma biblioteca genérica não garantiria sem configuração extensiva. Geração direta dá controle total.

Tratamento da indentação rígida do YAML:

O YAML usa indentação como estrutura. Cada função geradora retorna strings com indentação fixada para o nível correto do bloco. O bloco automation_to_yaml monta as peças com 2 espaços de base, e os blocos aninhados (trigger, condições, ações) adicionam mais 2 espaços por nível.

Quoting de valores YAML:

O YAML interpreta on, off, true, false, yes, no como booleanos. Para emiti-los como strings (o que o HA espera nos campos state e to), a função yaml_string/1 envolve esses valores em aspas simples. O mesmo vale para strings que se parecem com horários (07:00).

6.2 Mapeamento AST → YAML

Triggers:

Homi YAML gerado
when state of entity becomes "v" trigger: state, entity_id, to: 'v'
when at HH:MM trigger: time, at: 'HH:MM:SS'
when motion in entity trigger: state, entity_id, to: 'on' (convenção HA)

Condições (AND vs OR):

Homi YAML gerado
if A is on and B is off lista plana de - condition: state (HA avalia com AND implícito)
if A is on or B is on bloco - condition: or com conditions: aninhado

Ações:

Homi YAML gerado
turn_on entity action: {domain}.turn_on + target.entity_id
turn_off entity action: {domain}.turn_off + target.entity_id
toggle entity action: {domain}.toggle + target.entity_id
wait 45s delay: {hours: 0, minutes: 0, seconds: 45}
notify "msg" action: notify.notify + data.message
speak "msg" action: tts.speak + data.message
start timer.x action: timer.start + target.entity_id
stop timer.x action: timer.cancel + target.entity_id
run "script" action: script.{script}
repeat N repeat: {count: N, sequence: []}

Derivação do domínio de serviço: o domínio do serviço gerado (light.turn_on, switch.turn_off) é derivado diretamente do prefixo do entity_id. Não há tabela de tradução; essa é a convenção da API do Home Assistant, onde cada domínio expõe serviços com seu próprio nome.

Decomposição do wait: a duração em segundos (calculada pelo lexer) é decomposta em horas/minutos/segundos para o campo delay do HA:

45   → hours: 0, minutes: 0, seconds: 45
3900 → hours: 1, minutes: 5, seconds: 0

6.3 Demonstração: pipeline completa (hallway_motion)

source = File.read!("#{project_root}/examples/hallway_motion.homi")

IO.puts("=== Script Homi ===")
IO.puts(source)

{:ok, tokens} = Lexer.tokenize(source)
{:ok, ast} = Parser.parse(tokens)
{:ok, _} = Semantic.analyse(ast)
yaml = Codegen.generate(ast)

IO.puts("=== YAML gerado ===")
IO.puts(yaml)

6.4 Demonstração: condições OR

source = """
automation "Backup light" {
    when at 22:00;
    if switch.main is on
       or switch.backup is on;
    then { turn_off light.sala; }
}
"""

{:ok, tokens} = Lexer.tokenize(source)
{:ok, ast} = Parser.parse(tokens)
{:ok, _} = Semantic.analyse(ast)
yaml = Codegen.generate(ast)

IO.puts("=== Condições OR: YAML gerado ===")
IO.puts(yaml)

Observe que o bloco condition: or com conditions: aninhado é gerado automaticamente quando o conditions_op da AST é :or.

6.5 Demonstração: rotina matinal

source = File.read!("#{project_root}/examples/morning_routine.homi")
{:ok, tokens} = Lexer.tokenize(source)
{:ok, ast} = Parser.parse(tokens)
{:ok, _} = Semantic.analyse(ast)
yaml = Codegen.generate(ast)

IO.puts("=== Rotina matinal: YAML gerado ===")
IO.puts(yaml)

6.6 Demonstração: múltiplas automações em um arquivo

source = """
automation "Liga cedo" {
    when at 07:00;
    then { turn_on light.bedroom; }
    mode single;
}

automation "Desliga tarde" {
    when at 23:00;
    then { turn_off light.bedroom; }
    mode single;
}
"""

{:ok, tokens} = Lexer.tokenize(source)
{:ok, ast} = Parser.parse(tokens)
{:ok, _} = Semantic.analyse(ast)
yaml = Codegen.generate(ast)

IO.puts("=== Múltiplas automações: YAML gerado ===")
IO.puts(yaml)

Múltiplas automações no mesmo arquivo são emitidas como uma lista YAML (- alias: ...), diretamente compatível com o formato que o Home Assistant espera no arquivo automations.yaml.


7. Detecção e Reporte de Erros: Pipeline Completa

O compilador nunca aborta no primeiro erro. Cada fase acumula todos os erros encontrados e passa os resultados (mesmo que parciais) para a próxima fase.

Fluxo de erros:

flowchart LR
    L["Lexer"]
    P["Parser"]
    S["Semantic"]
    C["Codegen"]
    Y["yaml"]

    L -->|"tokens (+ lex_errors?)"| P
    P -->|"ast (+ parse_errors?)"| S
    S -->|"ast (+ sem_errors?)"| C
    C --> Y

Mesmo que o lexer encontre erros, ele devolve os tokens reconhecidos. O parser usa esses tokens (ignorando :error atoms dos tokens inválidos) e aplica Modo Pânico para produzir uma AST parcial. A análise semântica consegue então verificar as partes que chegaram íntegras.

7.1 Demonstração: múltiplos erros em todas as fases

source = """
automation "Múltiplos erros" {
    when state of light.hallway becomes "armed";
    if alarm_control_panel.home is on;
    then { turn_on binary_sensor.door }
}
"""

{tokens, lex_errors} =
  case Lexer.tokenize(source) do
    {:ok, t} -> {t, []}
    {:error, errs, t} -> {t, errs}
  end

{ast, parse_errors} =
  case Parser.parse(tokens) do
    {:ok, a} -> {a, []}
    {:error, errs, a} -> {a, errs}
  end

sem_errors =
  case Semantic.analyse(ast) do
    {:ok, _} -> []
    {:error, errs, _} -> errs
  end

IO.puts("=== Resumo de erros ===")
IO.puts("Erros léxicos:    #{length(lex_errors)}")
IO.puts("Erros sintáticos: #{length(parse_errors)}")
IO.puts("Erros semânticos: #{length(sem_errors)}")

IO.puts("\nDetalhes:")
Enum.each(lex_errors,   fn e -> IO.puts("  [lex] L#{e.line}:C#{e.column}: #{e.message}") end)
Enum.each(parse_errors, fn e -> IO.puts("  [syn] L#{e.line}:C#{e.column}: #{e.message}") end)
Enum.each(sem_errors,   fn e -> IO.puts("  [sem] L#{e.line}: #{e.message}") end)

Este exemplo demonstra três erros semânticos em uma única automação:

  1. Trigger: light não suporta estado "armed"
  2. Condição: alarm_control_panel não suporta estado on
  3. Ação: turn_on em binary_sensor (read-only); falta também ; antes de }

Todos são reportados antes de qualquer abortamento, o que é o comportamento esperado de um compilador de produção.