packages feed

json-spec-2.0.0.0: test/language.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}

{-# OPTIONS_GHC -Werror=missing-import-lists #-}

module Main (main) where

import Data.Either (isLeft)
import Data.JsonSpec
  ( BindingSpec(ModuleBind, TypeBind), FieldSpec(Optional, Required)
  , Module(Module)
  , Specification
    ( JsonArray, JsonBool, JsonDateTime, JsonDict, JsonEither, JsonInt, JsonLet
    , JsonNullable, JsonNum, JsonObject, JsonRaw, JsonRef, JsonString, JsonTag
    )
  )
import Data.JsonSpec.Language.Parser
  ( Binding(ModuleBinding, TypeBinding), Field(Field), Program(Program)
  , Spec
    ( ArraySpec, BoolSpec, DateTimeSpec, DictSpec, EitherSpec, IntSpec, LetSpec
    , NullSpec, NumberSpec, ObjectSpec, RawSpec, RefSpec, StringSpec, TagSpec
    )
  , parseProgram
  )
import Data.JsonSpec.Language.QQ (jsonspec)
import Data.Text (Text)
import Prelude (Applicative(pure), Bool(False, True), Either(Right), ($), IO)
import Test.Hspec (describe, hspec, it, shouldBe, shouldSatisfy)
import qualified Test.Hspec as Hspec

{-| Proxy for types of kind 'Module'. -}
data Mod (m :: Module) = Mod


main :: IO ()
main =
  hspec suite


suite :: Hspec.Spec
suite = do
  describe "parser" parserTests
  describe "quasi-quoter" qqTests


parserTests :: Hspec.Spec
parserTests = do
  it "parses a trivial module" $
    parseProgram "trivial" "module Id = string"
      `shouldBe`
        Right (Program "Id" StringSpec)

  it "parses Person from the language spec" $
    parseProgram "Person" personSrc
      `shouldBe`
        Right (Program "Person" personAst)

  it "parses Graphs mutual recursion" $
    parseProgram "Graphs" graphsSrc
      `shouldBe`
        Right (Program "Graphs" graphsAst)

  it "parses Demo with nested closed module" $
    parseProgram "Demo" demoSrc
      `shouldBe`
        Right (Program "Demo" demoAst)

  it "parses either, dict, null, tags, and primitives" $
    parseProgram "Kitchen" kitchenSrc
      `shouldBe`
        Right (Program "Kitchen" kitchenAst)

  it "allows comments and trailing commas" $
    parseProgram "Comments" commentsSrc
      `shouldBe`
        Right
          (Program "Comments"
            (ObjectSpec
              [ Field "a" False IntSpec
              , Field "b" True StringSpec
              ]))

  it "rejects duplicate field names" $
    parseProgram "DupField"
      "module X = { \"a\": int, \"a\": string }"
      `shouldSatisfy` isLeft

  it "rejects duplicate binding names" $
    parseProgram "DupBind"
      "module X = let { type A = int type A = string in A }"
      `shouldSatisfy` isLeft

  it "rejects keyword used as bare identifier" $
    parseProgram "Keyword"
      "module type = string"
      `shouldSatisfy` isLeft

  it "allows backtick-escaped keyword as module name" $
    parseProgram "EscapedMod"
      "module `type` = string"
      `shouldBe`
        Right (Program "type" StringSpec)

  it "allows backtick-escaped keyword bind and ref" $
    parseProgram "EscapedBind"
      "module X = let { type `string` = int in `string` }"
      `shouldBe`
        Right
          (Program "X"
            (LetSpec
              [TypeBinding "string" IntSpec]
              (RefSpec "string")))

  it "keeps bare string as the primitive, not a ref" $
    parseProgram "BarePrim"
      "module X = let { type `string` = int in string }"
      `shouldBe`
        Right
          (Program "X"
            (LetSpec
              [TypeBinding "string" IntSpec]
              StringSpec))


qqTests :: Hspec.Spec
qqTests = do
  it "quotes a trivial module" $
    sameModule
      (Mod @TrivialQuoted)
      (Mod @('Module 'JsonString))

  it "quotes Person" $
    sameModule
      (Mod @PersonQuoted)
      (Mod @PersonExpected)

  it "quotes the exhaustive Demo program" $
    sameModule
      (Mod @DemoQuoted)
      (Mod @DemoExpected)

  it "quotes either / dict / tags / datetime / raw" $
    sameModule
      (Mod @KitchenQuoted)
      (Mod @KitchenExpected)

  it "quotes backtick-escaped keyword bind and ref" $
    sameModule
      (Mod @EscapedQuoted)
      (Mod @EscapedExpected)


sameModule
  :: Mod a
  -> Mod a
  -> IO ()
sameModule _ _ =
  pure ()


type TrivialQuoted =
  [jsonspec| module Id = string |]


type EscapedQuoted =
  [jsonspec|
    module X = let {
      module `let` = let {
        type `module` = int
        in `module`
      }
      type `string` = int
      in `string`
    }
  |]


type EscapedExpected =
  'Module
    (JsonLet
      '[ ModuleBind "let" (
           'Module (
             JsonLet
               '[ TypeBind "module" JsonInt ]
               (JsonRef "module")
           )
         )
       , TypeBind "string" JsonInt
       ]
       (JsonRef "string"))


