|
export const parse = (source) => { |
|
const main = (source) => { |
|
const state = { |
|
stack: [{ type: null, value: null }], |
|
get current() { |
|
return state.stack[state.stack.length - 1] |
|
}, |
|
req_indent: 0, |
|
unnestable: false, |
|
push(key) { |
|
const next_state = { type: null, value: null } |
|
if (Array.isArray(state.current.value)) { |
|
state.current.value.push(next_state) |
|
} else { |
|
state.current.value[key] = next_state |
|
} |
|
state.stack.push(next_state) |
|
state.req_indent++ |
|
}, |
|
pop() { |
|
state.req_indent-- |
|
state.stack.pop() |
|
}, |
|
string_block: { |
|
key: null, |
|
lines: null, |
|
use_nest: false, |
|
enter(key, use_nest, obj_key) { |
|
state.string_block.key = key |
|
state.string_block.lines = [] |
|
state.string_block.use_nest = use_nest |
|
if (use_nest) { |
|
state.push(obj_key) |
|
} |
|
}, |
|
append(line) { |
|
if (line === "") { |
|
state.string_block.lines.push("") |
|
return |
|
} |
|
const req_indent = "\t".repeat(state.req_indent) |
|
if (!line.startsWith(req_indent)) { |
|
throw new Error(`インデントレベルが不正です`) |
|
} |
|
const body = line.slice(state.req_indent) |
|
if (body === state.string_block.key) { |
|
state.current.type = "primitive" |
|
const str = state.string_block.lines.join("\n") |
|
state.current.value = str |
|
state.string_block.key = null |
|
state.string_block.lines = null |
|
if (state.string_block.use_nest) { |
|
state.pop() |
|
} |
|
} else { |
|
state.string_block.lines.push(body) |
|
} |
|
}, |
|
}, |
|
} |
|
|
|
for (const [index, line] of source.split(/\r?\n/).entries()) { |
|
if (state.string_block.key) { |
|
try { |
|
state.string_block.append(line) |
|
} catch (err) { |
|
throw new Error(err.message + ` (${index + 1}行目)`) |
|
} |
|
continue |
|
} |
|
|
|
if (line.trim() === "") { |
|
state.unnestable = true |
|
continue |
|
} |
|
|
|
const [, indent_str, body] = line.match(/^(\t*)(.*)$/) |
|
const indent = indent_str.length |
|
|
|
if (indent > state.req_indent) { |
|
throw new Error(`${index + 1}行目のインデントレベルが不正です`) |
|
} |
|
|
|
if (indent < state.req_indent) { |
|
if (!state.unnestable) { |
|
throw new Error(`${index + 1}行目のインデントが不正です\nネストの解除時は空行が必要です`) |
|
} |
|
if (state.current.type === null) { |
|
throw new Error(`直前のネストに対する値が空です (${index + 1}行目)`) |
|
} |
|
while (indent < state.req_indent) { |
|
state.pop() |
|
} |
|
} |
|
|
|
state.unnestable = false |
|
|
|
const parsed = parseBody(body) |
|
|
|
const checkInvalid = (parsed) => { |
|
if (!parsed) return |
|
if (parsed.invalid) { |
|
const message = { |
|
"empty-value": "値が空です", |
|
"extra-space": "余分な空白文字があります", |
|
"array-nest-inline": "-: の後に文字が存在します", |
|
}[parsed.type] |
|
throw new Error(`${index + 1}行目が不正です:${message}`) |
|
} |
|
checkInvalid(parsed.value) |
|
} |
|
|
|
checkInvalid(parsed) |
|
|
|
if (parsed.type === "comment") { |
|
continue |
|
} |
|
|
|
if (state.current.type === "primitive") { |
|
throw new Error(`配列・オブジェクト外では複数の値を記述できません (${index + 1}行目)`) |
|
} |
|
|
|
if (state.current.type === "array" && parsed.type !== "array-item" && parsed.type !== "array-nest") { |
|
throw new Error(`配列内に不正な値があります (${index + 1}行目)`) |
|
} |
|
|
|
if (state.current.type === "object" && parsed.type !== "object-entry" && parsed.type !== "object-nest") { |
|
throw new Error(`オブジェクト内に不正な値があります (${index + 1}行目)`) |
|
} |
|
|
|
if (["null", "boolean", "number"].includes(parsed.type)) { |
|
state.current.type = "primitive" |
|
state.current.value = parsed.value |
|
continue |
|
} |
|
|
|
if (parsed.type === "string") { |
|
try { |
|
state.current.type = "primitive" |
|
state.current.value = unescapeString(parsed.value) |
|
} catch (err) { |
|
throw new Error(`文字列エスケープが不正です (${index + 1}行目)`) |
|
} |
|
continue |
|
} |
|
|
|
if (parsed.type === "string-block-start") { |
|
state.string_block.enter(parsed.value, false) |
|
continue |
|
} |
|
|
|
if (parsed.type === "array-item" || parsed.type === "array-nest") { |
|
if (state.current.type === null) { |
|
state.current.type = "array" |
|
state.current.value = [] |
|
} |
|
|
|
if (parsed.type === "array-nest") { |
|
state.push() |
|
continue |
|
} |
|
|
|
const sub = parsed.value |
|
checkInvalid(sub) |
|
|
|
if (["null", "boolean", "number", "string"].includes(sub.type)) { |
|
const sub_state = { type: null, value: null } |
|
sub_state.type = "primitive" |
|
if (sub.type === "string") { |
|
try { |
|
sub_state.value = unescapeString(sub.value) |
|
} catch (err) { |
|
throw new Error(`文字列エスケープが不正です (${index + 1}行目)`) |
|
} |
|
} else { |
|
sub_state.value = sub.value |
|
} |
|
state.current.value.push(sub_state) |
|
continue |
|
} |
|
if (sub.type === "string-block-start") { |
|
state.string_block.enter(sub.value, true) |
|
continue |
|
} |
|
|
|
throw new Error("実装エラー") |
|
} |
|
|
|
if (parsed.type === "object-entry" || parsed.type === "object-nest") { |
|
if (state.current.type === null) { |
|
state.current.type = "object" |
|
state.current.value = {} |
|
} |
|
|
|
const key = parsed.value.key |
|
|
|
if (parsed.type === "object-nest") { |
|
state.push(key) |
|
continue |
|
} |
|
|
|
const sub = parsed.value.value |
|
checkInvalid(sub) |
|
|
|
if (["null", "boolean", "number", "string"].includes(sub.type)) { |
|
const sub_state = { type: null, value: null } |
|
sub_state.type = "primitive" |
|
if (sub.type === "string") { |
|
try { |
|
sub_state.value = unescapeString(sub.value) |
|
} catch (err) { |
|
throw new Error(`文字列エスケープが不正です (${index + 1}行目)`) |
|
} |
|
} else { |
|
sub_state.value = sub.value |
|
} |
|
state.current.value[key] = sub_state |
|
continue |
|
} |
|
if (sub.type === "string-block-start") { |
|
state.string_block.enter(sub.value, true, key) |
|
continue |
|
} |
|
|
|
throw new Error("実装エラー") |
|
} |
|
} |
|
|
|
if (state.string_block.key) { |
|
throw new Error(`文字列ブロックの終端が見つかりませんでした`) |
|
} |
|
|
|
if (state.current.type === null) { |
|
throw new Error(`最後に空の値が存在します`) |
|
} |
|
|
|
return format(state.stack[0]) |
|
} |
|
|
|
const parseValue = (body) => { |
|
if (body === "") { |
|
return { invalid: true, type: "empty-value" } |
|
} |
|
if (body === "true") { |
|
return { value: true, type: "boolean" } |
|
} |
|
if (body === "false") { |
|
return { value: false, type: "boolean" } |
|
} |
|
if (body === "null") { |
|
return { value: null, type: "null" } |
|
} |
|
|
|
const is_num = body.match(/^-?\d+(\.\d+)?$/) |
|
if (is_num) { |
|
return { value: +body, type: "number" } |
|
} |
|
|
|
if (body.startsWith(`"""`)) { |
|
return { value: body.trim(), type: "string-block-start" } |
|
} |
|
|
|
if (body[0] === `"` && body.slice(-1) === `"`) { |
|
return { value: body.slice(1, -1), type: "string" } |
|
} |
|
|
|
return { value: body, type: "string" } |
|
} |
|
|
|
const unescapeString = (str) => { |
|
return JSON.parse(`"${str}"`) |
|
} |
|
|
|
const parseQuote = (body) => { |
|
if (!body.startsWith(`"`)) { |
|
throw new Error("実装エラー") |
|
} |
|
|
|
let esc = false |
|
let key = "" |
|
for (const c of body.slice(1)) { |
|
if (c === `"`) { |
|
if (esc) { |
|
key += `"` |
|
esc = false |
|
} else { |
|
break |
|
} |
|
} |
|
if (c === "\\") { |
|
if (esc) { |
|
key += "\\" |
|
esc = false |
|
} else { |
|
esc = true |
|
} |
|
} else { |
|
key += c |
|
esc = false |
|
} |
|
} |
|
const after = body.slice(key.length + 2) |
|
return [key, after] |
|
} |
|
|
|
const parseBody = (body) => { |
|
if (body.trimStart() !== body) { |
|
return { invalid: true, type: "extra-space" } |
|
} |
|
|
|
if (body.startsWith("//")) { |
|
return { type: "comment" } |
|
} |
|
|
|
if (body.startsWith("- ")) { |
|
const sub_body = body.slice(2).trim() |
|
return { value: parseValue(sub_body), type: "array-item" } |
|
} |
|
|
|
if (body.startsWith("-:")) { |
|
if (body.trim() !== "-:") { |
|
return { invalid: true, type: "array-nest-inline" } |
|
} |
|
return { type: "array-nest" } |
|
} |
|
|
|
if (body.includes(":")) { |
|
if (body.startsWith(`"`)) { |
|
// 「"foo":bar」形式を想定 |
|
const [key, after] = parseQuote(body).map((x) => x.trim()) |
|
if (after.startsWith(":")) { |
|
const sub_body = after.slice(1).trim() |
|
if (sub_body === "") { |
|
return { value: { key }, type: "object-nest" } |
|
} else { |
|
const value = parseValue(sub_body) |
|
return { value: { key, value }, type: "object-entry" } |
|
} |
|
} |
|
// key のあとに : が来ないのは key-value ではないので通常値扱い |
|
} else { |
|
// 「foo:bar」形式 |
|
const pos = body.indexOf(":") |
|
const key = body.slice(0, pos).trim() |
|
const sub_body = body.slice(pos + 1).trim() |
|
if (sub_body === "") { |
|
return { value: { key }, type: "object-nest" } |
|
} else { |
|
const value = parseValue(sub_body) |
|
return { value: { key, value }, type: "object-entry" } |
|
} |
|
} |
|
} |
|
|
|
return parseValue(body) |
|
} |
|
|
|
const format = (value) => { |
|
if (value.type === "primitive") return value.value |
|
if (value.type === "array") { |
|
return value.value.map(format) |
|
} |
|
if (value.type === "object") { |
|
return Object.fromEntries(Object.entries(value.value).map(([k, v]) => [k, format(v)])) |
|
} |
|
throw new Error("実装エラー") |
|
} |
|
|
|
if (typeof source !== "string" || source.trim() === "") return |
|
return main(source) |
|
} |