summary refs log tree commit diff
diff options
context:
space:
mode:
authorLeah Neukirchen <leah@vuxu.org>2018-07-24 17:18:01 +0200
committerLeah Neukirchen <leah@vuxu.org>2018-07-24 17:18:01 +0200
commit29676c717eac0f2c5e6cc02941b479dd5d81727c (patch)
treeafb0e71dc5b5da2ff3c6ecc036f93113b2f5be7b
downloadsson-29676c717eac0f2c5e6cc02941b479dd5d81727c.tar.gz
sson-29676c717eac0f2c5e6cc02941b479dd5d81727c.tar.xz
sson-29676c717eac0f2c5e6cc02941b479dd5d81727c.zip
initial revision
-rw-r--r--SPEC.md106
-rw-r--r--lib/sson.rb199
2 files changed, 305 insertions, 0 deletions
diff --git a/SPEC.md b/SPEC.md
new file mode 100644
index 0000000..78b6d30
--- /dev/null
+++ b/SPEC.md
@@ -0,0 +1,106 @@
+# SSON - S-Expression Standard Object Notation
+a faithful embedding of JSON (RFC 8259) into a S-Expression syntax.
+
+To the extent possible under law, Leah Neukirchen <leah@vuxu.org>
+has waived all copyright and related or neighboring rights to this work.
+http://creativecommons.org/publicdomain/zero/1.0/
+
+## Syntax
+
+	value = "#n" / "#t" / "#f" /
+	        "(" value* ")" / "#(" (string value)* ")" /
+	        json-number / string
+	string = json-string / literal
+	literal = [^0-9#;()" \t\r\n+-][^#;()" \t\r\n]*
+	";" starts a comment until end of line, it is treated as whitespace
+
+The grammar can be parsed with 1 byte lookahead.
+
+## Example
+
+	[{
+	  "created_at": "Thu Jun 22 21:00:00 +0000 2017",
+	  "id": 877994604561387500,
+	  "id_str": "877994604561387520",
+	  "text": "Creating a Grocery List Manager Using Angular, Part 1: Add &amp; Display Items https://t.co/xFox78juL1 #Angular",
+	  "truncated": false,
+	  "entities": {
+	    "hashtags": [{
+	      "text": "Angular",
+	      "indices": [103, 111]
+	    }],
+	    "symbols": [],
+	    "user_mentions": [],
+	    "urls": [{
+	      "url": "https://t.co/xFox78juL1",
+	      "expanded_url": "http://buff.ly/2sr60pf",
+	      "display_url": "buff.ly/2sr60pf",
+	      "indices": [79, 102]
+	    }]
+	  },
+	  "source": "<a href=\"http://bufferapp.com\" rel=\"nofollow\">Buffer</a>",
+	  "user": {
+	    "id": 772682964,
+	    "id_str": "772682964",
+	    "name": "SitePoint JavaScript",
+	    "screen_name": "SitePointJS",
+	    "location": "Melbourne, Australia",
+	    "description": "Keep up with JavaScript tutorials, tips, tricks and articles at SitePoint.",
+	    "url": "http://t.co/cCH13gqeUK",
+	    "entities": {
+	      "url": {
+	        "urls": [{
+	          "url": "http://t.co/cCH13gqeUK",
+	          "expanded_url": "http://sitepoint.com/javascript",
+	          "display_url": "sitepoint.com/javascript",
+	          "indices": [0, 22]
+	        }]
+	      },
+	      "description": {
+	        "urls": []
+	      }
+	    },
+	    "protected": false,
+	    "followers_count": 2145,
+	    "friends_count": 18,
+	    "listed_count": 328,
+	    "created_at": "Wed Aug 22 02:06:33 +0000 2012",
+	    "favourites_count": 57,
+	    "utc_offset": 43200,
+	    "time_zone": "Wellington"
+	  }
+	}]
+
+	(#(created_at "Thu Jun 22 21:00:00 +0000 2017"
+	   id 877994604561387500
+	   id_str "877994604561387520"
+	   text "Creating a Grocery List Manager Using Angular, Part 1: Add &amp; Display Items https://t.co/xFox78juL1 #Angular"
+	   truncated #f
+	   entities #(hashtags (#(text Angular indices (103 111)))
+	     symbols ()
+	     user_mentions ()
+	     urls (#(url https://t.co/xFox78juL1
+	        expanded_url http://buff.ly/2sr60pf
+	        display_url buff.ly/2sr60pf
+	        indices (79 102))))
+	   source "<a href=\"http://bufferapp.com\" rel=\"nofollow\">Buffer</a>"
+	   user #(id 772682964
+	     id_str "772682964"
+	     name "SitePoint JavaScript"
+	     screen_name SitePointJS
+	     location "Melbourne, Australia"
+	     description "Keep up with JavaScript tutorials, tips, tricks and articles at SitePoint."
+	     url http://t.co/cCH13gqeUK
+	     entities #(url #(urls (#(url http://t.co/cCH13gqeUK
+	            expanded_url http://sitepoint.com/javascript
+	            display_url sitepoint.com/javascript
+	            indices (0 22))))
+	       description #(urls ()))
+	     protected #f
+	     followers_count 2145
+	     friends_count 18
+	     listed_count 328
+	     created_at "Wed Aug 22 02:06:33 +0000 2012"
+	     favourites_count 57
+	     utc_offset 43200
+	     time_zone Wellington)))
diff --git a/lib/sson.rb b/lib/sson.rb
new file mode 100644
index 0000000..684749a
--- /dev/null
+++ b/lib/sson.rb
@@ -0,0 +1,199 @@
+# SSON - S-Expression Standard Object Notation
+# a faithful embedding of JSON (RFC 8259) into a S-Expression syntax
+
+#
+# value = "#n" / "#t" / "#f" /
+#         "(" value* ")" / "#(" (string value)* ")" /
+#         json-number / string
+# string = json-string / literal
+# literal = [^0-9#;()" \t\r\n+-][^#;()" \t\r\n]*
+# ";" starts a comment until end of line, it is treated as whitespace
+#
+
+# To the extent possible under law, Leah Neukirchen <leah@vuxu.org>
+# has waived all copyright and related or neighboring rights to this work.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+require 'json'
+require 'prettyprint'
+require 'strscan'
+
+module SSON; end
+class << SSON
+  VERSION = "0.1"
+               
+  SCANIDENT = /[^0-9#;()" \t\r\n+-][^#;()" \t\r\n]*/
+  IDENT = /\A#{SCANIDENT}\z/
+
+  class SSONError < StandardError; end
+  class GeneratorError < SSONError; end
+  class ParserError < SSONError; end
+
+  def generate(o)
+    case o
+    when Hash
+      r = "#("
+      o.each { |k, v| 
+        r << " "  if r.size > 2
+        r << generate(k.to_s) << " " << generate(v) 
+      }
+      r << ")"
+      r
+    when Array
+      r = "("
+      o.each { |e|
+        r << " "  if r.size > 1
+        r << generate(e)
+      }
+      r << ")"
+      r
+    when true
+      "#t"
+    when false
+      "#f"
+    when nil
+      "#n"
+    when Integer
+      o.to_s
+    when Float
+      if o.nan?
+        raise GeneratorError, "NaN not allowed in SSON"
+      elsif o.infinite?
+        raise GeneratorError, "Infinity not allowed in SSON"
+      end
+      o.to_s
+    when String
+      if o =~ IDENT
+        o
+      else
+        JSON.generate(o)
+      end
+    else
+      # like JSON.generate
+      generate(o.to_s)
+    end
+  end
+
+  def pretty_generate(obj)
+    return PrettyPrint.format('', 80) { |q|
+      inner = lambda { |o|
+        case o
+        when Array
+          q.group(1) {
+            q.text "("
+            b = false
+            o.each { |e|
+              q.breakable " "  if b
+              b = true
+              inner[e]
+            }
+            q.text ")"
+          }
+        when Hash
+          q.group(2) {
+            q.text "#("
+            b = false
+            o.each { |k, v|
+              q.breakable " "  if b
+              b = true
+              q.group {
+                inner[k]
+                q.text " "
+                inner[v]
+              }
+            }
+            q.text ")"
+          }
+
+        when true; q.text "#t"
+        when false; q.text "#f"
+        when nil; q.text "#n"
+        when Integer
+          q.text o.to_s
+        when Float
+          if o.nan?
+            raise GeneratorError, "NaN not allowed in SSON"
+          elsif o.infinite?
+            raise GeneratorError, "Infinity not allowed in SSON"
+          end
+          q.text o.to_s
+        when String
+          if o =~ IDENT
+            q.text o  # ES5 identifier
+          else
+            q.text JSON.generate(o)
+          end
+        end
+      }
+      inner[obj]
+    }
+  end
+
+  def parse(str)
+    e = SSON.enum_for(:tok, str)
+    r = parse_form e
+    begin
+      e.next
+    rescue StopIteration
+      r
+    else
+      raise ParserError, "trailing garbage"
+    end
+  end
+
+  private
+
+  def tok(str)
+    ss = StringScanner.new(str)
+    until ss.eos?
+      if ss.scan(/\A[ \t\r\n]+/) || ss.scan(/;[^\n]*$/)
+        # ignore
+      elsif ss.scan(/\(/);      yield :OPEN
+      elsif ss.scan(/\)/);      yield :CLOSE
+      elsif ss.scan(/#\(/);     yield :HASH
+      elsif ss.scan(/#n/);      yield :NULL
+      elsif ss.scan(/#t/);      yield :TRUE
+      elsif ss.scan(/#f/);      yield :FALSE
+      elsif s = ss.scan(/"(\\"|[^"])*"/)
+        yield JSON.parse(s)
+      elsif s = ss.scan(SCANIDENT)
+        yield s
+      elsif s = ss.scan(/[-+]?(?:\d*\.?\d+|\d+\.?\d*)(?:[eE][-+]?\d+)?/)
+        yield Float(s)
+      else
+        raise ParserError, "invalid SSON: " + ss.peek(20).dump
+      end
+    end
+  end
+
+  def parse_form(e)
+    case t = e.next
+    when :OPEN
+      r = []
+      while e.peek != :CLOSE
+        r << parse_form(e)
+      end
+      e.next
+      r
+    when :HASH
+      r = {}
+      while e.peek != :CLOSE
+        k = e.next
+        raise "non string key"  unless k.kind_of?(String)
+        v = parse_form(e)
+        r[k] = v
+      end
+      e.next
+      r
+    when :CLOSE;   raise ParserError, "invalid SSON, toplevel )"
+    when :NULL;    nil
+    when :TRUE;    true
+    when :FALSE;   false
+    when String;   t
+    when Float;    t
+    end
+  rescue StopIteration
+    raise ParserError, "early EOF"
+  end
+
+end