۲۳ - ۷پارسینگِ فایل‌های پیکربندی

برای مثال‌های بعدی از فرمت ِ فایل‌های پیکربندیِ INI* استفاه می‌کنیم. یکی از دلایل‌ش اینه که خیلی فرمت ِ رسمی‌ای نیست و میشه سریع و آزادانه باهاش کار کنیم، که برای یادگیری و آزمایش مناسبه. یه دلیلِ دیگه اینه که نسبتاً پیچیدگیِ کمی داره.

*

INI یه استاندارِ غیررسمی برای فایل‌های پیکربندی در بعضی سکو‌هاست. اسم‌ش براساسِ پسوند فایل‌ش، ‏‎.ini‎‏ که مخففِ initialization (مقداردهیِ اولیه) هست گذاشته شده.

یه مثال کوچولو از یه فایلِ پیکربندی ِ INI:

; comment
[section]
host=wikipedia.org
alias=claw

این فایلِ نمونه یه کامنت داره، که تأثیری روی داده ِ پارس‌شده از فایلِ پیکربندی نداره، ولی می‌تونه برای تنظیماتی که پیکربندی می‌کنه توضیحاتی بده. خطِ بعد یه عنوانِ بخش به اسمِ ‏‎section‎‏ داره. این بخش خودِش شامل دوتا تنظیمات‌ه: یکی به نامِ ‏‎host‎‏ با مقدارِ ‏‎wikiperdia.org‎‏، یکی هم به اسمِ ‏‎alias‎‏ با مقدارِ ‏‎claw‎‏.

این مثال رو با پراگما‌ها، تعریفِ ماژول، و واردات‌ها شروع می‌کنیم:

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes       #-}

module Data.Ini where

import Control.Applicative
import Data.ByteString (ByteString)
import Data.Char (isAlpha)
import Data.Map (Map)
import qualified Data.Map as M
import Data.Text (Text)
import qualified Data.Text.IO as TIO
import Test.Hspec
import Text.RawString.QQ
-- parsers 0.12.3, trifecta 1.5.2
import Text.Trifecta

دیگه باید با ‏‎OverloadedStrings‎‏ و ‏‎QuasiQuotes‎‏ آشنا باشین.*

*

م. کاری که پراگما ِ ‏‎OverloadedStrings‎‏ انجام میده، در واقع پلی‌مورفیک کردنِ لفظ‌های نوشته هست (شبیهِ وضعی که لفظ‌های عددی بصورتِ پیش‌فرض دارن) تا بشه تایپ‌های دیگه‌ای بهشون اختصاص داد (مثلِ ‏‎ByteString‎‏ و ‏‎Text‎‏).

آسون‌ترین راهِ نوشتن پارسرها در هسکل اینه که اول با پارسرهای کوچکتر که مسائلِ ریزتَر رو حل می‌کنن شروع کنین، بعد اونها رو با هم ترکیب کنین تا پارسر ِ نهایی رو بدست بیارین. این دستورالعملِ ایده‌آل برای درکِ پارسر‌هاتون نیست، اما اینکه میشه به سادگیِ توابع اونها رو با هم ترکیب کرد خیلی کارا و خوبه. کارِمون رو با تعریفِ یه ورودیِ تست برای عنوان ِ INI، یه نوع‌داده، و بعد پارسر‌ِش ادامه میدیم:

headerEx :: ByteString
headerEx = "[blah]"

-- "[blah]" -> Section "blah"
newtype Header =
  Header String
  deriving (Eq, Ord, Show)

parseBracketPair :: Parser a -> Parser a
parseBracketPair p =
  char '[' *> p <* char ']'
-- این اپراتورها باعث میشن کروشه‌ها
-- پارس بشن بعد دور انداخته بشن، اما
-- ‎‏به عنوان جواب باقی می‌مونه.‏‎ p

parseHeader :: Parser Header
parseHeader =
  parseBracketPair (Header <$> some letter)

