packages feed

technique-0.2.5: lib/Technique/Parser.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeFamilies #-}

-- |
--
-- /Commentary/
--
-- We're optimizing for simplicity here. The language balances conventions
-- from other languages with choices to not overcomplicate things. Not
-- overloading operators, for example. Mostly we want to have "good error
-- messages" which is tough and subjective anyway. Not having multiline
-- anything, for example, might be a good choice, except that we also want to
-- be whitepsace insensitive.
module Technique.Parser
  ( -- parser for technique procedure files.
    pTechnique,
    -- everthing else is only exposed for testing purposes.
    pMagicLine,
    pSpdxLine,
    pIdentifier,
    pType,
    stringLiteral,
    numberLiteral,
    pQuantity,
    pAttribute,
    pExpression,
    pStatement,
    pBlock,
    pProcedureDeclaration,
    pProcedureCode,
  )
where

import Control.Monad
  ( unless,
    void,
  )
import Control.Monad.Combinators
  ( (<|>),
    many,
    optional,
    sepBy,
    sepBy1,
    some,
  )
import Core.Text.Rope
  ( Rope,
    appendRope,
    emptyRope,
    intoRope,
    singletonRope,
  )
import Data.Foldable
  ( foldl',
  )
import Data.Int
  ( Int64,
    Int8,
  )
import Data.Text
  ( Text,
  )
import qualified Data.Text as T (pack)
import Data.Void
  ( Void,
  )
import Technique.Language
import Technique.Quantity
import Text.Megaparsec
  ( (<?>),
    Parsec,
    getOffset,
    hidden,
    label,
    lookAhead,
    notFollowedBy,
    oneOf,
    skipMany,
    takeWhile1P,
    takeWhileP,
    try,
  )
import Text.Megaparsec.Char
  ( char,
    digitChar,
    lowerChar,
    newline,
    printChar,
    space,
    spaceChar,
    string,
    upperChar,
  )
import Text.Read
  ( readMaybe,
  )

type Parser = Parsec Void Text

__VERSION__ :: Int
__VERSION__ = 0

-- |
-- Skip /zero/ or more actual space characters. The __megaparsec__ function
-- @space@ etc consume all whitespace, not just ' '. That includes newlines,
-- which is very unhelpful.
skipSpace :: Parser ()
skipSpace = void (hidden (many (char ' ' <|> char '\t')))

-- |
-- Skip at least /one/ actual space character.
skipSpace1 :: Parser ()
skipSpace1 = void (hidden (some (char ' ' <|> char '\t')))

digitChar0 :: Parser Char
digitChar0 = label "a digit" $ digitChar

pMagicLine :: Parser Int
pMagicLine = do
  void (char '%') <?> "first line to begin with % character"
  void spaceChar <?> "a space character"
  void (string "technique")
  void spaceChar <?> "a space character"
  void (char 'v') <?> "the character 'v' and then a number"
  v <- numberLiteral <?> "the language version"
  void newline
  return (fromIntegral v)

pSpdxLine :: Parser (Rope, Maybe Rope)
pSpdxLine = do
  void (char '!') <?> "second line to begin with ! character"
  skipSpace

  -- I know we're supposed to use takeWhile1P in cases like this, but aren't
  -- we just duplicating the work of the parser combinators?
  license <-
    takeWhile1P
      (Just "software license description (ie an SPDX-Licence-Header value)")
      (\c -> not (c == ';' || c == '\n'))

  copyright <- optional $ do
    void (char ';') <?> "a semicolon"
    skipSpace
    void (char '©') <|> void (string "(c)")
    skipSpace
    takeWhile1P (Just "a copyright declaration") (/= '\n')
  void newline
  return (intoRope license, fmap intoRope copyright)

---------------------------------------------------------------------

pProcedureDeclaration :: Parser (Identifier, [Identifier], [Type], [Type])
pProcedureDeclaration = do
  name <- pIdentifier
  skipSpace
  -- zero or more separated by comma
  params <- pIdentifiers

  skipSpace
  void (char ':')
  skipSpace

  ins <- pTypes1

  skipSpace
  void (string "->")
  skipSpace

  out <- pTypes1
  return (name, params, ins, out)

