Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,18 @@ on:
env:
PR_COMMIT_SHA: ${{ github.event.client_payload.pull_request.head.sha }}
jobs:
build_and_test:
test_and_lint:
name: Test and Lint
strategy:
matrix:
# We need to make sure we are testing at least the earliest and latest versions of
# Emacs.
version: ["27.1", "29.1"]
runs-on: ubuntu-latest
steps:
- uses: purcell/setup-emacs@master
with:
version: "27.1"
version: ${{ matrix.version }}
- uses: actions/checkout@v2
with:
ref: ${{ env.PR_COMMIT_SHA }}
Expand Down
45 changes: 16 additions & 29 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -45,39 +45,26 @@ checkdoc:
--eval "(setq byte-compile-error-on-warn t)" \
-f batch-byte-compile $<

bench = time $(EMACS) -Q -nw $(3) \
--eval '(setq enable-local-variables nil)' \
--eval '(setq large-file-warning-threshold nil)' \
--eval '(switch-to-buffer (find-file-literally "$(1)"))' \
--eval $(2) \
--eval '(condition-case err \
(with-current-buffer (current-buffer) \
(setq font-lock-major-mode nil) \
(syntax-ppss-flush-cache -1) \
(font-lock-set-defaults) \
(save-excursion \
(font-lock-fontify-region (point-min) (point-max)))) \
((debug error) (kill-emacs (error-message-string err))))' \
--eval '(goto-char (point-max))' \
--eval '(kill-emacs)'

LARGE_JSON_FILE := test-assets/large-json-file.json
${LARGE_JSON_FILE}:
curl 'https://raw.githubusercontent.com/pulumi/pulumi-azure-native/master/provider/cmd/pulumi-resource-azure-native/schema.json' > ${LARGE_JSON_FILE}

bench-base: ${LARGE_JSON_FILE} jsonian.elc

bench-jsonian: bench-base
$(call bench,${LARGE_JSON_FILE}, "(progn (require 'jsonian) (jsonian-mode))", -L .)
BENCHMARK_START=<!--BENCHMARK_START-->
BENCHMARK_END=<!--BENCHMARK_END-->
README.md: bench/markdown.md
@echo "Splicing bench/markdown.md into README.md"
cp $@ $@.backup
rg -U '(?s)${BENCHMARK_START}.*${BENCHMARK_END}' \
--replace '${BENCHMARK_START}'"$$(cat bench/markdown.md)"'${BENCHMARK_END}' \
--passthru < $@ > $@.new
mv $@.new $@

bench-json-mode: bench-base
$(call bench,${LARGE_JSON_FILE}, "(progn (require 'json-mode) (json-mode))", -L ../json-mode -L ../json-snatcher -L ../json-reformat)
bench/markdown.md: bench/format.md bench/font-lock.md bench/markdown.sh
EMACS="${EMACS}" EXPORT="$@" ./bench/markdown.sh

bench-javascript: bench-base
$(call bench,${LARGE_JSON_FILE}, "(javascript-mode)",)

bench-fundamental: bench-base
$(call bench,${LARGE_JSON_FILE},"(fundamental-mode)",)
PHONY: bench-base
bench-base: ${LARGE_JSON_FILE} jsonian.elc
hyperfine --version # Ensure hyperfine is installed

bench-prog: bench-base
$(call bench,${LARGE_JSON_FILE},"(prog-mode)",)
bench/%.md: bench/%.sh bench-base
EMACS="${EMACS}" FILE="${LARGE_JSON_FILE}" EXPORT="$@" $<
121 changes: 85 additions & 36 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,41 @@ When you return from the buffer, the string is collapsed back into its escaped f

By default, this command is bound to `C-c C-s`.

#### jsonian-find

Provide an interactive completion interface for selecting an element in the
buffer. When the element is selected, jump to that point in the buffer.

By default, this command is bound to `C-c C-f`.

### jsonian-format-region

Maximize the JSON contents of the region. This is equivalent to the built-in function
`json-pretty-print`, but much faster (see "\#\# Benchmarks"). For example:

``` json
{"key":["simple",null,{"cpx": true},[]]}
```