اینجا دوتا پارسر رو ترکیب کردیم تا یه Header پارس کنیم. میشه هرکدوم‌شون رو تو REPL آزمایش کنیم. اول تایپِ ‏‎some letter‎‏ که به ‏‎parseBracketPair‎‏ دادیم رو بررسی می‌کنیم:

> :t some letter
ome letter :: CharParsing f => f [Char]
> :t Header <$> some letter
eader <$> some letter :: CharParsing f => Header
> let slp = Header <$> some letter :: Parser Header

تایپِ اول برای پارسِر ایه که حروف رو می‌شناسه و در صورت موفقیت یه مقدارِ ‏‎String‎‏ تولید می‌کنه. تایپِ دوم هم همونه، فقط بجای یه ‏‎String‎‏، یه مقدارِ ‏‎Header‎‏ درست می‌کنه. تایپ‌های پارسر در هسکل تقریباً همیشه احتمالِ شکست هم در نظر می‌گیرن؛ جلوتر در این فصل چگونگی‌ش رو توضیح میدیم. تایپِ سوم، بجای تایپِ پلی‌مورفیک ِ ‏‎f‎‏، تایپِ معین ِ ‏‎Parser‎‏ از ‏‎trifecta‎‏ رو میده.

تابعِ ‏‎letter‎‏ یک کاراکتر مجرد رو پارس می‌کنه، و ‏‎some letter‎‏ یک کاراکتر یا بیشتر رو. باید با سازنده ِ ‏‎Header‎‏ بپوشونیم‌ش تا جواب‌مون (هر حرفی که ممکنه داخلِ کروشه‌ها باشه، همون ‏‎p‎‏ از ‏‎parseBracketPair‎‏) به عنوانِ ‏‎Header‎‏ ِ فایل (م. به معنای عنوانِ فایل) در پارس ِ نهایی مشخص بشه.

‏‎assignmentEx‎‏ یه ورودیِ نمونه از یک واگذاری در فایلِ پیکربندی ِه، که برای تست‌کردنِ پارسر‌ِمون ازش استفاده می‌کنیم. تایپ‌های مستعار هم فقط برای خوانایی بیشتر تعریف کردیم. چیز خاصی نیست:

assignmentEx :: ByteString
assignemntEx = "woot=1"

type Name = String
type Value = String
type Assignments = Map Name Value

parseAssignment :: Parser (Name, Value) 
parseAssignment = do
  name <- some letter
  _ <- char '='
  val <- some (noneOf "\n")
  skipEOL -- خیلی مهم!
  return (name, val)

-- | آخرِ خط و فاصله‌های سفیدِ
-- | .بعد از اون رو رَد کن
skipEOL :: Parser Header
skipEOL = skipMany (oneOf "\n")

پارسر ِ ‏‎parseAssignment‎‏ رو قدم به قدم توضیح میدیم. برای پارس کردنِ کلید ِ یه واگذاری، یک یا چند حرف رو پارس می‌کنیم:

name <- some letter

بعد ‏‎"="‎‏، که کلید رو از مقدار جدا می‌کنه پارس می‌کنیم و دور میندازیم.

_ <- char "="

بعد هم کاراکترها رو تا جایی که خطِ‌جدید نیستن پارس می‌کنیم. این کار رو کردیم تا حروف، اعداد، و فاصله‌های سفید پارس بشن:

val <- some (noneOf "\n")

"پایانِ خط" رو رَد می‌کنیم تا دیگه کاراکترهای خطِ‌جدید نبینیم:

skipEOL -- خیلی مهم!

با این کار پایانِ واگذاری‌ها مشخص میشن و راحت میشه بیشتر از یک واگذاری رو پارس کرد. یه حالتِ دیگه از همین پارسر که ‏‎skipEOL‎‏ نداره رو فرض کنین:

