packages feed

spdx-license-0.1.0: src/Distribution/SPDX/Template.hs

{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE StrictData #-}

-- | Parsing, pretty-printing and rendering of SPDX license templates
module Distribution.SPDX.Template
  ( Parser,
    License (..),
    Piece (..),
    SubstitutionError (..),
    license,
    prettyLicense,
    render,
  )
where

import Data.Functor
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import Data.String.Interpolate
import Data.Text (Text, pack)
import qualified Data.Text as T
import Data.Void
import GHC.Generics
import Text.Megaparsec
  ( MonadParsec (eof, notFollowedBy, takeWhileP, try),
    Parsec,
    anySingle,
    chunk,
    many,
    single,
    some,
    (<|>),
  )
import Text.Regex.TDFA ((=~))

type Parser = Parsec Void Text

data SubstitutionError = RegexNoMatch
  { name :: {-# UNPACK #-} Text,
    original :: {-# UNPACK #-} Text,
    match :: {-# UNPACK #-} Text,
    target :: {-# UNPACK #-} Text
  }
  deriving (Eq, Generic)

instance Show SubstitutionError where
  show RegexNoMatch {..} =
    [i|the substitution target "#{target}" does not match the regex "#{match}" required by the var "#{name}"|]

newtype License = License [Piece]
  deriving (Show, Eq, Generic)

data Piece
  = Substansive {-# UNPACK #-} Text
  | Optional [Piece]
  | Var
      { name :: {-# UNPACK #-} Text,
        -- | The original content
        original :: {-# UNPACK #-} Text,
        -- | A POSIX ERE that any new value must match
        match :: {-# UNPACK #-} Text
      }
  deriving (Show, Eq, Generic)

prettyPiece :: Piece -> Text
prettyPiece (Substansive s) = s
prettyPiece (Optional o) = [i|<<beginOptional>>#{foldMap prettyPiece o}<<endOptional>>|]
prettyPiece Var {..} = [i|<<var;name="#{name}";original="#{original}";match="#{match}">>|]

-- | Pretty-print a license template
prettyLicense :: License -> Text
prettyLicense (License ps) = foldMap prettyPiece ps

bra :: Parser ()
bra = chunk "<<" $> ()

ket :: Parser ()
ket = chunk ">>" $> ()

bracket :: Parser a -> Parser a
bracket p = bra *> p <* ket

semi :: Parser ()
semi = single ';' $> ()

quote :: Parser a -> Parser a
quote p = single '"' *> p <* single '"'

eq :: Parser ()
eq = single '=' $> ()

field :: Text -> Parser Text
field fieldName = do
  _ <- chunk fieldName
  eq
  quote $ takeWhileP Nothing (/= '"')

beginOptional :: Parser ()
beginOptional = bracket $ chunk "beginOptional" $> ()

endOptional :: Parser ()
endOptional = bracket $ chunk "endOptional" $> ()

substansive :: Parser Piece
substansive = do
  l <- some $ do
    notFollowedBy var
    notFollowedBy beginOptional
    notFollowedBy endOptional
    anySingle
  pure $ Substansive $ pack l

optional :: Parser Piece
optional = do
  beginOptional
  l <- many piece
  endOptional
  pure $ Optional l

var :: Parser Piece
var = bracket $ do
  _ <- chunk "var"
  semi
  name <- field "name"
  semi
  original <- field "original"
  semi
  match <- field "match"
  pure Var {..}

piece :: Parser Piece
piece = try var <|> try optional <|> try substansive

-- | License parser
license :: Parser License
license = do
  pieces <- many piece
  eof
  pure $ License pieces

substitute :: Map Text Text -> Piece -> Either SubstitutionError Text
substitute _ (Substansive s) = pure s
substitute ctx (Optional ps) = substitute' ctx ps
substitute ctx Var {..} =
  let mTarget = M.lookup name ctx
   in maybe
        (pure original)
        ( \target ->
            if target =~ match
              then pure target
              else Left $ RegexNoMatch {..}
        )
        mTarget

substitute' :: Map Text Text -> [Piece] -> Either SubstitutionError Text
substitute' ctx ps = T.concat <$> traverse (substitute ctx) ps

-- | Render a license from a context, if a var is not in the context, the original value will be used
render :: Map Text Text -> License -> Either SubstitutionError Text
render ctx (License ps) = T.concat <$> traverse (substitute ctx) ps