diff --git a/grammar.ne b/grammar.ne index 0ef90c7..8627d99 100644 --- a/grammar.ne +++ b/grammar.ne @@ -6,6 +6,7 @@ JVALUE -> JOBJECT {% (d) => d[0] %} | "'" _ JOBJECT _ "'" {% (d) => d[2] %} | JARRAY {% (d) => d[0] %} | STRING {% (d) => d[0] %} + | SINGLE_QUOTED_STRING {% (d) => d[0] %} | "null" {% (d) => null %} JOBJECT -> "{" _ "}" {% (d) => { return { type: 'compound', value: {} } } %} @@ -21,6 +22,8 @@ PAIR -> STRING _ ":" _ JVALUE {% (d) => [d[0].value, d[4]] %} STRING -> "\"" ( [^\\"] | "\\" ["bfnrt\/\\] | "\\u" [a-fA-F0-9] [a-fA-F0-9] [a-fA-F0-9] [a-fA-F0-9] ):* "\"" {% (d) => parseValue( JSON.parse(d.flat(3).map(b => b.replace('\n', '\\n')).join('')) ) %} | [^\"\'}\]:;,\s]:+ {% (d) => parseValue(d[0].join('')) %} +SINGLE_QUOTED_STRING -> "'" ( [^\\'] | "\\" ["bfnrt\/\\'] | "\\u" [a-fA-F0-9] [a-fA-F0-9] [a-fA-F0-9] [a-fA-F0-9] ):* "'" {% (d) => parseSingleQuoteString(d) %} + @{% // Because of unquoted strings, parsing can be ambiguous. @@ -48,6 +51,38 @@ function parseValue (str) { return { value: str, type: 'string' } } +function parseSingleQuoteString(d) { + // Build the string content from the parsed parts similar to double-quoted strings + // The structure is: ["'", [content parts], "'"] + // d[1] contains the content between quotes + const content = d[1] || [] + let str = "'" + for (const part of content) { + if (Array.isArray(part)) { + str += part.flat().join('') + } else if (part) { + str += part + } + } + str += "'" + + // Process escape sequences to convert to actual string value + // Replace escaped single quotes with actual single quotes + // and handle other escape sequences + str = str.replace(/\\'/g, "\\'") // Keep escaped single quotes for JSON parsing + .replace(/\\"/g, '\\"') // Keep escaped double quotes + + // Convert to a JSON-compatible string by replacing outer single quotes with double quotes + str = '"' + str.slice(1, -1).replace(/"/g, '\\"').replace(/\\'/g, "'") + '"' + + try { + return { value: JSON.parse(str), type: 'string' } + } catch (e) { + // If JSON parsing fails, return the raw string content + return { value: str.slice(1, -1), type: 'string' } + } +} + function extractPair(kv, output) { if (kv[0] !== undefined) { output[kv[0]] = kv[1] diff --git a/index.js b/index.js index 653d6d0..cd184ea 100644 --- a/index.js +++ b/index.js @@ -92,13 +92,68 @@ function parse (text) { try { const parserNE = new nearley.Parser(nearley.Grammar.fromCompiled(grammar)) parserNE.feed(text) - return parserNE.results[0] + // When there are multiple parse results (ambiguous grammar), + // prefer results with more structured types (compound, list) over strings + const results = parserNE.results + if (results.length > 1) { + // Score each result based on how "structured" it is + const scored = results.map((r, i) => { + const score = scoreResult(r) + return { result: r, score } + }) + // Sort by score descending (higher score = more structured) + scored.sort((a, b) => b.score - a.score) + return scored[0].result + } + return results[0] } catch (e) { e.message = `Error parsing text '${text}'` throw e } } +function scoreResult (obj) { + let score = 0 + if (!obj || typeof obj !== 'object') return score + + // Prefer compound and list types over string types + if (obj.type === 'compound') score += 10 + if (obj.type === 'list') score += 10 + if (obj.type === 'string') score -= 1 + + // Recursively score nested structures + if (obj.value && typeof obj.value === 'object') { + if (Array.isArray(obj.value)) { + obj.value.forEach(item => { + score += scoreResult(item) + }) + } else if (obj.value.value && Array.isArray(obj.value.value)) { + // This is a list type with value.value array + // The items in this array are raw values, not wrapped in { type, value } + // Score based on whether items are objects (compounds) vs primitives + obj.value.value.forEach(item => { + if (item && typeof item === 'object' && !Array.isArray(item)) { + // This is likely a compound object + score += 10 + // Recursively score the object's properties + Object.values(item).forEach(prop => { + score += scoreResult(prop) + }) + } else if (typeof item === 'string') { + score -= 1 + } + }) + } else { + // This is a compound with nested values + Object.values(obj.value).forEach(item => { + score += scoreResult(item) + }) + } + } + + return score +} + module.exports = { parse, simplify, diff --git a/test/test.js b/test/test.js index 87e13bb..83fd158 100644 --- a/test/test.js +++ b/test/test.js @@ -34,7 +34,15 @@ describe('mojangson', function () { ['{text:"This string contains escaped characters: \\" \\b \\f \\n \\r \\t \\/ \\\\"}', { type: 'compound', value: { text: { type: 'string', value: 'This string contains escaped characters: " \b \f \n \r \t / \\' } } }], ['{id:"minecraft:nether_star",Count:1b,display:{Name:"§7the Player Rank \\"§f§lI§f§7\\"!"},Damage:0s}', { type: 'compound', value: { id: { value: 'minecraft:nether_star', type: 'string' }, Count: { value: 1, type: 'byte' }, display: { type: 'compound', value: { Name: { value: '§7the Player Rank "§f§lI§f§7"!', type: 'string' } } }, Damage: { value: 0, type: 'short' } } }], ['{display:{Name:"New Mob"},SkullOwner:{Id:"8987f87a-6c6b-4e87-8322-ce70957b6272",Properties:{textures:[{Value:"eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvN2VmNWY5ODY0YjM2MmI5ZWVjY2YxYWI5ZjE3YTI2NDc1OWJhMjgwZmI2NTJiZDgzZWNjMDAwNWFkMjk2ZmYzYyJ9fX0="}]}}}', { type: 'compound', value: { display: { type: 'compound', value: { Name: { value: 'New Mob', type: 'string' } } }, SkullOwner: { type: 'compound', value: { Id: { value: '8987f87a-6c6b-4e87-8322-ce70957b6272', type: 'string' }, Properties: { type: 'compound', value: { textures: { type: 'list', value: { type: 'compound', value: [{ Value: { value: 'eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvN2VmNWY5ODY0YjM2MmI5ZWVjY2YxYWI5ZjE3YTI2NDc1OWJhMjgwZmI2NTJiZDgzZWNjMDAwNWFkMjk2ZmYzYyJ9fX0=', type: 'string' } }] } } } } } } } }], - ['"\n"', { type: 'string', value: '\n' }] + ['"\n"', { type: 'string', value: '\n' }], + // Single-quoted string tests + ["{key:'value'}", { type: 'compound', value: { key: { value: 'value', type: 'string' } } }], + ['{"minecraft:custom_name":\'":!!: book :!!:"\'}', { type: 'compound', value: { 'minecraft:custom_name': { value: '":!!: book :!!:"', type: 'string' } } }], + ['{k1:\'v1\',k2:\'v2\'}', { type: 'compound', value: { k1: { value: 'v1', type: 'string' }, k2: { value: 'v2', type: 'string' } } }], + ['{key:\'va,lue\'}', { type: 'compound', value: { key: { value: 'va,lue', type: 'string' } } }], + ["'hello world'", { value: 'hello world', type: 'string' }], + ["'{}'", { type: 'compound', value: {} }], + ["'{key:value}'", { type: 'compound', value: { key: { value: 'value', type: 'string' } } }] ] data.forEach(function (a) { it('should be equal', function () {