From db2e93b2f31a85135bcd00a2d5aa561dba59844e Mon Sep 17 00:00:00 2001 From: "Elf M. Sternberg" Date: Thu, 20 Aug 2015 08:50:52 -0700 Subject: [PATCH] [refactor] Is this the functor/applicative/monadic life? This is a big change. For chapter 5, I ripped out all line/column tracking and most error handling from the parser; it's now a plain ol' Lisp parser, and if it's not close to CL 22.1.1, it's a hell of a lot closer than it used to be. In doing so, I reduced the size of the parser by about 40 lines. TrackingReader takes every function in a Reader and puts that debugging information *back*. It turns out that all that information was prelude and postlude to the act of parsing; by wrapping each function in a decorator I was able to restore all that information, and I only had to get it right exactly *once*. In functional programming terms, this lifts: IO -> (atom | list) to: IO with tracking -> Node (atom | list) with tracking It's a totally free win without having to do much extra work. Now, this check-in isn't perfect. The tracking reader is still tossing on some things, and because I don't have a robust type system (it is Coffeescript, after all), I'm having to do the decorating and wrapping by hand. But I'm definitely on my way to understanding the issues, and having a grasp on functors and monoids. --- chapter5/reader.coffee | 200 ++++++++++++----------------- chapter5/reader_rawtoform.coffee | 27 ++++ chapter5/reader_tracktoform.coffee | 37 ++++++ chapter5/tracking_reader.coffee | 22 ++++ test/reader5_samples.coffee | 22 ++++ test/test_reader5a.coffee | 15 +++ test/test_reader5b.coffee | 15 +++ 7 files changed, 220 insertions(+), 118 deletions(-) create mode 100644 chapter5/reader_rawtoform.coffee create mode 100644 chapter5/reader_tracktoform.coffee create mode 100644 chapter5/tracking_reader.coffee create mode 100644 test/reader5_samples.coffee create mode 100644 test/test_reader5a.coffee create mode 100644 test/test_reader5b.coffee diff --git a/chapter5/reader.coffee b/chapter5/reader.coffee index 1223b35..558e14a 100644 --- a/chapter5/reader.coffee +++ b/chapter5/reader.coffee @@ -1,6 +1,6 @@ {car, cdr, cons, nil, nilp, pairp, vectorToList, list} = require 'cons-lists/lists' {inspect} = require "util" -{Node, Comment, Symbol} = require "../chapter5/reader_types" +{Comment, Symbol} = require "../chapter5/reader_types" NEWLINES = ["\n", "\r", "\x0B", "\x0C"] WHITESPACE = [" ", "\t"].concat(NEWLINES) @@ -8,6 +8,10 @@ WHITESPACE = [" ", "\t"].concat(NEWLINES) EOF = new (class Eof)() EOO = new (class Eoo)() +class ReadError extends Error + name: 'LispInterpreterError' + constructor: (@message) -> + class Source constructor: (@inStream) -> @index = 0 @@ -32,33 +36,6 @@ class Source skipWS = (inStream) -> while inStream.peek() in WHITESPACE then inStream.next() -# msg -> (IO -> IO, Node) -handleError = (message) -> - (line, column) -> new Node('error', message, line, column) - -# IO -> (IO, Node) -readComment = (inStream) -> - [line, column] = inStream.position() - r = (while inStream.peek() != "\n" and not inStream.done() - inStream.next()).join("") - if not inStream.done() - inStream.next() - new Node 'comment', (new Comment r), line, column - -# IO -> (IO, Node) | Error -readString = (inStream) -> - [line, column] = inStream.position() - inStream.next() - string = until inStream.peek() == '"' or inStream.done() - if inStream.peek() == '\\' - inStream.next() - inStream.next() - if inStream.done() - return handleError("end of file seen before end of string.")(line, column) - inStream.next() - new Node 'string', (string.join ''), line, column - -# (String) -> (Symbol | Number) | Nothing readMaybeNumber = (symbol) -> if symbol[0] == '+' return readMaybeNumber symbol.substr(1) @@ -75,129 +52,116 @@ readMaybeNumber = (symbol) -> return nil undefined -# (IO, macros) -> (IO, Node => Number | Symbol) | Error -readSymbol = (inStream, tableKeys) -> - [line, column] = inStream.position() - symbol = (until (inStream.done() or inStream.peek() in tableKeys or inStream.peek() in WHITESPACE) - inStream.next()).join '' - number = readMaybeNumber symbol - if number? - return new Node 'number', number, line, column - new Node (new Symbol symbol), line, column - - -# (Delim, TypeName) -> IO -> (IO, Node) | Error +# (Delim, TypeName) -> IO -> (IO, Node) | Errorfor makeReadPair = (delim, type) -> # IO -> (IO, Node) | Error (inStream) -> inStream.next() skipWS inStream - [line, column] = inStream.position() if inStream.peek() == delim - inStream.next() - return new Node type, nil, line, column + inStream.next() unless inStream.done() + return if type then cons((new Symbol type), nil) else nil # IO -> (IO, Node) | Error dotted = false - readEachPair = (inStream) -> - [line, column] = inStream.position() - obj = read inStream, true, null, true + readEachPair = (inStream) => + obj = @read inStream, true, null, true if inStream.peek() == delim if dotted then return obj return cons obj, nil - if inStream.done() then return handleError("Unexpected end of input")(line, column) - if dotted then return handleError("More than one symbol after dot") - return obj if obj.type == 'error' - if obj.type == 'symbol' and obj.value == '.' + return obj if obj instanceof ReadError + if inStream.done() then return new ReadError "Unexpected end of input" + if dotted then return new ReadError "More than one symbol after dot in list" + if obj instanceof Symbol and obj.name == '.' dotted = true return readEachPair inStream cons obj, readEachPair inStream - ret = new Node type, readEachPair(inStream), line, column - inStream.next() - ret + obj = readEachPair(inStream) + inStream.next() + if type then cons((new Symbol type), obj) else obj # Type -> IO -> IO, Node prefixReader = (type) -> # IO -> IO, Node (inStream) -> - [line, column] = inStream.position() inStream.next() - [line1, column1] = inStream.position() obj = read inStream, true, null, true - return obj if obj.type == 'error' - new Node "list", cons((new Node("symbol", (new Symbol type), line1, column1)), cons(obj)), line, column + return obj if obj instanceof ReadError + cons((new Symbol type), obj) -# I really wanted to make anything more complex than a list (like an -# object or a vector) something handled by a read macro. Maybe in a -# future revision I can vertically de-integrate these. +class Reader + "symbol": (inStream) -> + symbol = (until (inStream.done() or @[inStream.peek()]? or inStream.peek() in WHITESPACE) + inStream.next()).join '' + number = readMaybeNumber symbol + if number? + return number + new Symbol symbol + + "read": (inStream, eofErrorP = false, eofError = EOF, recursiveP = false, keepComments = false) -> + inStream = if inStream instanceof Source then inStream else new Source inStream + + c = inStream.peek() + + # (IO, Char) -> (IO, Node) | Error + matcher = (inStream, c) => + if inStream.done() + return if recursiveP then (new ReadError 'EOF while processing nested object') else nil + if c in WHITESPACE + inStream.next() + return nil + if c == ';' + return readComment(inStream) + ret = if @[c]? then @[c](inStream) else @symbol(inStream) + skipWS inStream + ret + + while true + form = matcher inStream, c + skip = (not nilp form) and (form instanceof Comment) and not keepComments + break if (not skip and not nilp form) or inStream.done() + c = inStream.peek() + null + form + + '(': makeReadPair ')', null -readMacros = - '"': readString - '(': makeReadPair ')', 'list' - ')': handleError "Closing paren encountered" '[': makeReadPair ']', 'vector' - ']': handleError "Closing bracket encountered" + '{': makeReadPair('}', 'record', (res) -> res.length % 2 == 0 and true or mkerr "record key without value") - '}': handleError "Closing curly without corresponding opening." - "`": prefixReader 'back-quote' - "'": prefixReader 'quote' - ",": prefixReader 'unquote' - ";": readComment -# Given a stream, reads from the stream until a single complete lisp -# object has been found and returns the object - -# IO -> IO, Node -read = (inStream, eofErrorP = false, eofError = EOF, recursiveP = false, inReadMacros = null, keepComments = false) -> - inStream = if inStream instanceof Source then inStream else new Source inStream - inReadMacros = if InReadMacros? then inReadMacros else readMacros - inReadMacroKeys = (i for i of inReadMacros) - - c = inStream.peek() - - # (IO, Char) -> (IO, Node) | Error - matcher = (inStream, c) -> - if inStream.done() - return if recursiveP then handleError('EOF while processing nested object')(inStream) else nil - if c in WHITESPACE + '"': (inStream) -> + inStream.next() + s = until inStream.peek() == '"' or inStream.done() + if inStream.peek() == '\\' + inStream.next() inStream.next() - return nil - if c == ';' - return readComment(inStream) - ret = if c in inReadMacroKeys then inReadMacros[c](inStream) else readSymbol(inStream, inReadMacroKeys) - skipWS inStream - ret + return (new ReadError "end of file seen before end of string") if inStream.done() + inStream.next() + s.join '' - while true - form = matcher inStream, c - skip = (not nilp form) and (form.type == 'comment') and not keepComments - break if (not skip and not nilp form) or inStream.done() - c = inStream.peek() - null - form + ')': (inStream) -> new ReadError "Closing paren encountered" -# readForms assumes that the string provided contains zero or more -# forms. As such, it always returns a list of zero or more forms. + ']': (inStream) -> new ReadError "Closing bracket encountered" -# IO -> (IO, Nodes* | Error) -readForms = (inStream) -> - inStream = if inStream instanceof Source then inStream else new Source inStream - return nil if inStream.done() + '}': (inStream) -> new ReadError "Closing curly without corresponding opening." - # IO -> (IO, Nodes* | Error - [line, column] = inStream.position() - readEach = (inStream) -> - obj = read inStream, true, null, false - return nil if (nilp obj) - return obj if obj.type == 'error' - cons obj, readEach inStream + "`": prefixReader 'back-quote' - obj = readEach inStream - if obj.type == 'error' then obj else new Node "list", obj, line, column + "'": prefixReader 'quote' -exports.read = read -exports.readForms = readForms -exports.Node = Node -exports.Symbol = Symbol + ",": prefixReader 'unquote' + + ";": (inStream) -> + r = (while inStream.peek() != "\n" and not inStream.done() + inStream.next()).join("") + inStream.next() if not inStream.done() + new Comment r + +exports.Source = Source +exports.ReadError = ReadError +exports.Reader = Reader +reader = new Reader() +exports.read = -> reader.read.apply(reader, arguments) diff --git a/chapter5/reader_rawtoform.coffee b/chapter5/reader_rawtoform.coffee new file mode 100644 index 0000000..255fd65 --- /dev/null +++ b/chapter5/reader_rawtoform.coffee @@ -0,0 +1,27 @@ +{car, cdr, cons, listp, nilp, nil, + list, pairp, listToString} = require 'cons-lists/lists' + +{Symbol, Comment} = require './reader_types' + +exports.normalize = normalize = (form) -> + return nil if nilp form + + methods = + 'vector': (form) -> + until (nilp form) then p = normalize(car form); form = cdr form; p + + 'record': (form) -> + o = Object.create(null) + until (nilp form) + o[(normalize car form)] = (normalize car cdr form) + form = cdr cdr form + null + o + + if (listp form) and (car form) instanceof Symbol + if (car form).name in ['vector', 'record'] + methods[(car form).name](cdr form) + else + cons (normalize car form), (normalize cdr form) + else + form diff --git a/chapter5/reader_tracktoform.coffee b/chapter5/reader_tracktoform.coffee new file mode 100644 index 0000000..30a19e6 --- /dev/null +++ b/chapter5/reader_tracktoform.coffee @@ -0,0 +1,37 @@ +{car, cdr, cons, listp, nilp, nil, + list, pairp, listToString} = require 'cons-lists/lists' + +{Symbol, Comment} = require './reader_types' + +exports.normalize = normalize = (form) -> + _normalize = (form) -> + return nil if nilp form.v + + methods = + 'vector': (form) -> + until (nilp form.v) then p = normalize(car form.v); form = cdr form.v; p + + 'record': (form) -> + o = Object.create(null) + until (nilp form.v) + o[(normalize car form.v)] = (normalize car cdr form.v) + form = cdr cdr form.v + null + o + + 'list': (form) -> + handle = (form) -> + return nil if (nilp form) + return _normalize(form) if not (listp form) + cons (_normalize car form), (handle cdr form) + handle(form.v) + + if (listp form.v) + if (car form.v) instanceof Symbol and (car form.v).name in ['vector', 'record'] + methods[(car form.v).name](cdr form.v) + else + methods.list(form) + else + form.v + + _normalize(form) diff --git a/chapter5/tracking_reader.coffee b/chapter5/tracking_reader.coffee new file mode 100644 index 0000000..bd91823 --- /dev/null +++ b/chapter5/tracking_reader.coffee @@ -0,0 +1,22 @@ +{Reader, ReadError, Source} = require './reader' +{Node} = require './reader_types' + +liftToTrack = (f) -> + (ioStream) -> + ioStream = if ioStream instanceof Source then ioStream else new Source ioStream + [line, column] = ioStream.position() + obj = f.apply(this, arguments) + if obj instanceof ReadError + obj['line'] = line + obj['column'] = column + return obj + if obj instanceof Node then obj else new Node obj, line, column + +TrackingReader = class +for own key, func of Reader:: + TrackingReader::[key] = liftToTrack(func) + +exports.ReadError = ReadError +exports.Reader = TrackingReader +exports.reader = reader = new TrackingReader() +exports.read = -> reader.read.apply(reader, arguments) diff --git a/test/reader5_samples.coffee b/test/reader5_samples.coffee new file mode 100644 index 0000000..33e55be --- /dev/null +++ b/test/reader5_samples.coffee @@ -0,0 +1,22 @@ +{cons, nil} = require "cons-lists/lists" +exports.samples = [ + ['nil', nil] + ['0', 0] + ['1', 1] + ['500', 500] + ['0xdeadbeef', 3735928559] + ['"Foo"', 'Foo'] + ['(1)', cons(1)] + ['(1 2)', cons(1, (cons 2))] + ['(1 2 )', cons(1, (cons 2))] + ['( 1 2 )', cons(1, (cons 2))] + ['( 1 2 )', cons(1, (cons 2))] + ['("a" "b")', cons("a", (cons "b"))] + ['("a" . "b")', cons("a", "b")] + ['[]', []] + ['{}', {}] + ['[1 2 3]', [1, 2, 3]] + # ['(1 2 3', 'error'] + ['{"foo" "bar"}', {foo: "bar"}] +] + diff --git a/test/test_reader5a.coffee b/test/test_reader5a.coffee new file mode 100644 index 0000000..4407409 --- /dev/null +++ b/test/test_reader5a.coffee @@ -0,0 +1,15 @@ +chai = require 'chai' +chai.should() +expect = chai.expect + +{cons, nil, nilp} = require "cons-lists/lists" +{read} = require '../chapter5/reader' +{normalize} = require '../chapter5/reader_rawtoform' +{samples} = require './reader5_samples' + +describe "Lisp reader functions", -> + for [t, v] in samples + do (t, v) -> + it "should interpret #{t} as #{v}", -> + res = normalize read t + expect(res).to.deep.equal(v) diff --git a/test/test_reader5b.coffee b/test/test_reader5b.coffee new file mode 100644 index 0000000..2cc3176 --- /dev/null +++ b/test/test_reader5b.coffee @@ -0,0 +1,15 @@ +chai = require 'chai' +chai.should() +expect = chai.expect + +{cons, nil, nilp} = require "cons-lists/lists" +{read} = require '../chapter5/tracking_reader' +{normalize} = require '../chapter5/reader_tracktoform' +{samples} = require './reader5_samples' + +describe "Tracker reader functions", -> + for [t, v] in samples + do (t, v) -> + it "should interpret #{t} as #{v}", -> + res = normalize read t + expect(res).to.deep.equal(v)