Calling `jsonian-format-region` on the above will transform it into:

``` json
{
"key": [
"simple",
null,
{
"cpx": true
},
[]
]
}
```

If a prefix argument is supplied, `jsonian-format-region` minimizes instead of expanding.

By default, this command is bound to `C-c C-w`.

#### jsonian-enclosing-item

Move point to the enclosing node. For example:
Expand All @@ -169,49 +204,63 @@ the point to the opening `[`.

By default, this function is bound to `C-c C-e`.

#### jsonian-find

Provide an interactive completion interface for selecting an element in the
buffer. When the element is selected, jump to that point in the buffer.

#### jsonian-enable-flycheck

Enable `jsonian-mode` for all checkers where `json-mode` is enabled.

## Speed comparison against other modes

Part of the promise of `jsonian` is that it will be performant on large files. A
primitive benchmark is included in the `Makefile`. It opens a very very large
(42M) JSON file, and then forces emacs to fontify it. It finally moves point to
the end of the file and exits. Here is a comparison of the time it takes to
fontify the whole buffer on a file:

| Package | Time | comparison |
| ------------------ | -------------------------------------------------------------- | ---------- |
| `fundamental-mode` | 8 seconds | 0.66 |
| `prog-mode` | 8 seconds | 0.66 |
| `jsonian` | 12 seconds | 1 |
| `javascript-mode` | 31 seconds | 2.58 |
| `json-mode` | Fails after 43 seconds with "Stack overflow in regexp matcher" | 3.58 |

Here is what we can take away from this benchmark:

- Emacs spends 8 seconds traversing the buffer and parse matching delimiters. We
see that from the unfontified time of both `fundamental-mode` and `prog-mode`.
- `jsonian-mode` adds 4 seconds in fontification. I assume that this time is
spent in additional regex searches and function calls.
- `javascript-mode` spends 19 seconds longer the `jsonian-mode` to achieve the
same effect, presumably because the mode is more general. JavaScript is a much
more complicated spec then JSON. This will result in more complicated regexes
and functions.
- `json-mode` Spends 12 _additional_ seconds, presumably with an additional set
of font lock regexes.
<!--BENCHMARK_START-->
## Benchmarks

The original reason I wrote jsonian is that I needed to read and naviage very large JSON
files, and Emacs was slowing me down. To keep jsonian fast, I maintain benchmarks of
jsonian doing real world tasks.

### `font-lock`ing a large buffer

This benchmark opens a very large (42M) JSON file, then forces Emacs to fontify it. It
finally moves point to the end of the file and exits.

| Package | Mean [s] | Min [s] | Max [s] | Relative |
|:---|---:|---:|---:|---:|
| `fundamental-mode` | 1.444 ± 0.174 | 1.301 | 1.734 | 1.00 ± 0.12 |
| `prog-mode` | 1.442 ± 0.039 | 1.402 | 1.488 | 1.00 |
| `jsonian-mode` | 2.296 ± 0.013 | 2.289 | 2.332 | 1.59 ± 0.04 |
| `json-mode` | 3.775 ± 0.033 | 3.762 | 3.867 | 2.62 ± 0.07 |
| `javascript-mode` | 13.599 ± 0.288 | 13.341 | 14.145 | 9.43 ± 0.32 |

We can use this benchmark to derive how long different parts of the proces take.

- Fundamental mode is the lower limit. This is the time Emacs spends processing the
buffer, parsing sexps, etc.

- `prog-mode` doesn\'t do much more then `fundamental-mode`, which makes sense, since it
takes about the same amount of time.

- Applying JSON formatting take at most `jsonian-mode` - `prog-mode`.

- Parsing a javascript file is much more complicated (and thus expensive) then parsing a
JSON file.

### Formatting a large buffer

This tests applying formatting to a very large (42M) JSON file that is compressed to
remove all whitespace. The formatted files are largely identical.

