Advise-me-0.1: src/Recognize/Parsing/MathLexer.hs
-----------------------------------------------------------------------------
-- Copyright 2019, Advise-Me project team. This file is distributed under
-- the terms of the Apache License 2.0. For more information, see the files
-- "LICENSE.txt" and "NOTICE.txt", which are included in the distribution.
-----------------------------------------------------------------------------
-- |
-- Maintainer : bastiaan.heeren@ou.nl
-- Stability : provisional
-- Portability : portable (depends on ghc)
--
-- This module defines the lexing (1st) phase of the assessment pipeline.
-- The only function you should need is `extract`.
-- Note that we don't do true lexing: we don't convert symbols into tokens.
-- Instead, we mostly tend to remove text/symbols and do some conversion.
--
-----------------------------------------------------------------------------
module Recognize.Parsing.MathLexer (extract, useColumns, parseColumns, parseSimple, allowedWords, trim) where
import Control.Applicative (empty)
import Control.Arrow
import Control.Monad
import Data.Char
import Data.Either
import Data.List
import Data.Maybe
import Ideas.Utils.Parsing
import Recognize.Data.StringLexerOptions
import Text.Parsec (Parsec)
type MathLexer = Parsec String StringLexerOptions
-- | This is the lexer function.
--
-- It takes some options for string lexing, the string to lex and return a list of lines and a boolean denoting columnization.
extract :: StringLexerOptions -> String -> ([String], Bool)
extract opts txt =
-- (fixParens $ case parseSimple' opts pMathList $ (unlines . removeEnumerations . lines) phase2 of
(case parseSimple' opts pMathList $ (unlines . removeEnumerations . lines) phase2 of
Left msg -> error $ "Recognize.MathMathLexer.extract: " ++ txt ++ "\n" ++ msg
Right xs -> mapMaybe shorten xs, isJust columnized) -- what is the purpose of shorten here
where
phase1 = maybe txt (concatMap unlines) columnized
phase2 = case parseSimple' opts (preParser bRemDashes (replaceXByMultiplication opts)) phase1 of
(Left _ ) -> phase1
(Right r) -> r
bRemDashes = (>3) . length . filter (isPrefixOf "-" . dropWhile (==' ')) . lines $ phase1
columnized = useColumns opts txt
-- | Attempts to remove or convert certain symbols
preParser :: Bool -> Bool -> MathLexer String
preParser remDashes repAllX = let f = (++) <$> preParse <*> f
<|> [] <$ eof
in (++) <$> remFirstDash <*> f
where
preParse :: MathLexer String
preParse =
try repX
<|> try remStatement
<|> try remGraphicalSymbols
<|> repPercentage
<|> (if remDashes then remDashess else empty)
<|> (:[]) <$> anyChar
-- | Converts x% to x/100
repPercentage :: MathLexer String
repPercentage = "/100" <$ char '%'
repX :: MathLexer String
repX | repAllX = "*" <$ oneOf "xX"
| otherwise = (\a b -> a : "*" ++ [b]) <$> (alphaNum <|> char ')') <* tabsOrSpaces <* oneOf "xX" <* tabsOrSpaces <*> (alphaNum <|> char '(')
-- | Removes '->','-- >', '--'
--
-- Each graphical symbol may have more '-' characters
remGraphicalSymbols :: MathLexer String
remGraphicalSymbols = [' '] <$ (
try (many1 (char '-') <* char '>')
<|> try (many1 (char '-') <* char ' ' <* char '>')
<|> (char '-' *> char '-' *> many1 (char '-'))
)
-- potentially hazardous for input "a:" or "x:" when actually divided by something.
remStatement :: MathLexer String
remStatement = [] <$
( string "pour"
<|> string "est"
<|> try (string "avec")
<|> string "choici"
<|> (:[]) <$> oneOf "aAxX"
) <* tabsOrSpaces <* many digit <* tabsOrSpaces <* char ':'
tabsOrSpaces :: MathLexer String
tabsOrSpaces = many (char ' ' <|> char '\t')
remFirstDash :: MathLexer String
remFirstDash | remDashes = "" <$ char '-'
| otherwise = return ""
remDashess :: MathLexer String
remDashess = "\n" <$ char '\n' <* tabsOrSpaces <* char '-'
{- removeLeadingDash :: String -> String
removeLeadingDash r = if length (splitRegex (mkRegex "\n[' ']*-[' ']*") r) > 4
then subRegex (mkRegex "\n[' ']*-[' ']*") r "\n"
else r
-}
-- | Determines for a given string whether it should be parsed using columns
-- Columns can occur in a horizontal or vertical fashion.
-- Horizontal columns means that several expressions on a single line form one solution
-- Vertical columns means that several expressions directly below another form one solution
useColumns :: StringLexerOptions -> String -> Maybe [[String]]
useColumns opts s | nHzColumns > 0 -- There must be horizontal columns
&& mostLinesWithColumns -- Sometimes a single line contains columns but the rest do not.
&& maxXDimension >= maxYDimension = Just allHzColumns -- Determine whether horizontal or vertical columns are used
| not (null allVtColumns) -- There must be horizontal columns
&& mostLinesWithColumns
&& maxYDimension /= 0 = Just allVtColumns
| otherwise = Nothing
where allHzColumns = rights $ map (parseSimple' opts parseColumns) ls
nHzColumns = length allHzColumns
allVtColumns = transpose allHzColumns
maxXDimension = maximum $ map length allHzColumns
maxYDimension = maximum $ map length allVtColumns
-- removes spaces at the beginning and end of all lines
ls = map trim $ lines s
-- remove empty lines, lines containing too much NL
ls' = filter (\a -> not $ containsSpaces a || a == "") ls
mostLinesWithColumns = length ls' - nHzColumns < nHzColumns
complete' :: MathLexer a -> MathLexer a
complete' p = spaces *> (p <* eof)
parseSimple' :: StringLexerOptions -> MathLexer a -> String -> Either String a
parseSimple' opts p = left show . runParser (complete' p) opts ""
-- | Determines whether the string contains at least one space
containsSpaces :: String -> Bool
containsSpaces s = isRight $ parseSimple (many1 space) s
-- | drop spaces before and after
trim :: String -> String
trim = dropWhileEnd isSpace . dropWhile isSpace
-- | Parses several expressions on a single line
parseColumns :: MathLexer [String]
parseColumns = parseColumnLine
where
-- We separate columns on ; ; and a space. These may contain additional spaces on the left and right
seps :: MathLexer Char
seps = oneOf ";:"
spaceAsSep :: MathLexer Char
spaceAsSep = space <* many1 space
sepsWithSpaces :: MathLexer Char
sepsWithSpaces = spaces *> seps <* spaces
-- Parses two or more expressions separated by one of the above
parseColumnLine = (:) <$> allowedWords <*> many1 ((try spaceAsSep <|> sepsWithSpaces) *> allowedWords)
-- | Matches on expressions that may contain spaces. It currently does parse "60 60" as a single expression
allowedWords :: MathLexer String
allowedWords = (:) <$> choice [alphaNum, oneOf syms] <*> many (try $ optional realSpace *> choice [alphaNum, oneOf syms])
where syms ="+-–*/=,.:()[]#"
-- | Lex a space
realSpace :: MathLexer Char
realSpace = char ' '
-- | remove unwanted characters from the end
shorten :: String -> Maybe String
shorten = fmap reverse . rec . reverse
where
rec [] = Nothing
rec (x:xs)
| isSpace x = rec xs
| x `elem` "+*<=>-–/:([{^.," = rec xs
rec s = Just s
-- | Removes enumerations:
--
-- * 1:) ..
-- * 2:) ..
-- * etc
removeEnumerations :: [String] -> [String]
removeEnumerations = rec 1
where
rec :: Integer -> [String] -> [String]
rec _ [] = []
rec n (x:xs) =
case (hasNumber n x, hasNumber 1 x) of
(Just ys, _) -> ys ++ rec (n+1) xs
(_, Just ys) -> ys ++ rec 2 xs
_ -> x : rec n xs
hasNumber :: Integer -> String -> Maybe [String]
hasNumber n s
| show n `isPrefixOf` s =
case dropWhile isSpace (drop (length (show n)) s) of
sep:rest | sep `elem` ":)" ->
Just (maybeToList (shorten (dropWhile isSpace rest)))
_ -> Nothing
| otherwise = Nothing
-- addParens takes a string and adds parentheses (and other brackets) to the front and end to fix mismatches
-- If the brackets already match, the same string is returned
-- The function assumes that fixing is possible. So in "(])", it assumes that the first two characters are matching brackets and it returns "((])"
-- detParens is a helper function that determines which strings are added to the front and end
fixParens :: [String] -> [String]
fixParens = map addParens
addParens :: String -> String
addParens xs = before ++ xs ++ after
where (before, after) = detParens "" "" xs
detParens :: String -> String -> String -> (String, String)
detParens before after [] = (before, after)
detParens before after (x:xs) | x=='(' = detParens before (')' : after) xs
| x=='{' = detParens before ('}' : after) xs
| x=='[' = detParens before (']' : after) xs
detParens before [] (x:xs) | x==')' = detParens ('(' : before) [] xs
| x=='}' = detParens ('{' : before) [] xs
| x==']' = detParens ('[' : before) [] xs
| otherwise = detParens before [] xs
detParens before (y:ys) (x:xs) | elem x ")}]" = detParens before ys xs
| otherwise = detParens before (y:ys) xs
-- | Lexes math symbols
pMathList :: MathLexer [String]
pMathList = catMaybes <$>
many (Just <$> pMath <|> Nothing <$ pRest)
-- | Used to parse anything other than math symbols
pRest :: MathLexer String
pRest = concat <$> many1 (pWord <|> otherSym)
-- | Anything but a math symbol
otherSym :: MathLexer String
otherSym = do
notFollowedBy mathFirstSym
return <$> anyChar
-- | Natural language
pWord :: MathLexer String
pWord = try $ do
notFollowedBy pVar
notFollowedBy reservedWords
many1 (letter <|> oneOf "'") -- removed '-' (also math symbol)
-- | Match a given string to input case insensitive
pCaseInsensitive :: String -> MathLexer String
pCaseInsensitive = foldr op (return [])
where
op :: Char -> MathLexer String -> MathLexer String
op x m = (toLower x :) <$ (char (toUpper x) <|> char (toLower x)) <*> m
-- | Lex variables.
--
-- Lexing is done case insensitive and we take into account variable white listing
pVar :: MathLexer String
pVar = getState >>= \opts ->
choice [ try (pCaseInsensitive s) | s <- variableWhitelist opts]
<|>
try (do x <- letter
notFollowedBy (
(if x `elem` "ab"
then (do z <- letter
guard (z `notElem` "ab")
return [z])
else return <$> letter)
<|> string "'"
)
return [x])
-- | Lex some math
pMath :: MathLexer String
pMath = (\x xs -> x ++ concat xs) <$> mathFirstSym <*> many mathSym
reservedWords :: MathLexer String
reservedWords = try (pCaseInsensitive "sqrt" <|> pCaseInsensitive "root")
mathFirstSym :: MathLexer String
mathFirstSym = number
<|> reservedWords
<|> pVar
<|> return <$> satisfy (`elem` "+-–([{")
mathSym :: MathLexer String
mathSym = number
<|> reservedWords
<|> pVar
<|> (return <$> satisfy (`elem` "+*<=>-–/:()[]{}^% ")) -- hier x aan toevoegen?
<|> (return ' ' <$ try (do _ <- satisfy (`elem` "\r\n")
notFollowedBy (char '-'))
)
number :: MathLexer String
number = digitalNumber
<|> (:[]) <$> specialNumber
<|> try (do _ <- char '.'
ds <- many1 digit
notFollowedBy (char '.')
return ("0."++ds))
digitalNumber :: MathLexer String
digitalNumber = do
xs <- many1 digit
ys <- option "" (try ((:) <$> (char '.' <|> char ',') <*> many1 digit))
return (xs ++ ys)
specialNumber :: MathLexer Char
specialNumber = oneOf "½¼¾"