parseAssignment' :: Parser (Name, Value) 
parseAssignment' = do
  name <- some letter
  _ <- char '='
  val <- some (noneOf "\n")
  return (name, val)

اگه امتحان‌ش کنیم:

λ> let spa' = some parseAssignment'
λ> let s = "key=value\nblah=123"
λ> parseString spa' mempty s
Success [("key","value")]

می‌بینین... نتونست واگذاری ِ دوم رو پارس کنه. اما نسخه‌ی اول که ‏‎skipEOL‎‏ رو داره می‌تونه:

λ> let spa = some parseAssignment
λ> parseString spa mempty s
Success [("key","value"),("blah","123")]
λ> let d = "key=value\n\n\ntest=data"
λ> parseString spa mempty d
Success [("key","value"),("test","data")]

باید یک-یا-بیشتر کاراکترهای خطِ‌جدید که واگذاری ِ اول رو از دوم جدا می‌کنن رو رَد کنیم تا پارسرِ واگذاری با موفقیت شروع به پارس ِ حروفِ کلید ِ واگذاری ِ دوم کنه. خوش میگذره، نه؟

‏‎parseAssignment‎‏ رو با توپل کردنِ اسم و مقدارِ واگذاری، و نهایتاً قرار دادن اون توپل توی تایپِ ‏‎Parser‎‏ خاتمه میدیم:

return (name, val)

برای کامنت‌های INI، که باید کلاً رَد بشن، پارسر ِ ‏‎skipComments‎‏ رو تعریف می‌کنیم:

commentEx :: ByteString
commentEx =
  "; last modified 1 April\
  \ 2001 by John Doe"

commentEx' :: ByteString
commentEx' =
  "; blah\n; woot\n  \n;hah"

-- | از اول خط شروع کن و
-- | .کامنت‌ها رو رد کن
skipComments :: Parser ()
skipComments =
  skipMany (do _ <- char ';' <|> char '#'
               skipMany (noneOf "\n")
               skipEOL)

دوتا مثال برای پارسرِ کامنت تعریف کردیم. دقت کنید که کامنت‌ها ممکنه با ‏‎;‎‏ یا ‏‎#‎‏ شروع بشن.

بعد، پارسینگِ بخش لازم داریم. مثل کامنت‌ها، برای این هم داده ِ تست تعریف می‌کنیم. اینجا جایی‌ه که از اون توسعه ِ ‏‎QuasiQuotes‎‏ استفاده می‌کنیم تا نوشته‌های چندخطی رو خوشایندتر بنویسیم:

sectionEx :: ByteString
sectionEx =
  "; ignore me\n[states]\nChris=Texas"

sectionEx' :: ByteString
sectionEx' = [r|
; ignore me
[states]
Chris=Texas
|]

sectionEx'' :: ByteString
sectionEx'' = [r|
; comment
[section]
host=wikipedia.org
alias=claw

[whatisit]
red=intoothandclaw
|]

بعد میریم سراغِ پارسینگِ بخش:

data Section =
  Section Header Assignments
  deriving (Eq, Show)

newtype Config =
  Config (Map Header Assignments)
  deriving (Eq, Show)

skipWhitespace :: Parser ()
skipWhitespace =
  skipMany (char ' ' <|> char '\n')

parseSection :: Parser Section
parseSection = do
  skipWhitespace
  skipComments
  h <- parseHeader
  skipEOL
  assignments <- some parseAssignment
  return $
    Section h (M.fromList assignments)

دوتا نوع‌داده، یکی برای بخش، یکی هم برای کلِ یه فایلِ پیکربندی ِ INI تعریف کردیم. می‌بینید که الان ‏‎parseSection‎‏ هم فاصله‌های سفید، و هم کامنت‌ها رو رد می‌کنه. و بخش ِ پارس شده رو با عنوان‌ِش (همون ‏‎h‎‏) و یه ‏‎Map‎‏ از واگذاری‌ها برمی‌گردونه:

*Data.Ini> parseByteString
             parseSection
             mempty
             sectionEx
Success (Section (Header "states")
                 (fromList [("Chris","Texas")]))

خب، تا اینجا که همه چیز خوب بوده. حالا یه تابع بنویسیم که بخش‌ها رو توی یه ‏‎Map‎‏ می‌پوشونه. کلید ِ این ‏‎Map‎‏‌ها عنوان ِ بخش‌ها، و مقادیرشون ‏‎Map‎‏‌های دیگه‌ای‌اند که اسمِ واگذاری‌ها رو به مقدارشون نگاشت میدن. با استفاده از ‏‎foldr‎‏، یه لیست از بخش‌ها رو توی یک مقدار ِ ‏‎Map‎‏ جمع‌آوری می‌کنیم:

rollup :: Section
       -> Map Header Assignment
       -> Map Header Assignment
rollup (Section h a) m =
  M.insert h a m

parseIni :: Parser Config
parseIni = do
  sections <- some parseSection
  let mapOfSections =
        foldr rollup M.empty sections
  return (Config mapOfSections)

بعد از بارگذاری تو REPL، این دوتا رو اجرا کنین و خروجی‌هاشون رو با هم مقایسه کنین:

parseByteString parseIni mempty sectionEx

parseByteString parseSection mempty sectionEx

حالا اینها رو میذاریم کنار هم. می‌خوایم بدونیم پارسِرهامون کاری که باید انجام بدن رو درست انجام میدن یا نه، نمی‌خوایم یه فایلِ واقعیِ INI رو پارس کنیم. پس با ‏‎main‎‏ چندتا تستِ ‏‎hspec‎‏ رو اجرا می‌کنیم. از یه تابع کمکی به اسمِ ‏‎maybeSuccess‎‏ هم به عنوانِ بخشی از تست‌ها استفاده می‌کنیم:

maybeSuccess :: Result a -> Maybe a
maybeSuccess (Success a) = Just a
maybeSuccess _ = Nothing

main :: IO ()
main = hspec $ do

  describe "Assignment Parsing" $
    it "Can parse a simple assignment" $ do
      let m = parseByteString
              parseAssignment
              mempty assignmentEx
          r' = maybeSuccess m
      print m
      r' `shouldBe` Just ("woot", "1")

  describe "Header Parsing" $
    it "Can parse a simple header" $ do
      let m = parseByteString
                parseHeader
                mempty
                headerEx
          r' = maybeSuccess m
      print m
      r' `shouldBe` Just (Header "blah")

  describe "Comment Parsing" $
    it "Skips comment before header" $ do
      let p = skipComments >> parseHeader
          i = "; woot\n[blah]"
          m = parseByteString p mempty i
          r' = maybeSuccess m
      print m
      r' `shouldBe` Just (Header "blah")

  describe "Section Parsing" $
    it "Can parse a simple section" $ do
      let m = parseByteString
                parseSection
                mempty
                sectionEx
          r' = maybeSuccess m
          states = 
            M.fromList [("Chris", "Texas")]
          expected' =
            Just (Section (Header "blah")
                          states)
      print m
      r' `shouldBe` expected'

  describe "INI Parsing" $
    it "Can parse multiple sections" $ do
      let m = parseByteString
                parseIni
                mempty
                sectionEx''
          r' = maybeSuccess m
          sectionValues = 
            M.fromList
            [ ("alias", "claw")
            , ("host", "wikipedia.org")]
          whatisitValues = 
            M.fromList
            [ ("red", "intoothandclaw")]
          expected' =
            Just (Config
                  (M.fromList
                   [ (Header "section"
                   , sectionValues
                   , (Header "whatisit"
                   , whatisitValues)])) 
      print m
      r' `shouldBe` expected'

اجرا و آزمایش با این کُدها رو به خودتون می‌سپاریم.