| Package | Mean [s] | Min [s] | Max [s] | Relative |
|:---|---:|---:|---:|---:|
| `jsonian-format-region` | 1.709 ± 0.091 | 1.633 | 1.877 | 1.12 ± 0.06 |
| `jsonian-format-region (minimize)` | 1.524 ± 0.010 | 1.516 | 1.549 | 1.00 |
| `json-pretty-print-buffer` | 4.582 ± 0.006 | 4.576 | 4.593 | 3.01 ± 0.02 |
| `json-pretty-print-buffer (minimize)` | 4.440 ± 0.114 | 4.384 | 4.753 | 2.91 ± 0.08 |

We see that the built-in `json-pretty-print-buffer` takes significantly longer then
`jsonian-format-region`, regardless of whether we are pretty printing or minimizing.

Notes:

1. Both `jsonian` and `json-mode` were byte-compiled for this benchmark. Byte
compiling `jsonian` shaves 6 seconds off of this benchmark.
2. These benchmarks were taken on a 2.6 GHz 6-Core Intel i7 running macOS Monterey.
1. Both `jsonian` and `json-mode` were byte-compiled for the `font-lock` benchmark.
1. Tests were run against GNU Emacs 30.0.50.
1. These benchmarks were taken on an Apple M2 Max with 64GB running macOS Ventura.<!--BENCHMARK_END-->

## Contributing

Expand Down
7 changes: 7 additions & 0 deletions bench/font-lock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
| Command | Mean [s] | Min [s] | Max [s] | Relative |
|:---|---:|---:|---:|---:|
| `fundamental-mode` | 1.444 ± 0.174 | 1.301 | 1.734 | 1.00 ± 0.12 |
| `prog-mode` | 1.442 ± 0.039 | 1.402 | 1.488 | 1.00 |
| `jsonian-mode` | 2.296 ± 0.013 | 2.289 | 2.332 | 1.59 ± 0.04 |
| `json-mode` | 3.775 ± 0.033 | 3.762 | 3.867 | 2.62 ± 0.07 |
| `javascript-mode` | 13.599 ± 0.288 | 13.341 | 14.145 | 9.43 ± 0.32 |
30 changes: 30 additions & 0 deletions bench/font-lock.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env sh

# EMACS should be set to the binary to invoke.
# FILE should be set to the file to test against.

bench() {
echo "$EMACS -Q --batch -nw \
-L . -L ../json-mode -L ../json-snatcher -L ../json-reformat \
--eval '(setq enable-local-variables nil)' \
--eval '(setq large-file-warning-threshold nil)' \
--eval '(switch-to-buffer (find-file-literally \"$FILE\"))' \
--eval \"$1\" \
--eval '(condition-case err \
(with-current-buffer (current-buffer) \
(setq font-lock-major-mode nil) \
(syntax-ppss-flush-cache -1) \
(font-lock-set-defaults) \
(save-excursion \
(font-lock-fontify-region (point-min) (point-max)))) \
((debug error) (kill-emacs (error-message-string err))))' \
--eval '(goto-char (point-max))' \
--eval '(kill-emacs)'"
}

hyperfine --export-markdown "$EXPORT" --show-output \
--command-name "fundamental-mode" "$(bench "(fundamental-mode)")" \
--command-name "prog-mode" "$(bench "(prog-mode)")" \
--command-name "jsonian-mode" "$(bench "(progn (require 'jsonian) (jsonian-mode))")" \
--command-name "json-mode" "$(bench "(progn (require 'json-mode) (json-mode))")" \
--command-name "javascript-mode" "$(bench "(javascript-mode)")"
6 changes: 6 additions & 0 deletions bench/format.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
| Command | Mean [s] | Min [s] | Max [s] | Relative |
|:---|---:|---:|---:|---:|
| `jsonian-format-region` | 1.709 ± 0.091 | 1.633 | 1.877 | 1.12 ± 0.06 |
| `jsonian-format-region (minimize)` | 1.524 ± 0.010 | 1.516 | 1.549 | 1.00 |
| `json-pretty-print-buffer` | 4.582 ± 0.006 | 4.576 | 4.593 | 3.01 ± 0.02 |
| `json-pretty-print-buffer (minimize)` | 4.440 ± 0.114 | 4.384 | 4.753 | 2.91 ± 0.08 |
35 changes: 35 additions & 0 deletions bench/format.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env sh


