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"], &File.exists?("#{&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 deAction.
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_on…repeat |
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: 1h30min → value: 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:
-
Cria um
%LexError{message: ..., line: ..., column: ...} -
Adiciona ao acumulador
errs(sem lançar exceção) - 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(&(&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:
-
Chama
add_error/3para registrar o erro com linha e coluna -
Chama
panic_until/2com 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óprioautomation { })
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_triggertermina retornando o trigger incompleto -
parse_conditionsverifica peek → não éif→ retorna{:and, []} -
parse_actionsencontrathen→ 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 && 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, &IO.puts(" [sem] #{&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:
-
Estado
"armed"para trigger em domíniolight(aceita apenason/off) -
Condição
is onparaalarm_control_panel(aceitaarmed/disarmed) -
turn_on binary_sensor.door(domínio read-only) -
start light.hallway(starté exclusivo do domíniotimer) - 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:
-
Trigger:
lightnão suporta estado"armed" -
Condição:
alarm_control_panelnão suporta estadoon -
Ação:
turn_onembinary_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.