파싱은 텍스트를 의미 있는 데이터로 변환하는 과정이에요.
String 패키지의 함수들은 그런 면에서 한계가 있고 정규 표현식은 사용하기 어려워서, Elm에서 파싱하는 관용적인 방법은 elm/parser 패키지를 쓰는 거예요.
프로그래밍 언어 LOLCODE의 (일부분)을 파싱하는 파서를 작성하는 예제를 함께 살펴봐요. 다음과 같이 주석이 달린 프로그램을 봐요:
HAI 1 # program version 1
CAN HAS STDIO? # import stdio
VISIBLE "HAI WORLD!" # print "HAI WORLD!"
KTHXBYE # end of program
먼저, 가능한 명령을 나타내는 Elm 타입을 작성해봐요:
type Command
= Version Int
| Import String
| Print String
그러면 LOLCODE 프로그램은 List Command로 모델링할 수 있어요.
HAI 1 줄을 위한 파서부터 시작해봐요:
versionP : Parser Command
versionP =
Parser.succeed Version
|. Parser.keyword "HAI"
|. Parser.spaces
|= Parser.int
versionP는 _파서 파이프라인_을 사용해서 만들어요.
(|.) 연산자는 "문자열을 파싱하지만 결과는 버린다"는 뜻이고 (|=)는 "문자열을 파싱하고 결과는 유지한다"는 뜻이에요.
이 예제에서 Parser.keyword "HAI"는 "HAI"를 소비하지만 그 결과는 무시돼요.
Parser.spaces는 ' ', '\n', '\r' 문자를 여러 개 소비하지만, 이것도 무시돼요.
Parser.int는 정수를 파싱하고, 그 정수의 값(예를 들어 1)이 맨 위의 Parser.succeed Version에 전달되어 파서는 Version 1이라는 값을 성공적으로 반환해요.
파이프라인에 있는 파서 중 하나라도 실패하면 Parser.DeadEnd를 반환하고 전체 파서가 실패해요.
파서는 Parser.run으로 실행할 수 있어요:
Parser.run versionP "HAI 1"
--> Ok (Version 1)
Parser.run versionP "HAI 2"
--> Ok (Version 2)
Parser.run versionP "BYE 2"
--> Err [{ problem = ExpectingKeyword "HAI", col = 1, row = 1 }]
Parser.run versionP "HAI MOM"
--> Err [{ problem = ExpectingInt, col = 5, row = 1 }]
이제 "CAN HAS STDIO?" 줄을 다음으로 처리해봐요.
importP : Parser Command
importP =
Parser.succeed Import
|. Parser.keyword "CAN HAS"
|. Parser.spaces
|= packageP
|. Parser.symbol "?"
importP도 파서 파이프라인이지만, 이번에는 "?" 같은 기호를 파싱하는 데 적합한 Parser.symbol로 끝나요.
우리가 유지하고 싶은 값(임포트할 패키지 이름)은 다음으로 파싱해요.
packageP : Parser String
packageP =
Parser.chompWhile Char.isAlphaNum
|> Parser.getChompedString
|> Parser.map String.toLower
패키지 이름이 뭘지 모르기 때문에 Parser.keyword를 쓸 수 없어요.
대신 조건을 만족하는 한(여기서는 영숫자 문자인 경우) 문자를 하나씩 소비하는 Parser.chompWhile을 사용해요.
이렇게 소비한 문자들은 Parser String을 반환하는 Parser.getChompedString으로 모여요.
이 경우 패키지 이름을 정규화하고 싶어서, Parser.map을 사용해 파서에 담긴 문자열을 수정해 소문자로 만들어요.
Parser.run importP "CAN HAS STDIO?"
--> Ok (Import "stdio")
Parser.run importP "CAN HAS StDiO?"
--> Ok (Import "stdio")
Parser.run importP "CAN HAS STDIO"
--> Err [{ problem = ExpectingSymbol "?", col = 14, row = 1 }]
다음으로, VISIBLE "HAI WORLD!"는 다음으로 파싱해요.
printP : Parser Command
printP =
Parser.succeed Print
|. Parser.keyword "VISIBLE"
|. Parser.spaces
|. Parser.symbol "\""
|= stringP
|. Parser.symbol "\""
stringP : Parser String
stringP =
Parser.chompWhile (\c -> c /= '"')
|> Parser.getChompedString
Parser.run printP "VISIBLE \"HAI WORLD!\""
--> Ok (Print "HAI WORLD!")
이제 각 명령에 대한 파서가 있어요. 물론 일반적인 프로그램에서는 이 명령들의 순서를 예측할 수 없어서, 범용 명령 파서를 만들어요.
commandP : Parser Command
commandP =
Parser.oneOf
[ versionP
, importP
, printP
]
Parser.oneOf는 주어진 순서대로 파서를 하나씩 시도해요.
파서가 실패하면 다음 것을 시도하지만, 성공하면 나머지 파서를 시도하지 않고 그 결과를 반환해요.
모든 파서가 실패하면 Parser.oneOf도 실패해요.
Parser.run commandP "HAI 2"
--> Ok (Version 2)
Parser.run commandP "CAN HAS STDIO?"
--> Ok (Import "stdio")
Parser.run commandP "VISIBLE \"HAI WORLD!\""
--> Ok (Print "HAI WORLD!")
Parser.run commandP "O NOES"
--> Err [ { problem = ExpectingKeyword "HAI", col = 1, row = 1 }
-- , { problem = ExpectingKeyword "CAN HAS", col = 1, row = 1 }
-- , { problem = ExpectingKeyword "VISIBLE", col = 1, row = 1 }
-- ]
실패한 시도마다 Parser.oneOf가 모으는 Parser.DeadEnd를 만들어낸다는 점에 주목해요.
전체 프로그램을 파싱하려면 여러 명령을 순차적으로 파싱해야 하는데, 다음과 같이 해요.
programP : Parser (List Command)
programP =
Parser.sequence
{ start = ""
, separator = "\n"
, end = "KTHXBYE"
, spaces = Parser.succeed ()
, item = commandP
, trailing = Parser.Optional
}
Parser.sequence는 여러 옵션을 가진, 아주 유연하게 설정할 수 있는 파서예요.
start, separator, end는 시퀀스의 항목을 구분하는 문자열을 받는데, Elm 리스트를 파싱할 때는 "[", ",", "]"를 써요.
spaces는 항목과 구분자 사이의 잠재적인 공백을 처리할 파서를 받아 유연성을 제공하는데, Elm 리스트를 파싱할 때는 Parser.spaces를 쓰지만 이 경우에는 공백을 허용하지 않으므로 아무것도 실제로 소비하지 않고 바로 성공하는 Parser.succeed ()를 써요.
item은 항목 파서를 받고, 마지막으로 trailing은 뒤따르는 구분자가 Parser.Forbidden, Parser.Mandatory, Parser.Optional 중 무엇인지 정할 수 있게 해줘요.
Parser.run programP "HAI 2\nCAN HAS STDIO?\nKTHXBYE"
--> Ok [Version 2, Import "stdio"]
Parser.run programP "KTHXBYE"
--> Ok []
Parser.run programP "HAI 2\nCAN HAS STDIO?\n"
--> Err [ { problem = ExpectingKeyword "HAI", col = 1, row = 3 }
-- , { problem = ExpectingKeyword "CAN HAS", col = 1, row = 3 }
-- , { problem = ExpectingKeyword "VISIBLE", col = 1, row = 3 }
-- , { problem = Expecting "KTHXBYE", col = 1, row = 3 }
-- ]
파서가 성공하면 문자열 소비를 멈춰요.
Parser.run Parser.int "1"
--> Ok 1
Parser.run Parser.int "1 BTW VISIBLE \"U SEE NOTHING\""
--> Ok 1
그래서 문자열의 모든 것을 파싱했는지 확인해야 한다면 Parser.end를 사용할 수 있어요.
fullProgramP : Parser (List Command)
fullProgramP =
programP
|. Parser.spaces
|. Parser.end
Parser.run fullProgramP "HAI 2\nCAN HAS STDIO?\nKTHXBYE"
--> Ok [Version 2, Import "stdio"]
Parser.run fullProgramP "HAI 2\nCAN HAS STDIO?\nKTHXBYE\nBTW VISIBLE \"U SEE NOTHING\""
--> Err [{ problem = ExpectingEnd, col = 1, row = 4 }]
마지막으로, 파싱한 내용에 따라 파서를 실패시키는 결정을 내려야 할 때가 있어요. 예를 들어, "HAI" 명령 두 개로 연달아 시작하는 프로그램을 실패시키는 파서가 필요하다면 다음을 쓸 수 있어요.
singleVersionProgramP : Parser (List Command)
singleVersionProgramP =
fullProgramP
|> Parser.andThen
(\commands ->
case commands of
(Version _) :: (Version _) :: _ ->
Parser.problem "Multiple versions defined"
_ ->
Parser.succeed commands
)
Parser.andThen은 파서의 내용을 살펴보고 그 값에 따라 새로운 파서를 반환해요.
여기서는 한 경우에 Parser.problem으로 커스텀 메시지와 함께 파서를 실패시키고, 다른 경우에는 원래 값을 반환했어요.
Parser.run singleVersionProgramP "HAI 2\nCAN HAS STDIO?\nKTHXBYE"
--> Ok [Version 2, Import "stdio"]
Parser.run singleVersionProgramP "HAI 1\nHAI 2\nCAN HAS STDIO?\nKTHXBYE"
--> Err [{ problem = Problem "Multiple versions defined", col = 8, row = 4 }]
여러분은 새로운 레스토랑 매니저로 Paola's Prestigious Pizza에 합류하게 되어 설레요. 이곳을 효율적으로 운영할 계획이 많지만, 그러려면 데이터가 좀 필요해요. 가장 먼저 메뉴를 분석해야 해요.
메뉴에는 여러 종류의 피자가 있어요. Paola가 텍스트 파일로 목록을 줬는데, 한 줄에 피자 하나씩 들어 있어요. 각 피자 항목은 이름, 있을 수도 있는 채식 표시, 가격이라는 세 가지 요소로 이루어져 있어요.
메뉴의 처음 몇 줄을 살펴봐요:
Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...
가격부터 시작하는 게 좋겠어요. 모든 가격은 유로화의 정수예요.
priceParser를 구현해서 가격을 파싱해요.
모든 가격이 유로이므로 통화를 따로 기억할 필요는 없지만, 그래도 파싱은 해야 해요.
Parser.run priceParser "8€"
--> Ok 8
Parser.run priceParser "8"
--> Err [{ problem = ExpectingSymbol "€", col = 2, row = 1 }]
메뉴를 다시 살펴봐요:
Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...
채식 표시 "(v)"도 꽤 간단해서, 다음으로 이걸 하기로 해요!
vegetarianParser를 구현해서 표시를 파싱해요.
파서는 Bool을 반환해야 하는데, 표시가 있으면 True, 없으면 False예요.
Parser.run vegetarianParser "(v)"
--> Ok True
Parser.run vegetarianParser ""
--> Ok False
자, 그다음은 뭘까요?
Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...
재료와 피자 이름은 꽤 단순해 보여요. 모두 대문자와 소문자 ASCII 문자로 이루어진 한 단어예요.
wordParser를 구현해서 이름을 파싱해요.
겸사겸사 모든 단어를 통일성을 위해 소문자로 만들어요.
Parser.run wordParser "REGINA"
--> Ok "regina"
Parser.run wordParser "tomato"
--> Ok "tomato"
Parser.run wordParser "(v)"
--> Ok ""
Parser.run wordParser ""
--> Ok ""
그런데 재료는 여러 개가 한데 모여 있어요:
Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...
쉼표와 간혹 공백으로 구분된 한 단어들이에요.
ingredientsParser를 구현해서 재료들을 파싱해요.
Parser.run ingredientsParser "tomato, emmental"
--> Ok ["tomato", "emmental"]
Parser.run ingredientsParser "tomato"
--> Ok ["tomato"]
이제 재료가 다 모였으니 Pizza를 만들 시간이에요!
Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...
먼저 피자 이름, 있을 수도 있는 채식 표시, 콜론 ':', 재료 목록, 대시 '-', 그리고 가격이 오는데, 사이사이에 공백이 있을 수 있어요.
pizzaParser를 구현해서 피자 전체를 파싱해요.
Parser.run pizzaParser "Regina: tomato, ham, mushrooms, cantal - 11€"
--> Ok (Pizza "regina" False ["tomato", "ham", "mushrooms", "cantal"] 11)
파티할 시간이에요!
menuParser를 구현해서 텍스트 파일에서 줄바꿈 문자 '\n'로 구분된 모든 피자 목록을 파싱해요.
파서를 파일 끝에 도달할 때까지 실행해서 피자를 모두 가져왔는지 확인해요.
Parser.run menuParser "Regina: tomato, ham, mushrooms, cantal - 11€\nFormaggio (v): tomato, emmental - 8€"
--> Ok [Pizza "regina" False ["tomato", "ham", "mushrooms", "cantal"] 11,
-- Pizza "formaggio" True ["tomato", "emmental"] 8]
Parser.run menuParser "Regina: tomato, ham, mushrooms, cantal - 11€\[END]"
--> Err [{ problem = ExpectingEnd, col = 1, row = 2 }]
이런, 뭔가를 놓친 것 같아요. 메뉴 아래쪽 어딘가에, 한 단어가 아닌 재료들이 있어요:
...
Tonno: tomato sauce, tuna - 10€
Hawaii: tomato sauce, fresh pineapple, ham - 9€
...
oneIngredientParser를 구현하는데, 대문자와 소문자 ASCII 문자 또는 공백 ' '을 받아들여야 해요.
겸사겸사 wordParser에 있던 작은 결함도 고쳐서, 빈 문자열이 유효한 재료로 인식되지 않게 하고 대신 Problem "empty string"을 내보내요.
그러는 김에 문자열을 소문자로 만들고 양쪽의 공백 문자도 제거해요.
Parser.run oneIngredientParser "Tomato Sauce"
--> Ok "tomato sauce"
Parser.run oneIngredientParser " tomato sauce "
--> Ok "tomato sauce"
Parser.run oneIngredientParser ""
--> Err [{ problem = Problem "empty string", col = 1, row = 1 }]
파서를 정의했으면 ingredientsParser에서 사용해요.
Exercism에 가입하고 Elm 트랙을 개념 28개연습 문제 110개, 그리고 실제 사람의 멘토링과 함께 배우고 익혀 보세요. 모두 무료예요.