forked from krisnye/ion
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathion.coffee
259 lines (245 loc) · 7.45 KB
/
ion.coffee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
class Token
constructor: (@symbol, @type, @text = @type, @value) ->
toJSON: -> if @symbol then @type else @value
toString: -> @text
tokentypes = [
[/^\s*#.*/, (x) -> null]
[/^\s*\[/, (x) -> new Token true, '[', x]
[/^\s*\]/, (x) -> new Token true, ']', x]
[/^\s*:/, (x) -> new Token true, ':', x]
[/^\s*,/, (x) -> new Token true, ',', x]
[/^\s*"([^"\\]|(\\([\/'"\\bfnrt]|(u[a-fA-F0-9]{4}))))*"/, (x) -> new Token false, 'quoted', x, JSON.parse x]
[/^[^,:\[\]#]+/, (x) -> new Token false, 'unquoted', x, x.trim()]
]
parseTokens = (line) ->
if line.trim().length is 0
return null
tokens = []
while line.trim().length > 0
matched = false
for tokentype in tokentypes
match = line.match tokentype[0]
if match?
matched = true
# parse and add the token to our list
token = tokentype[1] text = match[0]
tokens.push token if token?
# consume the matched token
line = line.substring text.length
break
if not matched
# this shouldn't ever happen
throw new Error line
return tokens
min = (a, b) ->
return a unless b?
return b unless a?
return a if a <= b
return b
class Node
constructor: (@line, @lineNumber, @indent) ->
if line?
@tokens = parseTokens line
@isText = isText @tokens
if @tokens?.length >= 2 and not (key = @tokens[0]).symbol and @tokens[1].type is ':'
@key = key.value
@hasColon = @key? or @tokens?[0]?.type is ':'
error: (message, lineNumber) ->
error = new Error "#{message}, line:#{@lineNumber}"
error.lineNumber = @lineNumber
error.line = @line
error
getSmallestDescendantIndent: ->
smallest = null
if @children?
for child in @children
smallest = min smallest, child.indent
smallest = min smallest, child.getSmallestDescendantIndent()
smallest
getAllDescendantLines: (lines = [], indent) ->
indent ?= @getSmallestDescendantIndent()
if @children?
for child in @children
lines.push child.line.substring indent
child.getAllDescendantLines lines, indent
return lines
getComplexType: (options) ->
# see if we have an explicit type
explicitType = if @tokens?.length >= 3 then @tokens?.slice(2).join('').trim()
if explicitType?
options.explicit = true
return explicitType
nonEmptyChildCount = 0
keyCount = 0
keys = {}
duplicateKeys = false
for child in @children
if (child.isText and not child.key) or (child.children? and not child.hasColon)
return '""'
if child.tokens
nonEmptyChildCount++
if child.key
keyCount++
if keys[child.key]
duplicateKeys = true
keys[child.key] = true
if duplicateKeys or nonEmptyChildCount > 0 and keyCount is 0
return '[]'
if keyCount is nonEmptyChildCount
return '{}'
throw @error 'Inconsistent child keyCount'
getSimpleValue: (options) ->
tokens = @tokens
return undefined if tokens.length is 0
if @key
tokens = tokens.slice 2
else if @hasColon
tokens = tokens.slice 1
# empty is implied null
if tokens.length is 0
return null
# expicit array
return value if tokens.length >= 2 and tokens[0].type is '[' and tokens[tokens.length - 1].type is ']' and value = getArray tokens.slice 1, -1
if not @isText
# single value
if tokens.length is 1
token = tokens[0]
if token.type is 'quoted'
options.explicit = true
return token.value
# implicit array
return value if value = getArray tokens
# string
return tokens.join('').trim()
doChildrenHaveKeys: ->
for child in @children when child.key?
return true
return false
getComplexValue: (options) ->
type = @getComplexType options
if type is '""'
value = @getAllDescendantLines().join '\n'
else if type is '[]'
# if the children have keys, then this is a different animal
if @doChildrenHaveKeys()
value = []
current = null
# read in the objects skipping to the next one whenever we have a new key
for child in @children when child.tokens
key = child.key
if current == null or current.hasOwnProperty key
value.push current = {}
current[key] = child.getValue()
else
value = (child.getValue() for child in @children when child.tokens)
else
value = {}
for child in @children when child.tokens
value[child.key] = child.getValue()
return value
getValue: ->
options = {}
if @children?
if @isText
throw @children[0].error 'Children not expected'
value = @getComplexValue options
else
value = @getSimpleValue options
if typeof value is 'string' and not options.explicit
value = processUnquoted value
return value
processUnquoted = (text) ->
for processor in ion.processors
result = processor text
if result isnt undefined
return result
return text
isText = (tokens) ->
if tokens
punctuation = /[^\s\w]/
for token in tokens
if token.type is 'unquoted'
value = token.value
if typeof value is 'string' and punctuation.test value
return true
return false
# returns an array of items if they are all comma separated, otherwise null
getArray = (tokens) ->
for token, index in tokens
if index % 2 is 0
if token.symbol
return null
else
if token.type isnt ','
return null
return (item.value for item in tokens by 2)
nest = (nodes) ->
root = new Node(null, null, -1)
stack = [root]
for node in nodes
while node.indent <= (parent = stack[stack.length-1]).indent
stack.pop()
(parent.children ?= []).push node
stack.push node
root
ion =
parse: (text, options) ->
# trim the text
text = text.trim()
# split text into lines
nodes = []
for line, index in text.split '\r\n' when line.trim()[0] isnt '#'
indent = (if line.trim().length is 0 then indent else indent = line.match(/^\s*/)?[0]?.length) ? 0
nodes.push new Node line, index + 1, indent
# nest the lines as children of a root node
root = nest nodes
# now get the root value
value = root.getValue()
return value
# extensible ion processors for converting unquoted text to other values
processors: [
(text) -> if text.match /^\s*null\s*$/ then return null
(text) -> if text.match /^\s*(true|false)\s*$/ then return Boolean text.trim()
(text) -> if text.match /^\s*[0-9]+(\.[0-9]+)?([eE][-+]?[0-9]+)?\s*$/ then return Number text.trim()
(text) -> if text.match /^\s*\d\d\d\d-\d\d-\d\d(T\d\d:\d\d(:\d\d(\.\d{1,3})?)?(Z|([+-]\d\d:\d\d))?)?\s*$/ then return new Date text.trim()
(text) -> if text.match /^\s*{}\s*$/ then return {}
(text) ->
# this attempts to match a table format and convert it to an array of objects
# header: values: separated: space:
lines = text.split '\n'
if lines.length > 3
if lines[0].match /^([^: ]+( [^: ]+)*:( +|$)){2,}$/
headers = []
regex = /[^: ]+( [^: ]+)*/g
while match = regex.exec lines[0]
headers.push [new Node(match[0]).getValue(), match.index]
if headers.length >= 2
array = []
for i in [1...lines.length]
line = lines[i]
array.push item = {}
for header, index in headers
key = header[0]
start = header[1]
end = headers[index+1]?[1]
cell = line.substring start, end
if cell.trim().length
value = new Node(cell).getValue()
item[key] = value
return array
return
]
if typeof module is 'undefined'
# global.ion
do -> this.ion = ion
else
# nodejs module
module.exports = ion
if require.main is module
fs = require 'fs'
args = process.argv.slice 2
if args.length is 0
return console.log 'Usage: ion file.ion'
content = fs.readFileSync args[0], 'utf8'
object = ion.parse content
console.log JSON.stringify object, null, ' '