summaryrefslogtreecommitdiff
path: root/Data/BuiltIn/Libraries/lua-addons/addons/libs/json.lua
blob: ff8e3142afce74c844ed9ec9f3e2833f1df8953b (plain)
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
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
--[[
Small implementation of a JSON file reader.
]]

_libs = _libs or {}

require('tables')
require('lists')
require('sets')
require('strings')

local table, list, set, string = _libs.tables, _libs.lists, _libs.sets, _libs.strings
local files = require('files')

local json = {}

_libs.json = json

-- Define singleton JSON characters that can delimit strings.
local singletons = '{}[],:'
local key_types = S{'string', 'number'}
local value_types = S{'boolean', 'number', 'string', 'nil'}

-- Takes a filename and tries to parse the JSON in it, after a validity check.
function json.read(file)
    if type(file) == 'string' then
        file = files.new(file)
    end

    if not file:exists() then
        return json.error('File not found: \''..file.path..'\'')
    end

    return json.parse(file:read())
end

-- Returns nil as the parsed table and an additional error message with an optional line number.
function json.error(message, line)
    if line == nil then
        return nil, 'JSON error: '..message
    end
    return nil, 'JSON error, line '..line..': '..message
end

-- Returns a Lua value based on a string.
-- Recognizes all valid atomic JSON values: booleans, numbers, strings and null.
-- Object and error groupings will be eliminated during the classifying process.
-- If stripquotes is set to true, quote characters delimiting strings will be stripped.
function json.make_val(str, stripquotes)
    stripquotes = true and (stripquotes ~= false)

    str = str:trim()
    if str == '' then
        return nil
    elseif str == 'true' then
        return true
    elseif str == 'false' then
        return false
    elseif str == 'null' then
        return nil
    elseif stripquotes and (str:enclosed('\'') or str:enclosed('"')) then
        return str:slice(2, -2)
    end

    str = str:gsub('\\x([%w%d][%w%d])', string.char..tonumber-{16})

    return tonumber(str) or str
end

-- Parsing function. Gets a string representation of a JSON object and outputs a Lua table or an error message.
function json.parse(content)
    return json.classify(json.tokenize(content))
end

-- Tokenizer. Reads a string and returns an array of lines, each line with a number of valid JSON tokens. Valid tokens include:
-- * \w+    Keys or values
-- * :        Key indexer
-- * ,        Value separator
-- * \{\}    Dictionary start/end
-- * \[\]    List start/end
function json.tokenize(content)
    -- Tokenizer. Reads the string by characters and finds word boundaries, returning an array of tokens to be interpreted.
    local current = nil
    local tokens = L{L{}}
    local quote = nil
    local comment = false
    local line = 1

    content = content:trim()
    local length = #content
    if content:sub(length, length) == ',' then
        content = content:sub(1, length - 1)
        length = length - 1
    end

    local first = content:sub(1, 1)
    local last = content:sub(length, length)
    if first ~= '[' and first ~= '{' then
        return json.error('Invalid JSON format. Document needs to start with \'{\' (object) or \'[\' (array).')
    end

    if not (first == '[' and last == ']' or first == '{' and last == '}') then
        return json.error('Invalid JSON format. Document starts with \''..first..'\' but ends with \''..last..'\'.')
    end

    local root
    if first == '[' then
        root = 'array'
    else
        root = 'object'
    end

    content = content:sub(2, length - 1)

    for c in content:it() do
        -- Only useful for a line count, to produce more accurate debug messages.
        if c == '\n' then
            line = line + 1
            comment = false
            tokens:append(L{})
        end

        -- If the quote character is set, don't parse but syntax, but instead just append to the string until the same quote character is encountered.
        if quote ~= nil then
            current = current..c
            -- If the quote character is found, append the parsed string and reset the parsing values.
            if quote == c then
                tokens[line]:append(json.make_val(current))
                current = nil
                quote = nil
            end
        elseif not comment then
            -- If the character is a singleton character, append the previous token and this one, reset the parsing values.
            if singletons:contains(c) then
                if current ~= nil then
                    tokens[line]:append(json.make_val(current))
                    current = nil
                end
                tokens[line]:append(c)
            -- If a quote character is found, start a quoting session, see alternative condition.
            elseif c == '"' or c == '\'' and current == nil then
                quote = c
                current = c
            -- Otherwise, just append
            elseif not c:match('%s') or current ~= nil then
                -- Ignore comments. Not JSON conformant.
                if c == '/' and current ~= nil and current:last() == '/' then
                    current = current:slice(1, -2)
                    if current == '' then
                        current = nil
                    end
                    comment = true
                else
                    current = current or ''
                    current = current..c
                end
            end
        end
    end

    return tokens, root
