Criando parser para arquivos texto
- 12 minutes read - 2412 wordsParseando
Imagine a situação na qual o seu programa precisa ler arquivos texto no qual o conteúdo está em um formato adhoc ou muito criativo e você não tem ideia de como implementar o parser do arquivo. Muitas vezes até sabemos como implementar mas é uma tarefa tediosa. Outras vezes tentamos fugir da implementação.
A verdade é que precisamos conhecer as ferramentas para melhor sair das situações. Nesse post vamos percorrer alguns possíveis caminhos para implementar alguns parses de arquivos.
Vamos implementar parsers para dois arquivos com formatos adhoc. O objetivo é implementar um códigos em Erlang para ler as informações dos arquivos e apresentar os dados em Erlang Terms.
Todo o código dos exemplos foi disponibilizado no repositório eparser.
O primeiro formato é o seguinte:
Comandos de configuração https://github.com/joaohf/eparser/blob/main/test/eparser_SUITE_data/zl30702.txt 1;======================================================================
2; Register Configuration Start
3
4
5X , 0x0582 , 0x00 ; ref_ctrl
6X , 0x0583 , 0x01 ; ref_ctrl
7X , 0x0584 , 0x02 ; ref_semaphore
8W , 20000
9X , 0x0585 , 0x03 ; ref0_freq_base
10X , 0x0586 , 0xE8 ; ref0_freq_base
11X , 0x0587 , 0x00 ; ref0_freq_mult
12X , 0x0588 , 0x01 ; ref0_freq_mult
13X , 0x0589 , 0x00 ; ref0_ratio_m
14X , 0x058A , 0x01 ; ref0_ratio_m
15X , 0x058B , 0x03 ; ref0_ratio_n
Isso representa uma determinada configuração e o objetivo do código que vai ler
este arquivo é extrair uma sequencia de tuplas com os comandos de write
e
wait
. Algo como abaixo representado:
1 Sequence = [
2 {write, 1410, 0},
3 {write, 1411, 1},
4 {write, 1412, 2},
5 {wait, 20000},
6 {write, 1413, 3},
7 {write, 1414, 232},
8 {write, 1415, 0},
9 {write, 1416, 1},
10 {write, 1417, 0},
11 {write, 1418, 1},
12 {write, 1419, 3},
13 {write, 1420, 232},
14 {write, 1421, 1},
15 {write, 1423, 5},
16 {write, 1424, 5},
17 {write, 1425, 33},
18 {write, 1426, 0},
19 {write, 1427, 50},
20 {write, 1428, 180},
21 {write, 1429, 39},
22 {write, 1430, 36},
23 {write, 1431, 0},
24 {write, 1432, 0},
25 {write, 1433, 40},
26 {write, 1434, 27},
27 {write, 1435, 2},
28 {write, 1436, 0},
29 {write, 1437, 0},
30 {write, 1412, 1},
31 {wait, 20000},
32 {wait, 1000000},
33 {write, 1286, 0},
34 {write, 1281, 0},
35 {write, 1296, 0},
36 {write, 1297, 0}
37 ],
Já o segundo formato é mais complicado:
Configurações de devices https://github.com/joaohf/eparser/blob/main/test/eparser_SUITE_data/devices.cfg 1[boards]
2num_boards = 2
3board1 = b1
4board2 = b2
5
6[b1]
7num_devices = 2
8
9device1 = fpgajic
10alias1 = a
11model1 = m
12version1 = xyz1
13file1 = /tmp/fpga_jic.rpd
14md51 = e019c0a6a79c526918622b637c3898d2
15devport1 = /dev/mx25u256
16adapter1 = 0
17activecard1 = 0
18enabled1 = 1
19checkversion1 = 1
20dependencies1 = b1_fpgacvp/a
21restart_type1 = fpga-reload
22estimated_time1 = 30
O formato deste arquivo parece bem comum e até mesmo a implementação é sugestiva (veja o arquivo completo antes de tomar uma decisão). Os dados representam configurações e o objetivo é apresentar os dados em tuplas e property lists. Como abaixo:
Comandos de configuração após o parser https://github.com/joaohf/eparser/blob/main/test/eparser_SUITE.erl 1 {boards, [{"num_boards", "2"}, {board, "b1"}, {board, "b2"}], [
2 {"b1", [{num_devices, "2"}], [
3 {device, "fpgajic", [
4 {alias, "a"},
5 {model, "m"},
6 {version, "xyz1"},
7 {file, "/tmp/fpga_jic.rpd"},
8 {md5, "e019c0a6a79c526918622b637c3898d2"},
9 {devport, "/dev/mx25u256"},
10 {adapter, "0"},
11 {activecard, "0"},
12 {enabled, "1"},
13 {checkversion, "1"},
14 {dependencies, "b1_fpgacvp/a"},
15 {restart_type, "fpga-reload"},
16 {estimated_time, "30"}
17 ]},
Como implementar ? O que usar ? é o que vamos ver a seguir.
Exemplos
Antes de solucionar os dois exemplos propostos, podemos tentar implementar um parser usando algumas abordagens. Mas qual a ideia de um parser de arquivos ? A ideia geral é abstração, ou seja, quem vai usar os dados do arquivo não precisa saber qual é o formato que foi lido.
Normalmente há uma implementação que sabe como abrir o arquivo, extrair as linhas, dividir os dados para compor as informações. Somente depois disso os dados estão prontos para uso.
Essa separação facilita na manutenção do código e reusabilidade do parser criado.
O básico e simples mas complicado de manter
Em Erlang/Elixir, se você tiver uma situação onde tem o controle do arquivo destino uma saída é usar a função file:consult/1 para ler o conteúdo de um arquivo contento Erlang Terms. Suponha o seguintes termos estão em um determinado arquivo:
Erlang terms https://github.com/joaohf/eparser/blob/main/test/eparser_SUITE_data/basic_erlang_like.conf1[{test, ok}, {check, ok}, {path, "/tmp"}].
Para ler chamamos file:consult/1
:
1simple_erlang_like(Config) ->
2 Filename = filename:join(?config(data_dir, Config), "basic_erlang_like.conf"),
3 Expect = [{test, ok}, {check, ok}, {path, "/tmp"}],
4 Result = simple_consult:parse_file(Filename),
5
6 ?assertMatch({ok, [Expect]}, Result),
7
8 ok.
Simples, certo ? Sim, bastante. Mas isso funciona bem quando você tem o controle do arquivo destino. E em muitas situações é o bastante.
adhoc na unha
Ok, agora suponha que você não é o dono do arquivo e precisa implementar um código para ler os exemplos 1 e 2 apresentados acima. Como você faria ? Talvez algo na seguinte linha:
- abrir o arquivo
- ler cada linha
- para cada linha:
- usar Erlang regexp ou string split
- criar algumas funções para fazer pattern match de listas
- guardar o resultado de cada quebra em um acumulador
- quando ler todas as linhas, retornar o acumulador
É mais ou menos a ideia geral, não é mesmo ? Mas a implementação vai ser bem complicada de entender e de dar manutenção. E de fato, tentei cria um exemplo aqui: device_adhoc.erl.
Vejamos uma outra forma de implementar parser de arquivos…
parse tools
Parse Tools. São um conjunto de ferramentas para trabalhar com parser de arquivos. As duas ferramentas principais são:
- yecc, no qual gera um parser LALR-1 usando uma gramática BNF (parser)
- leex, um tokenizador baseado em expressões regulares (lexer)
A ideia central é essa:
- Primeiro definimos um tokenizador (lexer) usando expressões regulares. A intenção é quebrar cada linha em tokens indivisíveis; depois
- criamos uma gramática (parser) para juntar estes tokens. Isso alimenta o parser no qual vai tentar ler a gramática baseada na regra implementada
A implementação do passo 1
é feita em arquivos com a extensão .xrl
. E a
implementação do passo 2
em arquivos .yrl
. Nos exemplos que estamos
seguindo:
- device_parse_tools_lexer.xrl
- device_parse_tools_parser.yrl
- zl30702_lexer.xrl
- zl30702_parser.yrl
A partir destes arquivos código Erlang é gerado com a implementação dos respectivos parsers e e tokenizados.
Vamos ver como isso funciona.
Organizando o código
Uma sugestão para organizar a estrutura da implementação para trabalhar com parse tools é criar primeiro um módulo para implementar uma API na qual o seu código vai chamar toda vez que precisar ler o conteúdo de algum arquivo:
src/device_parse_tools.erl
device_parse_tools
vai ser a API principal. Depois criamos dois arquivos para
implementar o parser e o tokenizador:
src/device_parse_tools_lexer.xrl
src/device_parse_tools_parser.yrl
A estratégia é deixar isso claro no nome do arquivo, para não confundir. Quando
compilamos o projeto usando rebar3
ou mix
, estas ferramentas cuidam de fazer
a compilação do .xrl e .yrl, gerando os arquivos:
src/device_parse_tools_lexer.erl
src/device_parse_tools_parser.erl
device_parse_tools_lexer.erl e device_parse_tools_parser.erl são autogeradas baseados nas regras definidas. Vamos ver mais sobre isso agora.
Facilitando com parse tools, exemplo 1
A implementação de parsers é um exercício dividido em três partes:
- Escrever o tokenizador
- Escrever o parser
- Escrever um módulo para carregar o arquivo e chamar o parser escrito
Visualizando o arquivo que precisamos parser, exemplo 1, vamos pensar em como quebrar em tokens cada parte do arquivo. A ideia principal é pensar em expressões regulares para casar com cada pedaço do arquivo no qual signifique algo. O resultado do tokenizador (zl30702_lexer.xrl) fica como abaixo:
Erlang terms https://github.com/joaohf/eparser/blob/main/src/zl30702_lexer.xrl 1Definitions.
2
3COMMENT = ;[%=:.<>,A-Za-z0-9\s\b\t\n\f\r\\\"\_]*
4REGNAME = ;\s\s[a-z0-9\_]*
5VALUE = 0[xX][0-9A-F]+
6TIME = [0-9]+
7COMMAND = X|W
8WS = [\s\t]
9LB = \n|\r\n|\r
10
11Rules.
12, : skip_token.
13{COMMAND} : {token, {command, TokenLine, command(TokenChars)}}.
14{VALUE} : {token, {value, TokenLine, from_hex(TokenChars)}}.
15{TIME} : {token, {time, TokenLine, list_to_integer(TokenChars)}}.
16{REGNAME} : {token, {regname, TokenLine, to_regname(TokenChars)}}.
17
18{COMMENT} : skip_token.
19{WS} : skip_token.
20{LB} : skip_token.
21
22Erlang code.
23
24from_hex([$0, $x | Rest]) ->
25 erlang:list_to_integer(Rest, 16).
26
27to_regname([$;, $ , $ | Regname]) -> Regname.
28
29command("X") -> write;
30command("W") -> wait.
Arquivos .xrl são divididos em seções:
- Definitions: definição de expressões regulares
- Rules: quando o tokenizador encontrar uma expressão regular que case, o que ele deve fazer
- Erlang code: funções em Erlang para auxiliar na ação do tokenizador
O segundo passo é darmos significado aos tokens gerado. Para isso implementamos as regras do parser (zl30702_parser.yrl) como abaixo:
Erlang terms https://github.com/joaohf/eparser/blob/main/src/zl30702_parser.yrl 1Nonterminals
2expression values.
3
4Terminals
5command value time.
6
7Rootsymbol
8expression.
9
10expression -> command : '$1'.
11
12values -> value : '$1'.
13values -> value values : ['$1' ++ '$2'].
14
15expression -> command value value : {unwrap('$1'), unwrap('$2'), unwrap('$3')}.
16expression -> command time : {unwrap('$1'), unwrap('$2')}.
17
18Header
19"%%%-------------------------------------------------------------------"
20"%% @hidden"
21"%% @doc ZL30702 incremental configuration script scanner."
22"%% @end"
23"%%%-------------------------------------------------------------------".
24
25Erlang code.
26
27unwrap({_,_,V}) -> V.
Arquivos .yrl também são divididos em seções:
- Nonterminals: símbolos que podem gerar outros símbolos e por isso são não terminais
- Terminals: o menor símbolo na qual não pode ser composta por outros símbolos
- Rootsymbol: qual é o símbolo inicial para iniciar o processamento
- Header: geralmente documentção no formato edoc ou comentários gerias
- Erlang code: qualquer código extra (funções locais) para ajudar na extração de dados
O parser é onde dizemos como interpretar, baseado na ordem dos tokens, e transformar em outras estruturas de dados. Novamente é um exercício de olhar para o arquivo original e tentar entender como as informações fazem sentido.
O último passo é onde vamos carregar o arquivo de origem, tokenizar e parsear.
Para carregar o arquivo, a estratégia usada é ler linha a linha:
Carregando o arquivo de entrada https://github.com/joaohf/eparser/blob/main/src/zl30702.erl1slurp_configuration_script(Filename) ->
2 case file:open(Filename, [read]) of
3 {ok, InFile} ->
4 Tokens = slurp(InFile),
5 ok = file:close(InFile),
6 {Filename, Tokens};
7 {error, enoent} ->
8 {error, file_not_found}
9 end.
Depois para cada linha do arquivo chamamos o tokenizaodr:
Chamando o tokenizador https://github.com/joaohf/eparser/blob/main/src/zl30702.erl1slurp(InFile, {ok, Data}, Acc) ->
2 case zl30702_lexer:string(Data) of
3 {ok, [], _EndLine} ->
4 slurp(InFile, file:read_line(InFile), Acc);
5 {ok, Tokens, _EndLine} ->
6 slurp(InFile, file:read_line(InFile), [Tokens | Acc])
E para cada conjunto de tokens, chamamos o parser para retornar uma tupla na qual vamos acumulando com o restante do processamento:
Chamando o parser https://github.com/joaohf/eparser/blob/main/src/zl30702.erl1parse_command([Tokens | Rest], Acc) ->
2 {ok, ParseTree} = zl30702_parser:parse(Tokens),
3 parse_command(Rest, [ParseTree | Acc]).
Veja a implementação completa em zl30702.erl.
Finalmente um teste unitário demonstrando a chamada do parser:
Testando o parser https://github.com/joaohf/eparser/blob/main/test/eparser_SUITE.erl 1zl30702_simple_parser(Config) ->
2 Filename = filename:join(?config(data_dir, Config), "zl30702.txt"),
3 Result = zl30702:parse_file(Filename),
4 Sequence = [
5 {write, 1410, 0},
6 {write, 1411, 1},
7 {write, 1412, 2},
8 {wait, 20000},
9 {write, 1413, 3},
10 {write, 1414, 232},
11 {write, 1415, 0},
12 {write, 1416, 1},
13 {write, 1417, 0},
14 {write, 1418, 1},
15 {write, 1419, 3},
16 {write, 1420, 232},
17 {write, 1421, 1},
18 {write, 1423, 5},
19 {write, 1424, 5},
20 {write, 1425, 33},
21 {write, 1426, 0},
22 {write, 1427, 50},
23 {write, 1428, 180},
24 {write, 1429, 39},
25 {write, 1430, 36},
26 {write, 1431, 0},
27 {write, 1432, 0},
28 {write, 1433, 40},
29 {write, 1434, 27},
30 {write, 1435, 2},
31 {write, 1436, 0},
32 {write, 1437, 0},
33 {write, 1412, 1},
34 {wait, 20000},
35 {wait, 1000000},
36 {write, 1286, 0},
37 {write, 1281, 0},
38 {write, 1296, 0},
39 {write, 1297, 0}
40 ],
41
42 ?assertMatch({ok, Sequence}, Result),
Facilitando com parse tools, exemplo 2
Até aqui vimos que a criação de parsers é um exercício de introspecção em como definir as expressões regulares e também a criação de uma gramática que use os tokens definidos.
O segundo exemplo segue a mesma filosofia do primeiro mas apresenta algumas diferenças.
A primeira delas é no carregamento do arquivo. Aqui queremos carregar o arquivo inteiro:
Carregando o arquivo de entrada https://github.com/joaohf/eparser/blob/main/src/device_parse_tools.erl1slurp_configuration_script(Filename) ->
2 case file:read_file(Filename) of
3 {ok, Content} ->
4 {Filename, {ok, binary_to_list(Content)}};
5 {error, _Reason} = Error ->
6 Error
7 end.
E chamar o tokenizador de uma vez só, retornando uma lista de tokens:
Quebrando em tokens https://github.com/joaohf/eparser/blob/main/src/device_parse_tools.erl1tokenize({Filename, {ok, Data}}) ->
2 {ok, Tokens, _EndLine} = device_parse_tools_lexer:string(Data),
3 {Filename, {ok, Tokens}}.
Com todos os tokens, chamamos o parser:
Parseando os tokens https://github.com/joaohf/eparser/blob/main/src/device_parse_tools.erl1parse_devices({_Filename, {ok, Tokens}}) ->
2 {ok, ParseTree} = device_parse_tools_parser:parse(Tokens),
3 ParseTree.
Esta estratégia foi necessária pois a gramática precisa conhecer todos os tokens para extrair um contexto que faça sentida. Isso é de acordo com a natureza do arquivo de entrada.
Um teste unitário chamando o parser:
Erlang terms https://github.com/joaohf/eparser/blob/main/test/eparser_SUITE.erl 1device_simple_parser(Config) ->
2 Filename = filename:join(?config(data_dir, Config), "devices.cfg"),
3 Expect =
4 {boards, [{"num_boards", "2"}, {board, "b1"}, {board, "b2"}], [
5 {"b1", [{num_devices, "2"}], [
6 {device, "fpgajic", [
7 {alias, "a"},
8 {model, "m"},
9 {version, "xyz1"},
10 {file, "/tmp/fpga_jic.rpd"},
11 {md5, "e019c0a6a79c526918622b637c3898d2"},
12 {devport, "/dev/mx25u256"},
13 {adapter, "0"},
14 {activecard, "0"},
15 {enabled, "1"},
16 {checkversion, "1"},
17 {dependencies, "b1_fpgacvp/a"},
18 {restart_type, "fpga-reload"},
19 {estimated_time, "30"}
20 ]},
21 {device, "fpgacvp", [
22 {model, "m"},
23 {alias, "a"},
24 {version, "xyz1"},
25 {file, "/tmp/fpga.core.rbf"},
26 {md5, "01639aac1805ee9f2984b2cf08899152"},
27 {devport, "/dev/cvp"},
28 {adapter, "0"},
29 {activecard, "0"},
30 {enabled, "1"},
31 {checkversion, "0"},
32 {dependencies, "b1_fpgajic/a"},
33 {restart_type, "fpga-reload"},
34 {estimated_time, "10"}
35 ]}
36 ]},
37 {"b2", [{num_devices, "2"}], [
38 {device, "cpld", [
39 {model, "a"},
40 {alias, undefined},
41 {version, "4"},
42 {file, "/tmp/v04.jed"},
43 {md5, "ac4b38fc63716157bfdd6f14680df55d"},
44 {devport, "/dev/i2c-34"},
45 {adapter, "34"},
46 {activecard, "1"},
47 {enabled, "1"},
48 {checkversion, "1"},
49 {dependencies, undefined},
50 {restart_type, "system-reboot"},
51 {estimated_time, "120"}
52 ]},
53 {device, "55", [
54 {model, "b"},
55 {alias, undefined},
56 {version, "1.0"},
57 {file, "/tmp/1_0.hex"},
58 {md5, "418b571772a384b694e56a42dd2ed0f1"},
59 {devport, "/dev/i2c-34"},
60 {adapter, "34"},
61 {activecard, "1"},
62 {enabled, "1"},
63 {checkversion, "1"},
64 {dependencies, undefined},
65 {restart_type, "system-reboot"},
66 {estimated_time, "120"}
67 ]}
68 ]}
69 ]},
70 Result = device_parse_tools:parse_file(Filename),
Mais sobre parse tools
A documentação oficial do parse tools é a melhor referência. Mas um pouco complicada se você nunca teve contato com outras ferrametas de parser e tokenization.
A seguir preparei uma lista de alguns sites que apresentam o mesmo tema mas com outras abordagens:
- Tokenizing and parsing in Elixir with yecc and leex
- Internationalization and localization support for Elixir
- Leex And Yecc
- A simple example of how to use Leex and Yecc
- HTML parsing in Elixir with leex and yecc
- How to use leex and yecc in Elixir
- Writing a lexer and parser
- Parsing with leex and yecc
- Simple project to play with leex and yecc.