Skip to content

Commit

Permalink
fix: improve solution for handling circular json (#1222)
Browse files Browse the repository at this point in the history
  • Loading branch information
mdonnalley authored Oct 15, 2024
1 parent daa1f33 commit 9073052
Show file tree
Hide file tree
Showing 2 changed files with 112 additions and 42 deletions.
67 changes: 33 additions & 34 deletions src/ux/colorize-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,53 +17,52 @@ type Options = {
theme?: Record<string, string> | undefined
}

export function removeCycles(object: unknown) {
// Keep track of seen objects.
const seenObjects = new WeakMap<Record<string, unknown>, undefined>()
type Replacer = (this: any, key: string, value: any) => any

const _removeCycles = (obj: unknown) => {
// Use object prototype to get around type and null checks
if (Object.prototype.toString.call(obj) === '[object Object]') {
// We know it is a "Record<string, unknown>" because of the conditional
const dictionary = obj as Record<string, unknown>

// Seen, return undefined to remove.
if (seenObjects.has(dictionary)) return
function stringify(value: any, replacer?: Replacer, spaces?: string | number) {
return JSON.stringify(value, serializer(replacer, replacer), spaces)
}

seenObjects.set(dictionary, undefined)
// Inspired by https://github.com/moll/json-stringify-safe
function serializer(replacer: Replacer | undefined, cycleReplacer: Replacer | undefined) {
const stack: any[] = []
const keys: any[] = []

for (const key in dictionary) {
// Delete the duplicate object if cycle found.
if (_removeCycles(dictionary[key]) === undefined) {
delete dictionary[key]
}
}
} else if (Array.isArray(obj)) {
for (const i in obj) {
if (_removeCycles(obj[i]) === undefined) {
// We don't want to delete the array, but we can replace the element with null.
obj[i] = null
}
}
if (!cycleReplacer)
cycleReplacer = function (key, value) {
if (stack[0] === value) return '[Circular ~]'
return '[Circular ~.' + keys.slice(0, stack.indexOf(value)).join('.') + ']'
}

return obj
}
return function (key: any, value: any) {
if (stack.length > 0) {
// @ts-expect-error because `this` is not typed
const thisPos = stack.indexOf(this)
// @ts-expect-error because `this` is not typed
// eslint-disable-next-line no-bitwise
~thisPos ? stack.splice(thisPos + 1) : stack.push(this)
// eslint-disable-next-line no-bitwise
~thisPos ? keys.splice(thisPos, Number.POSITIVE_INFINITY, key) : keys.push(key)
// @ts-expect-error because `this` is not typed
if (stack.includes(value)) value = cycleReplacer.call(this, key, value)
} else stack.push(value)

return _removeCycles(object)
// @ts-expect-error because `this` is not typed
return replacer ? replacer.call(this, key, value) : value
}
}

function formatInput(json?: unknown, options?: Options) {
return options?.pretty
? JSON.stringify(typeof json === 'string' ? JSON.parse(json) : json, null, 2)
export function stringifyInput(json?: unknown, options?: Options): string {
const str = options?.pretty
? stringify(typeof json === 'string' ? JSON.parse(json) : json, undefined, 2)
: typeof json === 'string'
? json
: JSON.stringify(json)
: stringify(json)
return str
}

export function tokenize(json?: unknown, options?: Options) {
let input = formatInput(removeCycles(json), options)

let input = stringifyInput(json, options)
const tokens = []
let foundToken = false

Expand Down
87 changes: 79 additions & 8 deletions test/ux/colorize-json.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {expect} from 'chai'

import {removeCycles, tokenize} from '../../src/ux/colorize-json'
import {stringifyInput, tokenize} from '../../src/ux/colorize-json'

describe('colorizeJson', () => {
it('tokenizes a basic JSON object', () => {
Expand Down Expand Up @@ -149,13 +149,65 @@ describe('colorizeJson', () => {
{type: 'colon', value: ':'},
{type: 'string', value: '"quux"'},
{type: 'brace', value: '}'},
{type: 'comma', value: ','},
{type: 'key', value: '"circular"'},
{type: 'colon', value: ':'},
{type: 'string', value: '"[Circular ~]"'},
{type: 'brace', value: '}'},
])
})
})

describe('removeCycles', () => {
it('removes circular references from objects', () => {
describe('formatInput', () => {
it('converts a json object to a string', () => {
const obj = {
foo: 'bar',
baz: {
qux: 'quux',
},
}

const result = stringifyInput(obj)
expect(result).to.equal('{"foo":"bar","baz":{"qux":"quux"}}')
})

it('converts a json string to a string', () => {
const objString = '{"foo":"bar","baz":{"qux":"quux"}}'

const result = stringifyInput(objString)
expect(result).to.deep.equal(objString)
})

it('adds indentation to json object when pretty is true', () => {
const obj = {
foo: 'bar',
baz: {
qux: 'quux',
},
}

const result = stringifyInput(obj, {pretty: true})
expect(result).to.equal(`{
"foo": "bar",
"baz": {
"qux": "quux"
}
}`)
})

it('adds indentation to json string when pretty is true', () => {
const objString = '{"foo":"bar","baz":{"qux":"quux"}}'

const result = stringifyInput(objString, {pretty: true})
expect(result).to.equal(`{
"foo": "bar",
"baz": {
"qux": "quux"
}
}`)
})

it('removes circular references from json objects', () => {
const obj = {
foo: 'bar',
baz: {
Expand All @@ -165,16 +217,18 @@ describe('removeCycles', () => {
// @ts-expect-error
obj.circular = obj

const result = removeCycles(obj)
expect(result).to.deep.equal({
const result = stringifyInput(obj)

expect(JSON.parse(result)).to.deep.equal({
foo: 'bar',
baz: {
qux: 'quux',
},
circular: '[Circular ~]',
})
})

it('removes circular references from objects in array', () => {
it('removes circular references from objects in json array', () => {
const obj = {
foo: 'bar',
baz: {
Expand All @@ -185,8 +239,8 @@ describe('removeCycles', () => {
obj.circular = obj
const arr = [{foo: 'bar'}, obj]

const result = removeCycles(arr)
expect(result).to.deep.equal([
const result = stringifyInput(arr)
expect(JSON.parse(result)).to.deep.equal([
{
foo: 'bar',
},
Expand All @@ -195,7 +249,24 @@ describe('removeCycles', () => {
qux: 'quux',
},
foo: 'bar',
circular: '[Circular ~.1]',
},
])
})

it('does not remove repeated objects', () => {
const repeatedObj = {
name: 'FooBar',
state: 'Unchanged',
path: '/path/to/file.txt',
}
const obj = {
key1: [{key2: [repeatedObj]}],
key2: [repeatedObj],
}

const result = JSON.parse(stringifyInput(obj))
expect(result.key1[0].key2[0]).to.deep.equal(repeatedObj)
expect(result.key2[0]).to.deep.equal(repeatedObj)
})
})

0 comments on commit 9073052

Please sign in to comment.