end

-- Takes a list of tokens and analyzes it to construct a valid Lua object from it.
function json.classify(tokens, root)
    if tokens == nil then
        return tokens, root
    end

    local scopes = L{root}

    -- Scopes and their domains:
    -- * 'object': Object scope, delimited by '{' and '}' as well as global scope
    -- * 'array': Array scope, delimited by '[' and ']'
    -- Possible modes and triggers:
    -- * 'new': After an opening brace, bracket, comma or at the start, expecting a new element
    -- * 'key': After reading a key
    -- * 'colon': After reading a colon
    -- * 'value': After reading or having scoped a value (either an object, or an array for the latter)
    local modes = L{'new'}

    local parsed
    if root == 'object' then
        parsed = L{T{}}
    else
        parsed = L{L{}}
    end

    local keys = L{}
    -- Classifier. Iterates through the tokens and assigns meaning to them. Determines scoping and creates objects and arrays.
    for array, line in tokens:it() do
        for token, pos in array:it() do
            if token == '{' then
                if modes:last() == 'colon' or modes:last() == 'new' and scopes:last() == 'array' then
                    parsed:append(T{})
                    scopes:append('object')
                    modes:append('new')
                else
                    return json.error('Unexpected token \'{\'.', line)
                end
            elseif token == '}' then
                if modes:last() == 'value' or modes:last() == 'new' then
                    modes:remove()
                    scopes:remove()
                    if modes:last() == 'colon' then
                        parsed:last(2)[keys:remove()] = parsed:remove()
                    elseif modes:last() == 'new' and scopes:last() == 'array' then
                        parsed:last():append(parsed:remove())
                    else
                        return json.error('Unexpected token \'}\'.', line)
                    end
                    modes[#modes] = 'value'
                else
                    return json.error('Unexpected token \'}\'.', line)
                end
            elseif token == '[' then
                if modes:last() == 'colon' or modes:last() == 'new' and scopes:last() == 'array' then
                    parsed:append(T{})
                    scopes:append('array')
                    modes:append('new')
                else
                    return json.error('Unexpected token \'{\'.', line)
                end
            elseif token == ']' then
                if modes:last() == 'value' or modes:last() == 'new' then
                    modes:remove()
                    scopes:remove()
                    if modes:last() == 'colon' then
                        parsed[#parsed-1][keys:remove()] = parsed:remove()
                    elseif modes:last() == 'new' and scopes:last() == 'array' then
                        parsed:last():append(parsed:remove())
                    else
                        return json.error('Unexpected token \'}\'.', line)
                    end
                    modes[#modes] = 'value'
                else
                    return json.error('Unexpected token \'}\'.', line)
                end
            elseif token == ':' then
                if modes:last() == 'key' then
                    modes[#modes] = 'colon'
                else
                    return json.error('Unexpected token \':\'.', line)
                end
            elseif token == ',' then
                if modes:last() == 'value' then
                    modes[#modes] = 'new'
                else
                    return json.error('Unexpected token \',\'.', line)
                end
            elseif key_types:contains(type(token)) and modes:last() == 'new' and scopes:last() == 'object' then
                keys:append(token)
                modes[#modes] = 'key'
            elseif value_types:contains(type(token)) then
                if modes:last() == 'colon' then
                    parsed:last()[keys:remove()] = token
                    modes[#modes] = 'value'
                elseif modes:last() == 'new' then
                    if scopes:last() == 'array' then
                        parsed:last():append(token)
                        modes[#modes] = 'value'
                    else
                        return json.error('Unexpected token \''..token..'\'.', line)
                    end
                else
                    return json.error('Unexpected token \''..token..'\'.', line)
                end
            else
                return json.error('Unkown token parsed. You should never see this. Token type: '..type(token), line)
            end
        end
    end

    if parsed:empty() then
        return json.error('No JSON found.')
    end
    if #parsed > 1 then
        return json.error('Invalid nesting, missing closing tags.')
    end

    return parsed:remove()
end

return json

--[[
Copyright (c) 2013, Windower
All rights reserved.

Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

    * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
    * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
    * Neither the name of Windower nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL Windower BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
]]