Input & Output; Reading Files, Writing Files, Files Files Files!
Theirs not to make reply, theirs not to reason why, theirs but to do and die. Alfred Tennyson
Input Output
Evaluating S-Expressions as pure functions is conceptually simple: reduce terms, modify environments, and return the result. However, if usefulness is an aim, we must introduce side effects that model realworld interactions, namely reading and writing files. Haskell has already tackled this awkward squad via the IO
monad, which we can use for our Scheme.
Working with IO inside Eval
Eval
is defined with IO
inside our monad transformer stack:
newtype Eval a = Eval { unEval :: ReaderT EnvCtx IO a }
deriving (Monad, Functor, Applicative, MonadReader EnvCtx, MonadIO)
Which results in EnvCtx -> IO a
monadic action. We also have the liftIO
helper function:
liftIO :: MonadIO m => IO a -> m a
Thus, we have contained evaluation within an IO
monadic action, and have a way, liftIO
, to encapsulate IO LispVal
into Eval LispVal
. However, before we cover the functions provided for IO
, there is still one important point, Exceptions.
Exceptions
We covered exceptions and safeExec
from Eval. hs in Chpater 4.
To review. safeExec
wraps runs a basic “try/catch” over the monadic action of evaluation to make sure exceptions are caught, and control resumed. Now that we are relatively ‘safe’ using IO
inside evaluation, let’s take a look at some of input and output functions.
IO Strategy: Goals and approach
We are going to support two main operations: inputting in a script to execute, and reading and writing data files. We approach this by building smaller functions: primitives that compose well. To run scripts, we’ll also need parse
and eval
functions to ingest the String
values inputted from files. That’s pretty much it, and we we are ready for our Haskell definitions.
Running A Script
The first function we need for running a program within Scheme is called slurp
, which takes a filename and returns a String
. Within slurp
, we are using liftIO
to interleave IO LispVal
and Eval LispVal
actions. To avoid complete irresponsibility when reading files, readTextFile
ensures the file being read actually exists, and if not, throws an informative IOError
message.
slurp :: LispVal -> Eval LispVal
slurp (String txt) = liftIO $ wFileSlurp txt
slurp val = throw $ TypeMismatch "read expects string, instead got: " val
wFileSlurp :: T.Text -> IO LispVal
wFileSlurp fileName = withFile (T.unpack fileName) ReadMode go
where go = readTextFile fileName
readTextFile :: T.Text -> Handle -> IO LispVal
readTextFile fileName handle = do
exists <- doesFileExist $ T.unpack fileName
if exists
then (TIO.hGetContents handle) >>= (return . String)
else throw $ IOError $ T.concat [" file does not exits: ", fileName]
We are going to need a few more helper functions to run a program.
parse
is defined in Eval. hs. For an inputed String
, a LispVal
representing the parsed structure is returned.
parseFn :: LispVal -> Eval LispVal
parseFn (String txt) = either (throw . PError . show) return $ readExpr txt
parseFn val = throw $ TypeMismatch "parse expects string, instead got: " val
Next, the LispVal
can be evaluated using eval
. We define a Scheme function eval
in the primitive environment, which is just a shadowed version of the Haskell eval
function defined throughout Eval. hs.
Putting all of this together we get:
(eval (parse (slurp "test/let.scm")))
which can be later defined as an entry in the standard library !
Reading and Writing Data Files
We’ve covered reading files, lets take a look at writing to files. The situation is a bit complicated, as we need to use String
to represent multiple types of LispVal
if we want to store data. Fortunately, we have the Show
typeclass for LispVal
defined in a way that lets us parse/show LispVal
values for all data constructors except Fun
and Lambda
. (See showVal
in Prim.hs) Not being able to represent Fun
and Lambda
won’t hold us back, as they only emerge to represent lambda functions during evaluation or primitive the primitive environment. Let’s take a look at put
, which follows the same structure as slurp
.
put :: LispVal -> LispVal -> Eval LispVal
put (String file) (String msg) = liftIO $ wFilePut file msg
put (String _) val = throw $ TypeMismatch "put expects string in the second argument (try using show), instead got: " val
put val _ = throw $ TypeMismatch "put expects string, instead got: " val
wFilePut :: T.Text -> T.Text -> IO LispVal
wFilePut fileName msg = withFile (T.unpack fileName) WriteMode go
where go = putTextFile fileName msg
putTextFile :: T.Text -> T.Text -> Handle -> IO LispVal
putTextFile fileName msg handle = do
canWrite <- hIsWritable handle
if canWrite
then (TIO.hPutStr handle msg) >> (return $ String msg)
else throw $ IOError $ T.concat [" file does not exits: ", fileName]
There are a couple of differences besides arity, particularly putTextFile
making a safety check via hIsWritable
, and it should be noted that put
returns the String
value written. Looking at put
, we see the second argument needs to be a String
.
We define a primitive function show
, defined as Fun $ IFunc $ unop (return . String . showVal))
, taking advantage showVal
defined in LispVal.hs.
Putting it all together we get:
Repl> (put "tmp1" (show '(1 2 3)))
"(1 2 3)"
Repl> (parse (slurp "tmp1"))
"(1 2 3)"
Repl> (put "tmp1" (show (+ 1 2 3)))
"6"
Repl> (parse (slurp "tmp1"))
"6"
Helper Functions
Reading from a non-existent file is one of the most common and preventable IO mistakes, and a good demonstration of a helper function.
fileExists :: LispVal -> Eval LispVal
fileExists (String txt) = Bool <$> liftIO (doesFileExist $ T.unpack txt)
fileExists val = throw $ TypeMismatch "read expects string, instead got: " val
fileExists
is a mapping of a single LispVal
input into a function provided by System.Directory. For any serious use of our Scheme, it would be useful to wrap additional functions from System.Directory for file system manipulation.
wslurp: Files From The Web
We can add functions from Network.Http as primtive function in our Scheme. A simple one, is an extended slurp that downloads websites.
openURL :: T.Text -> IO LispVal
openURL x = do
req <- simpleHTTP (getRequest $ T.unpack x)
body <- getResponseBody req
return $ String . T.pack body
wSlurp :: LispVal -> Eval LispVal
wSlurp (String txt) = liftIO $ openURL txt
wSlurp val = throw $ TypeMismatch "wSlurp expects a string, instead got: " val
This is another exmample of embedding IO
into Eval
monadic actions.
Conclusion
Sincle we have IO
within the monad transformer stack, we can use liftIO
to perform than convert IO a
to Eval a
actions. This is important for reading and writing files, as well as other things, like foriegn function interfaces, or concurrency/parallel support. To prevent unchecked exceptions from crashing the REPL, we use the safeExec
function, which runs actions within a try/catch block and displays the result. To read and write from files, we define our Scheme functions using a series of smaller, composable functions. Together, they can run scripts and read/write data files, and perform some basic checks on the file system.
[ Understanding Check ]
Add a new Scheme function appendTo
which appends its argument to a file.
Create some more helper functions from System.Directory, something like rmFile
, createDirectory
. If possible, abstract out as much of the interface as possible.
One interesting addition would be a way to take a list of expressions, then execute each entry in a different thread, returning the results in a list.