type PersonQuoted =
  [jsonspec|
    module Person = let {
      type Person = {
        "name": string,
        "age": int,
        "email"?: null string
      }
      in Person
    }
  |]


type PersonExpected =
  'Module
    (JsonLet
      '[ TypeBind "Person"
          (JsonObject
            '[ Required "name" JsonString
             , Required "age" JsonInt
             , Optional "email" (JsonNullable JsonString)
             ])
       ]
      (JsonRef "Person"))


type DemoQuoted =
  [jsonspec|
    module Demo = let {
      type Id = string

      type Money = {
        "amount": number,
        "currency": string
      }

      -- Closed: cannot see Id or Money; define what it needs locally.
      module Tax = let {
        type Rate = number
        type Line = {
          "sku": string,
          "qty": int,
          "price": {
            "amount": number,
            "currency": string
          }
        }
        in {
          "rate": Rate,
          "lines": [Line]
        }
      }

      -- Open let: may use Id, Money, Tax from the enclosing frame.
      type Invoice = let {
        type Line = {
          "sku": string,
          "qty": int,
          "price": Money
        }
        in {
          "id": Id,
          "items": [Line],
          "tax": Tax,
          "notes"?: null string
        }
      }

      in Invoice
    }
  |]


type DemoExpected =
  'Module
    (JsonLet
      '[ TypeBind "Id" JsonString
       , TypeBind "Money"
          (JsonObject
            '[ Required "amount" JsonNum
             , Required "currency" JsonString
             ])
       , ModuleBind "Tax"
          ('Module
            (JsonLet
              '[ TypeBind "Rate" JsonNum
               , TypeBind "Line"
                  (JsonObject
                    '[ Required "sku" JsonString
                     , Required "qty" JsonInt
                     , Required "price"
                        (JsonObject
                          '[ Required "amount" JsonNum
                           , Required "currency" JsonString
                           ])
                     ])
               ]
              (JsonObject
                '[ Required "rate" (JsonRef "Rate")
                 , Required "lines" (JsonArray (JsonRef "Line"))
                 ])))
       , TypeBind "Invoice"
          (JsonLet
            '[ TypeBind "Line"
                (JsonObject
                  '[ Required "sku" JsonString
                   , Required "qty" JsonInt
                   , Required "price" (JsonRef "Money")
                   ])
             ]
            (JsonObject
              '[ Required "id" (JsonRef "Id")
               , Required "items" (JsonArray (JsonRef "Line"))
               , Required "tax" (JsonRef "Tax")
               , Optional "notes" (JsonNullable JsonString)
               ]))
       ]
      (JsonRef "Invoice"))


type KitchenQuoted =
  [jsonspec|
    module Kitchen = {
      "tag": "ok",
      "choice": either int | string | bool,
      "meta": dict datetime,
      "payload": null raw
    }
  |]


type KitchenExpected =
  'Module
    (JsonObject
      '[ Required "tag" (JsonTag "ok")
       , Required "choice" (JsonEither '[JsonInt, JsonString, JsonBool])
       , Required "meta" (JsonDict JsonDateTime)
       , Required "payload" (JsonNullable JsonRaw)
       ])


personSrc :: Text
personSrc =
  "module Person = let {\n\
  \  type Person = {\n\
  \    \"name\": string,\n\
  \    \"age\": int,\n\
  \    \"email\"?: null string\n\
  \  }\n\
  \  in Person\n\
  \}"