identifierChar :: Parser Char
identifierChar = hidden (lowerChar <|> digitChar0 <|> char '_' <|> char '\'')

-- these do NOT consume trailing space. That's for pExpression to do.
pIdentifier :: Parser Identifier
pIdentifier = label "a valid identifier" $ do
  first <- lowerChar
  remainder <- many identifierChar
  return (Identifier (singletonRope first <> intoRope remainder))

pIdentifiers :: Parser [Identifier]
pIdentifiers = sepBy (pIdentifier <* skipSpace) (char ',' <* skipSpace)

pIdentifiers1 :: Parser [Identifier]
pIdentifiers1 = sepBy1 (pIdentifier <* skipSpace) (char ',' <* skipSpace)

typeChar :: Parser Char
typeChar = hidden (upperChar <|> lowerChar <|> digitChar0)

pType :: Parser Type
pType =
  label "a valid type" $
    try
      ( do
          void (string "()")
          return (Type "()")
      )
      <|> ( do
              first <- upperChar
              remainder <- many typeChar
              return (Type (singletonRope first <> intoRope remainder))
          )

pTypes1 :: Parser [Type]
pTypes1 = sepBy1 (pType <* skipSpace) (char ',' <* skipSpace)

---------------------------------------------------------------------

stringLiteral :: Parser Text
stringLiteral = label "a string literal" $ do
  void (char '\"')
  str <-
    many
      ( do
          try
            ( do
                void (char '\\')
                void (char '"')
                return '"'
            )
          <|> ( do
                  notFollowedBy (char '\"')
                  printChar
              )
      )
  void (char '\"')
  return (T.pack str)

unitChar :: Parser Char
unitChar = hidden (upperChar <|> lowerChar <|> char '°')

unitLiteral :: Parser Rope
unitLiteral = label "a units symbol" $ do
  str <- some unitChar
  return (intoRope str)

numberLiteral :: Parser Int64
numberLiteral = label "a number literal" $ do
  digits <- some digitChar0
  let result = readMaybe digits
  case result of
    Just number -> return number
    Nothing -> fail "expected a number but couldn't parse"

decimalLiteral :: Parser Decimal
decimalLiteral = label "a decimal literal" $ do
  digits1 <- some digitChar0
  fraction <-
    optional
      ( do
          void (char '.')
          some digitChar0
      )

  return
    ( case fraction of
        Nothing ->
          let number = read digits1
           in Decimal number 0
        Just digits2 ->
          let e = fromIntegral (length digits2)
              decimal = read digits1 * 10 ^ e + read digits2
           in Decimal decimal e
    )

