├── .node-version ├── .prettierignore ├── design ├── social.png ├── logo.sketch ├── fonts │ ├── SpaceMono-Bold.ttf │ ├── SpaceMono-Italic.ttf │ ├── SpaceMono-Regular.ttf │ └── SpaceMono-BoldItalic.ttf └── logo.svg ├── src ├── constants.ts ├── mod.ts ├── InputStream.ts ├── Parser.ts └── Serializer.ts ├── .prettierrc.json ├── .vscode └── settings.json ├── vitest.config.ts ├── .gitignore ├── .release-it.json ├── tsconfig.json ├── LICENSE ├── tests ├── parseOne.test.ts ├── serialize.test.ts └── parse.test.ts ├── eslint.config.js ├── README.md └── package.json /.node-version: -------------------------------------------------------------------------------- 1 | v20.12.2 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | pnpm-lock.yaml 2 | -------------------------------------------------------------------------------- /design/social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dldc-packages/literal-parser/HEAD/design/social.png -------------------------------------------------------------------------------- /design/logo.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dldc-packages/literal-parser/HEAD/design/logo.sketch -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const SINGLE_QUOTE = "'"; 2 | export const DOUBLE_QUOTE = '"'; 3 | export const BACKTICK = '`'; 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "useTabs": false 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "eslint.useFlatConfig": true 4 | } 5 | -------------------------------------------------------------------------------- /design/fonts/SpaceMono-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dldc-packages/literal-parser/HEAD/design/fonts/SpaceMono-Bold.ttf -------------------------------------------------------------------------------- /src/mod.ts: -------------------------------------------------------------------------------- 1 | export { Parser } from './Parser'; 2 | export { Serializer, type Format, type FormatObj } from './Serializer'; 3 | -------------------------------------------------------------------------------- /design/fonts/SpaceMono-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dldc-packages/literal-parser/HEAD/design/fonts/SpaceMono-Italic.ttf -------------------------------------------------------------------------------- /design/fonts/SpaceMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dldc-packages/literal-parser/HEAD/design/fonts/SpaceMono-Regular.ttf -------------------------------------------------------------------------------- /design/fonts/SpaceMono-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dldc-packages/literal-parser/HEAD/design/fonts/SpaceMono-BoldItalic.ttf -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | plugins: [], 5 | test: {}, 6 | }); 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | lerna-debug.log* 10 | 11 | *.tsbuildinfo 12 | coverage 13 | node_modules/ 14 | jspm_packages/ 15 | .env 16 | .env.test 17 | 18 | dist 19 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "before:init": ["pnpm run build", "pnpm test"] 4 | }, 5 | "npm": { 6 | "publish": true 7 | }, 8 | "git": { 9 | "changelog": "pnpm run --silent changelog" 10 | }, 11 | "github": { 12 | "release": true, 13 | "web": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "include": ["src", "tests", "vitest.config.ts"], 4 | "compilerOptions": { 5 | "rootDir": ".", 6 | "outDir": "dist", 7 | "target": "ESNext", 8 | "module": "ES2020", 9 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 10 | "importHelpers": false, 11 | "moduleResolution": "node", 12 | "esModuleInterop": true, 13 | "isolatedModules": true, 14 | "declaration": true, 15 | "sourceMap": true, 16 | "noEmit": true, 17 | "types": [], 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noImplicitReturns": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "skipLibCheck": true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Etienne Dldc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/InputStream.ts: -------------------------------------------------------------------------------- 1 | export interface InputStream { 2 | next(count?: number): string; 3 | peek(length?: number): string; 4 | position(): number; 5 | eof(): boolean; 6 | croak(msg: string): never; 7 | } 8 | 9 | export function InputStream(input: string): InputStream { 10 | let pos = 0; 11 | let line = 1; 12 | let col = 0; 13 | 14 | return { 15 | next, 16 | peek, 17 | eof, 18 | croak, 19 | position: () => pos, 20 | }; 21 | 22 | function next(count = 1): string { 23 | let val = ''; 24 | for (let i = 0; i < count; i++) { 25 | const ch = input.charAt(pos++); 26 | if (ch === '\n') { 27 | line++; 28 | col = 0; 29 | } else { 30 | col++; 31 | } 32 | val += ch; 33 | } 34 | return val; 35 | } 36 | 37 | function peek(length = 1): string { 38 | if (length === 1) { 39 | return input.charAt(pos); 40 | } 41 | let val = ''; 42 | for (let i = 0; i < length; i++) { 43 | val += input.charAt(pos + i); 44 | } 45 | return val; 46 | } 47 | 48 | function eof(): boolean { 49 | return peek() === ''; 50 | } 51 | 52 | function croak(msg: string): never { 53 | throw new Error(msg + ' (' + line + ':' + col + ')'); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/parseOne.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | import { Parser } from '../src/Parser'; 3 | 4 | describe('parseOne get correct length', () => { 5 | const SHAPES: Array<[string, number]> = [ 6 | ['{}', 2], 7 | [`'foo'`, 5], 8 | [`'john\\'s'`, 9], 9 | ['{ foo: {} }', 11], 10 | ['{ "foo-bar": {} }', 17], 11 | [`{ 'foo-bar': {} }`, 17], 12 | ['{ foo: {}, bar: {} }', 20], 13 | ['{ foo: { bar: { baz: {} } } }', 29], 14 | ['{ foo: 45 }', 11], 15 | ['{ foo: 45.566 }', 15], 16 | ['{ foo: -45.566 }', 16], 17 | ['{ foo: -.566 }', 14], 18 | ['{ foo: "bar" }', 14], 19 | [`{ foo: 'bar' }`, 14], 20 | [`{ ['foo']: 'bar' }`, 18], 21 | [`{ 45: 'bar' }`, 13], 22 | [`[0, 1, 5]`, 9], 23 | [`1234`, 4], 24 | [`12.34`, 5], 25 | [`true`, 4], 26 | [`false`, 5], 27 | [`{ foo: true }`, 13], 28 | [`{ foo: false }`, 14], 29 | [`{ foo: 'l\\'orage' }`, 19], 30 | [`null`, 4], 31 | [`undefined`, 9], 32 | ]; 33 | 34 | SHAPES.forEach(([str, res]) => { 35 | test(`Parse ${str}`, () => { 36 | expect(Parser.parseOne(str).length).toEqual(res); 37 | }); 38 | }); 39 | }); 40 | 41 | describe('parseOne ignore stuff after', () => { 42 | const SHAPES: Array<[string, any]> = [ 43 | [`{ foo: 'l\\'orage' }{}`, { foo: "l'orage" }], 44 | [`null}some other stuff`, null], 45 | [`undefined}yoloooo`, undefined], 46 | ]; 47 | 48 | SHAPES.forEach(([str, res]) => { 49 | test(str, () => { 50 | expect(Parser.parseOne(str).value).toEqual(res); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js'; 2 | import tseslint from 'typescript-eslint'; 3 | 4 | export default tseslint.config( 5 | { ignores: ['dist', 'coverage'] }, 6 | eslint.configs.recommended, 7 | ...tseslint.configs.recommendedTypeChecked, 8 | { 9 | languageOptions: { 10 | parserOptions: { 11 | sourceType: 'module', 12 | ecmaVersion: 2020, 13 | project: true, 14 | tsconfigRootDir: import.meta.dirname, 15 | }, 16 | }, 17 | rules: { 18 | 'no-constant-condition': 'off', 19 | '@typescript-eslint/ban-types': 'off', 20 | '@typescript-eslint/consistent-type-imports': 'error', 21 | '@typescript-eslint/no-base-to-string': 'off', 22 | '@typescript-eslint/no-empty-function': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | '@typescript-eslint/no-inferrable-types': 'off', 25 | '@typescript-eslint/no-non-null-assertion': 'off', 26 | '@typescript-eslint/no-redundant-type-constituents': 'off', 27 | '@typescript-eslint/no-this-alias': 'off', 28 | '@typescript-eslint/no-unsafe-argument': 'off', 29 | '@typescript-eslint/no-unsafe-assignment': 'off', 30 | '@typescript-eslint/no-unsafe-call': 'off', 31 | '@typescript-eslint/no-unsafe-member-access': 'off', 32 | '@typescript-eslint/no-unsafe-return': 'off', 33 | '@typescript-eslint/no-unused-vars': 'off', 34 | '@typescript-eslint/unbound-method': 'off', 35 | }, 36 | }, 37 | { 38 | files: ['**/*.js'], 39 | extends: [tseslint.configs.disableTypeChecked], 40 | }, 41 | ); 42 | -------------------------------------------------------------------------------- /tests/serialize.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { Serializer } from '../src/Serializer'; 3 | 4 | test('Handle quote in string when serialize', () => { 5 | expect(() => Serializer.serialize('"')).not.toThrow(); 6 | expect(() => Serializer.serialize("'")).not.toThrow(); 7 | 8 | expect(Serializer.serialize('"')).toEqual(`'"'`); 9 | expect(Serializer.serialize("'")).toEqual(`"'"`); 10 | }); 11 | 12 | test('Handle escaped quote in string', () => { 13 | // prettier-ignore 14 | expect(() => Serializer.serialize("\"")).not.toThrow(); 15 | // prettier-ignore 16 | expect(Serializer.serialize("\"")).toEqual(`'"'`); 17 | }); 18 | 19 | test('Handle recursive object', () => { 20 | const a: any = {}; 21 | const b: any = {}; 22 | a.b = b; 23 | b.a = a; 24 | 25 | expect(() => Serializer.serialize(a)).toThrow('Value not compatible with JSON.stringify'); 26 | }); 27 | 28 | test('Serialize complex object', () => { 29 | const obj = { bool: true, num: 1, str: 'foo', arr: [1, 2, 3], obj: { foo: 'bar' } }; 30 | 31 | expect(Serializer.serialize(obj)).toEqual(`{ bool: true, num: 1, str: 'foo', arr: [1, 2, 3], obj: { foo: 'bar' } }`); 32 | 33 | expect(Serializer.serialize(obj, 'compact')).toEqual(`{bool:true,num:1,str:'foo',arr:[1,2,3],obj:{foo:'bar'}}`); 34 | 35 | expect(Serializer.serialize(obj, 2).split('\n')).toEqual([ 36 | '{', 37 | ' bool: true,', 38 | ' num: 1,', 39 | " str: 'foo',", 40 | ' arr: [', 41 | ' 1,', 42 | ' 2,', 43 | ' 3', 44 | ' ],', 45 | ' obj: {', 46 | " foo: 'bar'", 47 | ' }', 48 | '}', 49 | ]); 50 | 51 | expect(Serializer.serialize(obj, 'pretty').split('\n')).toEqual([ 52 | `{ bool: true, num: 1, str: 'foo', arr: [1, 2, 3], obj: { foo: 'bar' } }`, 53 | ]); 54 | 55 | expect(Serializer.serialize(obj, { mode: 'pretty', threshold: 20 }).split('\n')).toEqual([ 56 | '{', 57 | ' bool: true,', 58 | ' num: 1,', 59 | " str: 'foo',", 60 | ' arr: [1, 2, 3],', 61 | " obj: { foo: 'bar' }", 62 | '}', 63 | ]); 64 | }); 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | literal-parser logo 3 |

4 | 5 | # 🔎 literal-parser 6 | 7 | > A small library to parse and serialize JavaScript array/object literal. 8 | 9 | This is like a `JSON.parse` / `JSON.serialize` but for JavaScript object instead of JSON objects. 10 | 11 | ## Gist 12 | 13 | ```js 14 | import Parser from '@dldc/literal-parser'; 15 | 16 | Parser.parse('{ some: ["object", { literal: true }] }'); 17 | // return an object { some: ["object", { literal: true }] } 18 | ``` 19 | 20 | ## Supported features 21 | 22 | Take a look at the tests see what is supported. 23 | 24 | ## API 25 | 26 | ### `Parser.parse(str)` 27 | 28 | > Parse the string, expect the string to contain only one expression and throw otherwise. 29 | 30 | Return the parsed object. 31 | 32 | ### `Parser.parseOne(str)` 33 | 34 | > Parse one expression then stop. 35 | 36 | Returns a object with `{ value, length }` where `value` is the parsed expression and `length` is the number of character parsed. 37 | 38 | ```js 39 | Parser.parseOne('{ props: true }} something="else" />'); 40 | // { value: { props: true }, length: 15 } 41 | ``` 42 | 43 | ### `Serializer.serialize(obj, format?)` 44 | 45 | > Print an object. 46 | 47 | `format` is optional and can be one of the following: 48 | 49 | #### `{ mode: 'line' } | 'line'` 50 | 51 | > Print on a single line with spaces. _This is the default format_ 52 | 53 | #### `{ mode: 'compact' } | 'compact'` 54 | 55 | > Print on a single line without any spaces. 56 | 57 | #### `{ mode: 'indent' } | 'indent' | number` 58 | 59 | > Similar to `JSON.stringify(obj, null, indent)`. 60 | 61 | Options: 62 | 63 | - `space` (default: `2`) 64 | 65 | #### `{ mode: 'pretty' } | 'pretty'` 66 | 67 | > Inspired by prettier, this mode will try to print objects and arrays on a single line, if the result is bigger than the `threshold` then it's splitted into multiple lines. 68 | 69 | Options: 70 | 71 | - `space` (default: `2`) 72 | - `threshold` (default: `80`) 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dldc/literal-parser", 3 | "version": "3.1.6", 4 | "description": "A small library to parse JavaScript array/object literal", 5 | "keywords": [], 6 | "homepage": "https://github.com/dldc-packages/literal-parser#readme", 7 | "bugs": { 8 | "url": "https://github.com/dldc-packages/literal-parser/issues" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/dldc-packages/literal-parser.git" 13 | }, 14 | "license": "MIT", 15 | "author": "Etienne Dldc ", 16 | "sideEffects": false, 17 | "type": "module", 18 | "exports": { 19 | ".": { 20 | "types": "./dist/mod.d.ts", 21 | "import": "./dist/mod.js", 22 | "require": "./dist/mod.cjs" 23 | } 24 | }, 25 | "main": "./dist/mod.js", 26 | "types": "./dist/mod.d.ts", 27 | "files": [ 28 | "dist" 29 | ], 30 | "scripts": { 31 | "build": "rimraf dist && tsup --format cjs,esm src/mod.ts --dts", 32 | "build:watch": "tsup --watch --format cjs,esm src/mod.ts --dts", 33 | "changelog": "auto-changelog --stdout --hide-credit true --commit-limit false -u --template https://raw.githubusercontent.com/release-it/release-it/main/templates/changelog-compact.hbs", 34 | "lint": "prettier . --check && eslint . && tsc --noEmit", 35 | "lint:fix": "prettier . --write . && eslint . --fix", 36 | "release": "release-it --only-version", 37 | "test": "pnpm run lint && vitest run --coverage", 38 | "test:run": "vitest run", 39 | "test:watch": "vitest --watch", 40 | "test:watch:coverage": "vitest --watch --coverage", 41 | "typecheck": "tsc", 42 | "typecheck:watch": "tsc --watch" 43 | }, 44 | "devDependencies": { 45 | "@eslint/js": "^9.2.0", 46 | "@types/node": "^20.12.8", 47 | "@vitest/coverage-v8": "^1.6.0", 48 | "auto-changelog": "^2.4.0", 49 | "eslint": "^8.57.0", 50 | "prettier": "^3.2.5", 51 | "release-it": "^17.2.1", 52 | "rimraf": "^5.0.5", 53 | "tsup": "^8.0.2", 54 | "typescript": "^5.4.5", 55 | "typescript-eslint": "^7.8.0", 56 | "vitest": "^1.6.0" 57 | }, 58 | "packageManager": "pnpm@9.0.6", 59 | "publishConfig": { 60 | "access": "public", 61 | "registry": "https://registry.npmjs.org" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/parse.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | import { Parser } from '../src/Parser'; 3 | import { Serializer } from '../src/Serializer'; 4 | 5 | describe('parse all sort of shape', () => { 6 | const SHAPES: Array<[string, any]> = [ 7 | ['{}', {}], 8 | [`'foo'`, 'foo'], 9 | [`'john\\'s'`, "john's"], 10 | ['{ foo: {} }', { foo: {} }], 11 | ['{ "foo-bar": {} }', { 'foo-bar': {} }], 12 | [`{ 'foo-bar': {} }`, { 'foo-bar': {} }], 13 | ['{ foo: {}, bar: {} }', { foo: {}, bar: {} }], 14 | ['{ foo: { bar: { baz: {} } } }', { foo: { bar: { baz: {} } } }], 15 | ['{ foo: 45 }', { foo: 45 }], 16 | ['{ foo: 45.566 }', { foo: 45.566 }], 17 | ['{ foo: -45.566 }', { foo: -45.566 }], 18 | ['{ foo: -.566 }', { foo: -0.566 }], 19 | ['{ foo: "bar" }', { foo: 'bar' }], 20 | [`{ foo: 'bar' }`, { foo: 'bar' }], 21 | [`{ ['foo']: 'bar' }`, { foo: 'bar' }], 22 | [`{ 45: 'bar' }`, { 45: 'bar' }], 23 | [`[0, 1, 5]`, [0, 1, 5]], 24 | [`1234`, 1234], 25 | [`12.34`, 12.34], 26 | [`true`, true], 27 | [`false`, false], 28 | [`{ foo: true }`, { foo: true }], 29 | [`{ foo: false }`, { foo: false }], 30 | [`{ foo: 'l\\'orage' }`, { foo: `l'orage` }], 31 | [`null`, null], 32 | [`undefined`, undefined], 33 | ]; 34 | 35 | SHAPES.forEach(([str, res]) => { 36 | test(`Parse ${str}`, () => { 37 | expect(Parser.parse(str)).toEqual(res); 38 | }); 39 | }); 40 | }); 41 | 42 | describe('parse then serialize should return the same', () => { 43 | const SHAPES: Array = [ 44 | '{}', 45 | '[]', 46 | `'foo'`, 47 | `"john's"`, 48 | "'string with backtick ``'", 49 | '{ foo: {} }', 50 | `{ 'foo-bar': {} }`, 51 | '{ foo: {}, bar: {} }', 52 | '{ foo: { bar: { baz: {} } } }', 53 | '{ foo: 45 }', 54 | '{ foo: 45.566 }', 55 | '{ foo: -45.566 }', 56 | '{ foo: -0.566 }', 57 | `{ foo: 'bar' }`, 58 | `{ 45: 'bar' }`, 59 | `[0, 1, 5]`, 60 | `1234`, 61 | `12.34`, 62 | `true`, 63 | `false`, 64 | `{ foo: true }`, 65 | `{ foo: false }`, 66 | `{ foo: "l'orage" }`, 67 | `null`, 68 | `undefined`, 69 | ]; 70 | 71 | SHAPES.forEach((str) => { 72 | test(`Parse then serialize ${str}`, () => { 73 | expect(Serializer.serialize(Parser.parse(str))).toEqual(str); 74 | }); 75 | }); 76 | }); 77 | 78 | test('parse complex object', () => { 79 | expect( 80 | Parser.parse(`{ 81 | type: 'Root', 82 | version: 1, 83 | course: [], 84 | slides: [] 85 | }`), 86 | ).toEqual({ 87 | type: 'Root', 88 | version: 1, 89 | course: [], 90 | slides: [], 91 | }); 92 | }); 93 | 94 | test('nested array', () => { 95 | expect(Parser.parse(`[[], []]`)).toEqual([[], []]); 96 | }); 97 | 98 | test('array of objects', () => { 99 | expect(Parser.parse(`[{ foo: true }, { bar: false }]`)).toEqual([{ foo: true }, { bar: false }]); 100 | }); 101 | 102 | test('nesteeeeeed', () => { 103 | expect(Parser.parse(`[[[[[[[[]]]]]]]]`)).toEqual([[[[[[[[]]]]]]]]); 104 | }); 105 | 106 | test('throw when more than one expression', () => { 107 | expect(() => Parser.parse('{}{}')).toThrow(); 108 | }); 109 | 110 | test('throw when empty', () => { 111 | expect(() => Parser.parse('')).toThrow('Unexpected empty string'); 112 | }); 113 | 114 | test('parse trailing commas', () => { 115 | expect(Parser.parse('{ foo: true, }')).toEqual({ foo: true }); 116 | }); 117 | 118 | test('fail when missing commas', () => { 119 | expect(() => Parser.parse('{ foo: true bar: false }')).toThrow(); 120 | expect(() => Parser.parse('[0 1 2]')).toThrow(); 121 | }); 122 | 123 | test('parse trailing commas in array', () => { 124 | expect(Parser.parse('[true, false, ]')).toEqual([true, false]); 125 | }); 126 | 127 | test('throw on invalid key', () => { 128 | expect(() => Parser.parse('{ -45: 55 }')).toThrow(); 129 | }); 130 | 131 | test('throw when invalid', () => { 132 | expect(() => Parser.parse('!')).toThrow(); 133 | }); 134 | 135 | test('string does not support multiline', () => { 136 | expect(() => Parser.parse(`'foo\nbar'`)).toThrow(); 137 | }); 138 | 139 | test('parse empty string', () => { 140 | expect(Parser.parse('""')).toEqual(''); 141 | }); 142 | 143 | describe('comments', () => { 144 | test('line comment', () => { 145 | expect(Parser.parse('{}// test 2 {}')).toEqual({}); 146 | }); 147 | 148 | test('inside comments', () => { 149 | expect(Parser.parse('/* test */{}/* test 2 */')).toEqual({}); 150 | }); 151 | 152 | test('multi-line comments', () => { 153 | expect(Parser.parse('/* \n */{}/* test 2 */')).toEqual({}); 154 | }); 155 | }); 156 | -------------------------------------------------------------------------------- /src/Parser.ts: -------------------------------------------------------------------------------- 1 | import { InputStream } from './InputStream'; 2 | import { BACKTICK, DOUBLE_QUOTE, SINGLE_QUOTE } from './constants'; 3 | 4 | export const Parser = { 5 | parse, 6 | parseOne, 7 | }; 8 | 9 | interface ParseOneResult { 10 | length: number; 11 | value: any; 12 | } 13 | 14 | function parseOne(file: string): ParseOneResult { 15 | const input = InputStream(file); 16 | const res = parseInternal(input); 17 | return { 18 | value: res, 19 | length: input.position(), 20 | }; 21 | } 22 | 23 | function parse(file: string): any { 24 | const input = InputStream(file); 25 | 26 | const res = parseInternal(input); 27 | 28 | if (input.eof()) { 29 | return res; 30 | } 31 | input.croak(`Expected EOF`); 32 | } 33 | 34 | function parseInternal(input: InputStream): any { 35 | return root(); 36 | 37 | function root() { 38 | skipWhitespacesAndComments(); 39 | const expr = parseExpression(); 40 | skipWhitespacesAndComments(); 41 | return expr; 42 | } 43 | 44 | function parseExpression(): any { 45 | const ch = input.peek(); 46 | if (ch === '{') { 47 | return parseObject(); 48 | } 49 | if (ch === '[') { 50 | return parseArray(); 51 | } 52 | if (ch === '-') { 53 | input.next(); 54 | return parseNumber(true); 55 | } 56 | if (ch === SINGLE_QUOTE || ch === DOUBLE_QUOTE || ch === BACKTICK) { 57 | return parseString(ch); 58 | } 59 | if (isDigit(ch)) { 60 | return parseNumber(); 61 | } 62 | if (isIdentifier('true')) { 63 | skipIdentifier('true'); 64 | return true; 65 | } 66 | if (isIdentifier('false')) { 67 | skipIdentifier('false'); 68 | return false; 69 | } 70 | if (isIdentifier('null')) { 71 | skipIdentifier('null'); 72 | return null; 73 | } 74 | if (isIdentifier('undefined')) { 75 | skipIdentifier('undefined'); 76 | return undefined; 77 | } 78 | if (input.eof()) { 79 | return input.croak(`Unexpected empty string`); 80 | } 81 | return input.croak(`Unexpected "${ch}"`); 82 | } 83 | 84 | function skipIdentifier(identifier: string): string { 85 | const next = input.next(identifier.length); 86 | if (next !== identifier) { 87 | input.croak(`Expected identifier "${identifier}" got "${next}"`); 88 | } 89 | return next; 90 | } 91 | 92 | function isIdentifier(identifier: string): boolean { 93 | if (input.peek(identifier.length) === identifier) { 94 | const after = input.peek(identifier.length + 1)[identifier.length]; 95 | return after === undefined || isNameChar(after) === false; 96 | } 97 | return false; 98 | } 99 | 100 | function isDigit(ch: string): boolean { 101 | return /[0-9]/i.test(ch); 102 | } 103 | 104 | function isNameStart(ch: string): boolean { 105 | return /[a-zA-Z_]/i.test(ch); 106 | } 107 | 108 | function isNameChar(ch: string): boolean { 109 | return isNameStart(ch) || '0123456789_'.indexOf(ch) >= 0; 110 | } 111 | 112 | function skipWhitespacesAndComments() { 113 | let didSomething: boolean; 114 | do { 115 | didSomething = skipComment() || skipWhitespaces(); 116 | } while (didSomething); 117 | } 118 | 119 | function skipComment(): boolean { 120 | if (input.peek(2) === '//') { 121 | input.next(); 122 | input.next(); 123 | skipUntil('\n'); 124 | return true; 125 | } 126 | if (input.peek(2) === '/*') { 127 | input.next(); 128 | input.next(); 129 | skipUntil('*/'); 130 | input.next(); 131 | input.next(); 132 | return true; 133 | } 134 | return false; 135 | } 136 | 137 | function isWhitspace(char: string): boolean { 138 | return char === ' ' || char === '\t' || char === '\n'; 139 | } 140 | 141 | function skipWhitespaces(): boolean { 142 | if (!isWhitspace(input.peek())) { 143 | return false; 144 | } 145 | while (!input.eof() && isWhitspace(input.peek())) { 146 | input.next(); 147 | } 148 | return true; 149 | } 150 | 151 | function skipUntil(condition: string) { 152 | while (!input.eof() && input.peek(condition.length) !== condition) { 153 | input.next(); 154 | } 155 | } 156 | 157 | function parseNumber(negative = false): number { 158 | let hasDot = false; 159 | const number = readWhile((ch) => { 160 | if (ch === '.') { 161 | if (hasDot) { 162 | return false; 163 | } 164 | hasDot = true; 165 | return true; 166 | } 167 | return isDigit(ch); 168 | }); 169 | return parseFloat(number) * (negative ? -1 : 1); 170 | } 171 | 172 | function parseArray(): Array { 173 | skip('['); 174 | const arr: Array = []; 175 | skipWhitespacesAndComments(); 176 | if (input.peek() === ']') { 177 | skip(']'); 178 | return arr; 179 | } 180 | while (!input.eof() && input.peek() !== ']') { 181 | const value = parseExpression(); 182 | skipWhitespacesAndComments(); 183 | arr.push(value); 184 | 185 | const foundComma = maybeSkip(','); 186 | if (!foundComma) { 187 | break; 188 | } 189 | skipWhitespacesAndComments(); 190 | } 191 | skip(']'); 192 | return arr; 193 | } 194 | 195 | function parseObject(): Record { 196 | skip('{'); 197 | const obj: Record = {}; 198 | skipWhitespacesAndComments(); 199 | if (input.peek() === '}') { 200 | skip('}'); 201 | return obj; 202 | } 203 | while (!input.eof() && input.peek() !== '}') { 204 | const key = parseKey(); 205 | skip(':'); 206 | skipWhitespacesAndComments(); 207 | const value = parseExpression(); 208 | skipWhitespacesAndComments(); 209 | obj[key] = value; 210 | const foundComma = maybeSkip(','); 211 | if (!foundComma) { 212 | break; 213 | } 214 | skipWhitespacesAndComments(); 215 | } 216 | skip('}'); 217 | return obj; 218 | } 219 | 220 | function parseKey(): string | number { 221 | const next = input.peek(); 222 | if (isDigit(input.peek())) { 223 | const res = parseNumber(); 224 | return res; 225 | } 226 | if (next === SINGLE_QUOTE || next === DOUBLE_QUOTE) { 227 | return parseString(next); 228 | } 229 | if (input.peek() === '[') { 230 | skip('['); 231 | const expr = parseExpression(); 232 | skip(']'); 233 | return expr; 234 | } 235 | if (isNameStart(input.peek())) { 236 | return readWhile(isNameChar); 237 | } 238 | return input.croak(`Unexpected "${input.peek()}"`); 239 | } 240 | 241 | function readWhile(predicate: (ch: string) => boolean): string { 242 | let str = ''; 243 | while (!input.eof() && predicate(input.peek())) { 244 | str += input.next(); 245 | } 246 | return str; 247 | } 248 | 249 | function parseString(end: "'" | '"' | '`'): string { 250 | let escaped = false; 251 | let str = ''; 252 | input.next(); 253 | while (!input.eof()) { 254 | const ch = input.next(); 255 | if (end !== BACKTICK && ch === '\n') { 256 | break; 257 | } 258 | if (escaped) { 259 | str += ch; 260 | escaped = false; 261 | } else if (ch === end) { 262 | break; 263 | } else if (ch === '\\') { 264 | escaped = true; 265 | } else { 266 | str += ch; 267 | } 268 | } 269 | return str; 270 | } 271 | 272 | // function skipUntil(condition: string) { 273 | // let val: string = ''; 274 | // while (input.peek(condition.length) !== condition) { 275 | // val += input.next(); 276 | // } 277 | // return val; 278 | // } 279 | 280 | function skip(char: string) { 281 | if (input.peek() !== char) { 282 | input.croak(`Expected ${char} got ${input.peek()}`); 283 | } 284 | input.next(); 285 | } 286 | 287 | function maybeSkip(char: string): boolean { 288 | if (input.peek() === char) { 289 | input.next(); 290 | return true; 291 | } 292 | return false; 293 | } 294 | } 295 | -------------------------------------------------------------------------------- /src/Serializer.ts: -------------------------------------------------------------------------------- 1 | import { BACKTICK, DOUBLE_QUOTE, SINGLE_QUOTE } from './constants'; 2 | 3 | export const Serializer = { 4 | serialize, 5 | }; 6 | 7 | export type FormatObj = 8 | | { mode: 'line' } 9 | | { mode: 'compact' } 10 | | { mode: 'indent'; space?: number } 11 | | { mode: 'pretty'; threshold?: number; space?: number }; 12 | 13 | export type Format = 'line' | 'compact' | 'pretty' | 'indent' | number | FormatObj; 14 | 15 | type PrintItem = 16 | | string 17 | | { type: 'Line' } 18 | | { type: 'IndentObj' } 19 | | { type: 'DedentObj' } 20 | | { type: 'IndentArr' } 21 | | { type: 'DedentArr' } 22 | | { type: 'Space' } 23 | | { type: 'Group'; items: PrintItems }; 24 | 25 | type PrintItems = readonly PrintItem[]; 26 | 27 | function serialize(obj: unknown, format: Format = 'line'): string { 28 | try { 29 | JSON.stringify(obj); 30 | } catch (error) { 31 | throw new Error(`Value not compatible with JSON.stringify`); 32 | } 33 | const formatObj = resolveFormatObj(format); 34 | 35 | const items = printItems(obj); 36 | return formatPrintItems(items, formatObj, 0); 37 | 38 | function printItems(obj: unknown): PrintItems { 39 | if (obj === null) { 40 | return ['null']; 41 | } 42 | if (obj === undefined) { 43 | return ['undefined']; 44 | } 45 | if (obj === true) { 46 | return ['true']; 47 | } 48 | if (obj === false) { 49 | return ['false']; 50 | } 51 | if (typeof obj === 'string') { 52 | return [serializeString(obj)]; 53 | } 54 | if (typeof obj === 'number') { 55 | if (Number.isFinite(obj)) { 56 | return [obj.toString()]; 57 | } 58 | } 59 | if (Array.isArray(obj)) { 60 | const items: PrintItem[] = []; 61 | items.push('['); 62 | if (obj.length === 0) { 63 | items.push(']'); 64 | return items; 65 | } 66 | items.push({ type: 'IndentArr' }); 67 | obj.forEach((item, index) => { 68 | if (index > 0) { 69 | items.push(','); 70 | items.push({ type: 'Line' }); 71 | } 72 | items.push(...printItems(item)); 73 | }); 74 | items.push({ type: 'DedentArr' }); 75 | items.push(']'); 76 | return [{ type: 'Group', items }]; 77 | } 78 | if (isPlainObject(obj)) { 79 | return objectPrintItems(obj as Record); 80 | } 81 | console.log(obj); 82 | throw new Error(`Unsuported type ${typeof obj}`); 83 | } 84 | 85 | function objectPrintItems(obj: Record): PrintItems { 86 | const items: PrintItem[] = []; 87 | items.push('{'); 88 | const keys = Object.keys(obj); 89 | if (keys.length === 0) { 90 | items.push('}'); 91 | return items; 92 | } 93 | items.push({ type: 'IndentObj' }); 94 | keys.forEach((key, index) => { 95 | if (index > 0) { 96 | items.push(','); 97 | items.push({ type: 'Line' }); 98 | } 99 | items.push(serializeKey(key)); 100 | items.push(':'); 101 | items.push({ type: 'Space' }); 102 | items.push(...printItems(obj[key])); 103 | }); 104 | items.push({ type: 'DedentObj' }); 105 | items.push('}'); 106 | return [{ type: 'Group', items }]; 107 | } 108 | 109 | function serializeKey(key: string | number): string { 110 | if (typeof key === 'number') { 111 | return key.toString(); 112 | } 113 | if (key.match(/^[A-Za-z0-9][A-Za-z0-9_]+$/)) { 114 | return key; 115 | } 116 | return serializeString(key); 117 | } 118 | 119 | function serializeString(obj: string) { 120 | const hasSingle = obj.indexOf(SINGLE_QUOTE) >= 0; 121 | // remove quote char 122 | if (!hasSingle) { 123 | return `'${obj}'`; 124 | } 125 | const hasDouble = obj.indexOf(DOUBLE_QUOTE) >= 0; 126 | if (!hasDouble) { 127 | return `"${obj}"`; 128 | } 129 | const hasBacktick = obj.indexOf(BACKTICK) >= 0; 130 | if (!hasBacktick) { 131 | return '`' + obj + '`'; 132 | } 133 | return `'${obj.replace(/'/g, `\\'`)}'`; 134 | } 135 | } 136 | 137 | function formatPrintItems(items: readonly PrintItem[], format: FormatObj, baseDepth: number) { 138 | let result: string = ''; 139 | let depth = baseDepth; 140 | items.forEach((item) => { 141 | if (typeof item === 'string') { 142 | result += item; 143 | return; 144 | } 145 | if (format.mode === 'compact') { 146 | if (item.type === 'Group') { 147 | result += formatPrintItems(item.items, format, depth); 148 | return; 149 | } 150 | return; 151 | } 152 | if (format.mode === 'line') { 153 | switch (item.type) { 154 | case 'Group': 155 | result += formatPrintItems(item.items, format, depth); 156 | return; 157 | case 'Line': 158 | case 'Space': 159 | result += ' '; 160 | return; 161 | case 'IndentArr': 162 | case 'DedentArr': 163 | return; 164 | case 'IndentObj': 165 | case 'DedentObj': 166 | result += ' '; 167 | return; 168 | default: 169 | return; 170 | } 171 | } 172 | if (format.mode === 'indent') { 173 | const { space = 2 } = format; 174 | const padding = ' '.repeat(space); 175 | switch (item.type) { 176 | case 'Group': 177 | result += formatPrintItems(item.items, format, depth); 178 | return; 179 | case 'Line': 180 | result += '\n' + padding.repeat(depth); 181 | return; 182 | case 'IndentArr': 183 | case 'IndentObj': 184 | depth += 1; 185 | result += '\n' + padding.repeat(depth); 186 | return; 187 | case 'DedentArr': 188 | case 'DedentObj': 189 | depth -= 1; 190 | result += '\n' + padding.repeat(depth); 191 | return; 192 | case 'Space': 193 | result += ' '; 194 | return; 195 | default: 196 | return; 197 | } 198 | } 199 | if (format.mode === 'pretty') { 200 | const { space = 2, threshold = 80 } = format; 201 | const padding = ' '.repeat(space); 202 | switch (item.type) { 203 | case 'Group': { 204 | const line = formatPrintItems(item.items, { mode: 'line' }, depth); 205 | if (line.length <= threshold) { 206 | result += line; 207 | return; 208 | } 209 | result += formatPrintItems(item.items, format, depth); 210 | return; 211 | } 212 | case 'Line': 213 | result += '\n' + padding.repeat(depth); 214 | return; 215 | case 'IndentArr': 216 | case 'IndentObj': 217 | depth += 1; 218 | result += '\n' + padding.repeat(depth); 219 | return; 220 | case 'DedentArr': 221 | case 'DedentObj': 222 | depth -= 1; 223 | result += '\n' + padding.repeat(depth); 224 | return; 225 | case 'Space': 226 | result += ' '; 227 | return; 228 | default: 229 | return; 230 | } 231 | } 232 | throw new Error(`Unsuported format ${format as any}`); 233 | }); 234 | 235 | return result; 236 | } 237 | 238 | function isObject(val: any): boolean { 239 | return val != null && typeof val === 'object' && Array.isArray(val) === false; 240 | } 241 | 242 | function isObjectObject(o: any) { 243 | return isObject(o) === true && Object.prototype.toString.call(o) === '[object Object]'; 244 | } 245 | 246 | function isPlainObject(o: any): boolean { 247 | if (isObjectObject(o) === false) return false; 248 | 249 | // If has modified constructor 250 | const ctor = o.constructor; 251 | if (typeof ctor !== 'function') return false; 252 | 253 | // If has modified prototype 254 | const prot = ctor.prototype; 255 | if (isObjectObject(prot) === false) return false; 256 | 257 | // If constructor does not have an Object-specific method 258 | if (Object.prototype.hasOwnProperty.call(prot, 'isPrototypeOf') === false) { 259 | return false; 260 | } 261 | 262 | // Most likely a plain Object 263 | return true; 264 | } 265 | 266 | function resolveFormatObj(format: Format): FormatObj { 267 | if (typeof format === 'number') { 268 | return { mode: 'indent', space: format }; 269 | } 270 | if (format === 'compact') { 271 | return { mode: 'compact' }; 272 | } 273 | if (format === 'indent') { 274 | return { mode: 'indent', space: 2 }; 275 | } 276 | if (format === 'line') { 277 | return { mode: 'line' }; 278 | } 279 | if (format === 'pretty') { 280 | return { mode: 'pretty', threshold: 80, space: 2 }; 281 | } 282 | return format; 283 | } 284 | -------------------------------------------------------------------------------- /design/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | logo 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 35 | --------------------------------------------------------------------------------