personAst :: Spec
personAst =
  LetSpec
    [ TypeBinding "Person"
        (ObjectSpec
          [ Field "name" False StringSpec
          , Field "age" False IntSpec
          , Field "email" True (NullSpec StringSpec)
          ])
    ]
    (RefSpec "Person")


graphsSrc :: Text
graphsSrc =
  "module Graphs = let {\n\
  \  type Node = {\n\
  \    \"id\": string,\n\
  \    \"edges\": [Edge]\n\
  \  }\n\
  \  type Edge = {\n\
  \    \"from\": Node,\n\
  \    \"to\": Node\n\
  \  }\n\
  \  in Node\n\
  \}"


graphsAst :: Spec
graphsAst =
  LetSpec
    [ TypeBinding "Node"
        (ObjectSpec
          [ Field "id" False StringSpec
          , Field "edges" False (ArraySpec (RefSpec "Edge"))
          ])
    , TypeBinding "Edge"
        (ObjectSpec
          [ Field "from" False (RefSpec "Node")
          , Field "to" False (RefSpec "Node")
          ])
    ]
    (RefSpec "Node")


demoSrc :: Text
demoSrc =
  "module Demo = let {\n\
  \  type Id = string\n\
  \  type Money = {\n\
  \    \"amount\": number,\n\
  \    \"currency\": string\n\
  \  }\n\
  \  module Tax = let {\n\
  \    type Rate = number\n\
  \    type Line = {\n\
  \      \"sku\": string,\n\
  \      \"qty\": int,\n\
  \      \"price\": {\n\
  \        \"amount\": number,\n\
  \        \"currency\": string\n\
  \      }\n\
  \    }\n\
  \    in {\n\
  \      \"rate\": Rate,\n\
  \      \"lines\": [Line]\n\
  \    }\n\
  \  }\n\
  \  type Invoice = let {\n\
  \    type Line = {\n\
  \      \"sku\": string,\n\
  \      \"qty\": int,\n\
  \      \"price\": Money\n\
  \    }\n\
  \    in {\n\
  \      \"id\": Id,\n\
  \      \"items\": [Line],\n\
  \      \"tax\": Tax,\n\
  \      \"notes\"?: null string\n\
  \    }\n\
  \  }\n\
  \  in Invoice\n\
  \}"


demoAst :: Spec
demoAst =
  LetSpec
    [ TypeBinding "Id" StringSpec
    , TypeBinding "Money"
        (ObjectSpec
          [ Field "amount" False NumberSpec
          , Field "currency" False StringSpec
          ])
    , ModuleBinding "Tax"
        (LetSpec
          [ TypeBinding "Rate" NumberSpec
          , TypeBinding "Line"
              (ObjectSpec
                [ Field "sku" False StringSpec
                , Field "qty" False IntSpec
                , Field "price" False
                    (ObjectSpec
                      [ Field "amount" False NumberSpec
                      , Field "currency" False StringSpec
                      ])
                ])
          ]
          (ObjectSpec
            [ Field "rate" False (RefSpec "Rate")
            , Field "lines" False (ArraySpec (RefSpec "Line"))
            ]))
    , TypeBinding "Invoice"
        (LetSpec
          [ TypeBinding "Line"
              (ObjectSpec
                [ Field "sku" False StringSpec
                , Field "qty" False IntSpec
                , Field "price" False (RefSpec "Money")
                ])
          ]
          (ObjectSpec
            [ Field "id" False (RefSpec "Id")
            , Field "items" False (ArraySpec (RefSpec "Line"))
            , Field "tax" False (RefSpec "Tax")
            , Field "notes" True (NullSpec StringSpec)
            ]))
    ]
    (RefSpec "Invoice")


kitchenSrc :: Text
kitchenSrc =
  "module Kitchen = {\n\
  \  \"tag\": \"ok\",\n\
  \  \"choice\": either int | string | bool,\n\
  \  \"meta\": dict datetime,\n\
  \  \"payload\": null raw\n\
  \}"


kitchenAst :: Spec
kitchenAst =
  ObjectSpec
    [ Field "tag" False (TagSpec "ok")
    , Field "choice" False
        (EitherSpec [IntSpec, StringSpec, BoolSpec])
    , Field "meta" False (DictSpec DateTimeSpec)
    , Field "payload" False (NullSpec RawSpec)
    ]


commentsSrc :: Text
commentsSrc =
  "module Comments = {- block -} {\n\
  \  \"a\": int, -- line comment\n\
  \  \"b\"?: string,\n\
  \}"