superscriptLiteral :: Parser Int8
superscriptLiteral = label "a superscript literal" $ do
  sign <- optional (char '⁻' <|> char '¯') -- honestly not sure what the second of those is
  digits <- some (oneOf ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹'])
  let number = read (map toNumbers digits)
  return
    ( case sign of
        Just _ -> negate number
        Nothing -> number
    )

toNumbers :: Char -> Char
toNumbers c = case c of
  '⁰' -> '0'
  '¹' -> '1'
  '²' -> '2'
  '³' -> '3'
  '⁴' -> '4'
  '⁵' -> '5'
  '⁶' -> '6'
  '⁷' -> '7'
  '⁸' -> '8'
  '⁹' -> '9'
  _ -> error "Invalid, superscript expected"

pQuantity :: Parser Quantity
pQuantity =
  ( do
      -- look ahead far enough to commit to this branch:  the pieces of a
      -- decimal, a space, and then one of the characters that starts an
      -- uncertainty, magnitude, or symbol.
      lookAhead
        ( try
            ( do
                skipMany (digitChar0 <|> char '.' <|> char '-' <|> char ' ')
                void (char '±' <|> char '+' <|> char '×' <|> char 'x' <|> unitChar)
            )
        )

      n <- pMantissa
      u <- pUncertainty <|> pure (Decimal 0 0)
      m <- pMagnitude <|> pure 0
      s <- pSymbol
      return (Quantity n u m s)
  )
    <|> ( do
            n <- pNumber
            return (Number n)
        )
  where
    pNumber = do
      sign <- optional (char '-')
      number <- numberLiteral
      return
        ( case sign of
            Just _ -> negate number
            Nothing -> number
        )
    pMantissa = do
      sign <- optional (char '-')
      decimal <- try decimalLiteral
      skipSpace
      return
        ( case sign of
            Just _ -> negateDecimal decimal
            Nothing -> decimal
        )
    pUncertainty = do
      void (char '±') <|> void (string "+/-")
      skipSpace
      decimal <- decimalLiteral
      skipSpace
      return decimal
    pMagnitude = do
      void (char '×') <|> void (char 'x') <|> hidden (void (char '*'))
      skipSpace
      void (string "10")
      number <-
        ( do
            void (char '^')
            sign <- optional (char '-')
            e <- numberLiteral
            pure
              ( fromIntegral
                  ( case sign of
                      Just _ -> negate e
                      Nothing -> e
                  )
              )
            <|> superscriptLiteral
          )
      skipSpace
      return number
    pSymbol = do
      symbol <- unitLiteral
      skipSpace
      return symbol

pOperator :: Parser Operator
pOperator =
  (char '&' *> return WaitBoth)
    <|> (char '|' *> return WaitEither)
    <|> (char '+' *> return Combine)

-- |
-- Parse a Tablet. This consumes trailing space around initial delimiter and
-- removes blank lines within the table (they're not syntactically meaningful)
-- but only cosnsumes a single newline after trailing delimeter, leaving
-- further consumption to pStatement.
--
-- TODO this doesn't preserve alternate syntax if employed by user
pTablet :: Parser Tablet
pTablet = do
  void (char '[' <* hidden space)

  bindings <-
    many
      (pBinding <* hidden space)

  void (char ']' <* skipSpace)

  return (Tablet bindings)
  where
    pBinding = do
      name <- stringLiteral
      skipSpace
      void (char '~')
      skipSpace
      subexpr <- pExpression

      -- handle alternate syntax here
      {-
              -- FIXME this is not working
              void (optional (char ','))
      -}
      return (Binding (Label (intoRope name)) subexpr)

pAttribute :: Parser Attribute
pAttribute =
  ( do
      void (char '@')
      role <- pIdentifier <|> pAny
      return (Role role)
  )
    <|> ( do
            void (char '#')
            place <- pIdentifier <|> pAny
            return (Place place)
        )
  where
    pAny = do
      void (char '*')
      return (Identifier (singletonRope '*'))

pExpression :: Parser Expression
pExpression = do
  o <- getOffset
  expr1 <- pTerm o
  skipSpace
  rest <- (optional (try pOperation2))
  skipSpace
  case rest of
    Just (oper, expr2) -> return (Operation o oper expr1 expr2)
    Nothing -> return expr1
  where
    pTerm o =
      pNone o
        <|> pUndefined o
        <|> pRestriction o
        <|> pGrouping o
        <|> pObject o
        <|> pApplication o
        <|> pLiteral o
        <|> pVariable o
    pNone :: Offset -> Parser Expression
    pNone o = do
      void (string "()")
      return (None o)
    pUndefined o = do
      void (char '?')
      return (Undefined o)
    pOperation2 = do
      -- 2 as in 2nd half
      operator <- pOperator
      skipSpace
      subexpr2 <- pExpression
      return (operator, subexpr2)
    pRestriction o = do
      attr <- pAttribute
      hidden space
      block <- pBlock
      return (Restriction o attr block)
    pGrouping o = do
      void (char '(')
      skipSpace

      subexpr <- pExpression

      void (char ')')
      skipSpace

      return (Grouping o subexpr)
    pObject o = do
      tablet <- pTablet
      return (Object o tablet)
    pApplication o = do
      lookAhead
        ( try
            ( do
                skipMany identifierChar
                skipSpace1
                void (identifierChar <|> digitChar0 <|> char '(' <|> char '\"')
            )
        )

      name <- pIdentifier
      -- ie at least one space
      skipSpace1
      -- FIXME better do this manually, not all valid
      subexpr <- pExpression
      return (Application o name subexpr)
    pLiteral o =
      ( do
          str <- stringLiteral
          return (Text o (intoRope str))
      )
        <|> ( do
                qty <- pQuantity
                return (Amount o qty)
            )
    pVariable o = do
      names <- pIdentifiers1

      return (Variable o names)

pStatement :: Parser Statement
pStatement = do
  o <- getOffset
  statement <-
    pAssignment o
      <|> pDeclaration o
      <|> pExecute o
      <|> pBlank o
      <|> pSeries o
  return statement
  where
    pAssignment o = label "an assignment" $ do
      lookAhead
        ( try
            ( do
                skipMany (identifierChar <|> char ',' <|> char ' ')
                void (char '=')
            )
        )
      names <- pIdentifiers1
      skipSpace
      void (char '=')
      hidden space
      expr <- pExpression
      return (Assignment o names expr)
    pDeclaration o = label "a declaration" $ do
      lookAhead
        ( try
            ( do
                skipMany (identifierChar <|> char ',' <|> char ' ')
                void (char ':')
            )
        )

      proc <- pProcedureCode
      return (Declaration o proc)
    pExecute o = label "a value to execute" $ do
      expr <- pExpression
      return (Execute o expr)
    pBlank o = hidden $ do
      -- label "a blank line"
      void newline
      return (Blank o)
    pSeries o = do
      void (char ';')
      return (Series o)

---------------------------------------------------------------------

pBlock :: Parser Block
pBlock = do
  -- open block, absorb whitespace
  void (char '{' <* hidden space)

  -- process statements, but only single newline at a time
  statements <-
    many
      (pStatement <* skipSpace <* optional newline <* skipSpace)

  -- close block, and wipe out any trailing whitespace
  void (char '}' <* skipSpace <* optional newline)

  return (Block statements)

-- Frankly, this parser looks ridiculous. Someone who knows what they are
-- doing *please* help refactor this. It seems unavoidlable to run the
-- pProcedureDeclaration parser twice, unless we can combine the successful
-- parse and the consumtion of description lines into one function. Maybe
-- this would be better done scanning ahead to count characters until a
-- declaration shows up, then explicitly taking that many?

fourSpaces :: Parser ()
fourSpaces =
  -- label "a code block indented by four spaces" $
  void (char ' ' <* char ' ' <* char ' ' <* char ' ')
    <|> fail "code blocks must be indented by four spaces"

pMarkdown :: Parser Markdown
pMarkdown = do
  -- gobble blank newlines before a heading
  void
    ( many
        ( do
            notFollowedBy fourSpaces
            notFollowedBy pProcedureDeclaration
            void (skipSpace *> hidden newline)
        )
    )

  -- TODO heading

  results <-
    some
      ( do
          notFollowedBy fourSpaces
          notFollowedBy pProcedureDeclaration
          line <- takeWhileP (Just "another line of description text") (/= '\n')
          void (hidden newline)
          return line
      )

  let description = foldl' (\acc text -> appendRope text acc <> "\n") emptyRope results
  return (Markdown description)

pProcedureCode :: Parser Procedure
pProcedureCode = do
  o <- getOffset
  (name, params, ins, out) <- pProcedureDeclaration <* skipSpace <* optional newline <* skipSpace

  block <- pBlock <* skipSpace <* optional newline

  return
    ( Procedure
        { procedureOffset = o,
          procedureName = name,
          procedureParams = params,
          procedureInput = ins,
          procedureOutput = out,
          procedureTitle = Nothing, -- FIXME
          procedureDescription = Nothing,
          procedureBlock = block
        }
    )

pProcedure :: Parser Procedure
pProcedure = do
  description <- optional pMarkdown
  fourSpaces
  proc <- pProcedureCode

  return
    ( proc
        { procedureTitle = Nothing, -- FIXME
          procedureDescription = description
        }
    )

---------------------------------------------------------------------

pTechnique :: Parser Technique
pTechnique = do
  version <- pMagicLine
  unless (version == __VERSION__) (fail ("currently the only recognized language version is v" ++ show __VERSION__))
  (license, copyright) <- pSpdxLine
  void (many newline)

  body <- many pProcedure

  return $
    Technique
      { techniqueVersion = version,
        techniqueLicense = intoRope license,
        techniqueCopyright = fmap intoRope copyright,
        techniqueBody = body
      }