Треки
/
Elm
Elm
/
Вправи
/
Престижна піца Паоли
Престижна піца Паоли

Престижна піца Паоли

Навчальна вправа

Вступ

Парсинг

Парсинг - це процес перетворення тексту на значущі дані. Функції з пакета 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 побудовано за допомогою конвеєра парсерів. Оператор (|.) означає «розібрати рядок тексту (англ. string), але відкинути результат», а (|=) означає «розібрати рядок тексту і зберегти результат». У цьому прикладі 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.getChompedString, який повертає Parser String. У цьому разі ми хочемо нормалізувати назви пакетів, тож використовуємо 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.DeadEnd, який збирає Parser.oneOf.

Щоб розібрати повну програму, нам потрібно послідовно розібрати кілька команд ось так:

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 дала нам список у текстовому файлі, по одній піці на рядок. Кожен запис про піцу складається з трьох частин: назви, необовʼязкового індикатора вегетаріанства та ціни.

1. Розібрати ціну піци

Погляньмо на перші кілька рядків меню:

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 }]

2. Розібрати індикатор вегетаріанства

Погляньмо на меню ще раз:

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

3. Розібрати назви піци та інгредієнтів

Гаразд, що далі?

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 ""

4. Розібрати список інгредієнтів

Інгредієнти, втім, ідуть групами:

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"]

5. Розібрати повну піцу

Тепер, коли в нас є всі інгредієнти, час зробити 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)

6. Розібрати повне меню

Час святкувати!

Реалізуйте 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 }]

7. Розібрати багатослівні назви інгредієнтів

Ой лихо, здається, ми щось пропустили. Десь далі в меню деякі інгредієнти складаються не з одного слова:

...
Tonno: tomato sauce, tuna - 10€
Hawaii: tomato sauce, fresh pineapple, ham - 9€
...

Реалізуйте oneIngredientParser, який прийматиме символи ASCII у верхньому і нижньому регістрі або пробіли ' '. Скористайтеся нагодою і виправте дрібний недолік wordParser: подбайте, щоб порожні рядки тексту (англ. string) не вважалися дійсними інгредієнтами, а натомість видавався 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.

Редагувати через GitHub Посилання відкривається в новому вікні або вкладці
Elm Exercism

Час розпочати Престижна піца Паоли?

Зареєструйтеся на Exercism, щоб вивчати й опановувати Elm, а також 28 концепцій110 вправ та справжнє наставництво від людей, і все це безкоштовно.