├── .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 |
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 |
--------------------------------------------------------------------------------