COMPRESSED="$FILE.compressed.json"
jq -c . "$FILE" > "$COMPRESSED"

bench () {
echo "$EMACS -Q --batch -nw \
-L . \
--eval '(setq large-file-warning-threshold nil)' \
--eval '(switch-to-buffer (find-file-literally \"$2\"))' \
--eval \"(require 'jsonian)\" \
--eval \"$1\" \
--eval '(kill-emacs)'"
}

# Run the benchmark on the full file.
full () {
bench "$1" "$FILE"
}

# Run the benchmark on the compressed file
cmpr () {
bench "$1" "$COMPRESSED"
}

hyperfine --export-markdown "$EXPORT" --show-output \
--command-name "jsonian-format-region" "$(cmpr "(let ((inhibit-message t))\
(jsonian-format-region (point-min) (point-max)))")" \
--command-name "jsonian-format-region (minimize)" "$(full "(let ((inhibit-message t))\
(jsonian-format-region (point-min) (point-max) t))")" \
--command-name "json-pretty-print-buffer" "$(cmpr "(json-pretty-print-buffer)")" \
--command-name "json-pretty-print-buffer (minimize)" "$(full "(json-pretty-print-buffer t)")"

rm "$COMPRESSED"
53 changes: 53 additions & 0 deletions bench/markdown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@

## Benchmarks

The original reason I wrote jsonian is that I needed to read and naviage very large JSON
files, and Emacs was slowing me down. To keep jsonian fast, I maintain benchmarks of
jsonian doing real world tasks.

### `font-lock`ing a large buffer

This benchmark opens a very large (42M) JSON file, then forces Emacs to fontify it. It
finally moves point to the end of the file and exits.

| Package | Mean [s] | Min [s] | Max [s] | Relative |
|:---|---:|---:|---:|---:|
| `fundamental-mode` | 1.444 ± 0.174 | 1.301 | 1.734 | 1.00 ± 0.12 |
| `prog-mode` | 1.442 ± 0.039 | 1.402 | 1.488 | 1.00 |
| `jsonian-mode` | 2.296 ± 0.013 | 2.289 | 2.332 | 1.59 ± 0.04 |
| `json-mode` | 3.775 ± 0.033 | 3.762 | 3.867 | 2.62 ± 0.07 |
| `javascript-mode` | 13.599 ± 0.288 | 13.341 | 14.145 | 9.43 ± 0.32 |

We can use this benchmark to derive how long different parts of the proces take.

- Fundamental mode is the lower limit. This is the time Emacs spends processing the
buffer, parsing sexps, etc.

- We see that `prog-mode` doesn\'t do much more then `fundamental-mode`, which makes
sense.

- Applying JSON formatting take at most `jsonian-mode` - `prog-mode`.

- Parsing a javascript file is much more complicated (and thus expensive) then parsing a
JSON file.

### Formatting a large buffer

This tests applying formatting to a very large (42M) JSON file that is compressed to
remove all whitespace. The formatted files are largely identical.

| Package | Mean [s] | Min [s] | Max [s] | Relative |
|:---|---:|---:|---:|---:|
| `jsonian-format-region` | 1.709 ± 0.091 | 1.633 | 1.877 | 1.12 ± 0.06 |
| `jsonian-format-region (minimize)` | 1.524 ± 0.010 | 1.516 | 1.549 | 1.00 |
| `json-pretty-print-buffer` | 4.582 ± 0.006 | 4.576 | 4.593 | 3.01 ± 0.02 |
| `json-pretty-print-buffer (minimize)` | 4.440 ± 0.114 | 4.384 | 4.753 | 2.91 ± 0.08 |

We see that the built-in `json-pretty-print-buffer` takes significantly longer then our
implementation.

Notes:

1. Both `jsonian` and `json-mode` were byte-compiled for the `font-lock` benchmark.
1. Tests were run against GNU Emacs 30.0.50.
1. These benchmarks were taken on an Apple M2 Max with 64GB running macOS Ventura.
Loading