Skip to content

Commit 18b06ad

Browse files
committed
Add new uber-simple vendor/jsonx.jl for true no-dependency JSON functionality that can be directly vendored in
1 parent a86467d commit 18b06ad

File tree

9 files changed

+858
-28
lines changed

9 files changed

+858
-28
lines changed

.github/workflows/vendor-tests.yml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
name: Vendor Tests
2+
3+
on:
4+
push:
5+
paths:
6+
- 'vendor/**'
7+
pull_request:
8+
paths:
9+
- 'vendor/**'
10+
11+
jobs:
12+
test:
13+
name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ matrix.arch }}
14+
runs-on: ${{ matrix.os }}
15+
strategy:
16+
fail-fast: false
17+
matrix:
18+
version:
19+
- '1' # automatically expands to the latest stable 1.x release of Julia
20+
- 'min'
21+
- 'pre'
22+
os:
23+
- ubuntu-latest
24+
- windows-latest
25+
arch:
26+
- x64
27+
include:
28+
- os: macOS-latest
29+
arch: aarch64
30+
version: 1
31+
32+
steps:
33+
- name: Checkout code
34+
uses: actions/checkout@v4
35+
36+
- name: Setup Julia
37+
uses: julia-actions/setup-julia@v2
38+
with:
39+
version: ${{ matrix.version }}
40+
arch: ${{ matrix.arch }}
41+
42+
- name: Cache Julia packages
43+
uses: julia-actions/cache@v2
44+
45+
- name: Run vendor tests
46+
run: |
47+
cd vendor
48+
julia test.jl

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ JSON.isvalidjson(json)
7171
JSON.json("test.json", j)
7272
```
7373

74+
## Vendor Directory
75+
76+
This package includes a `vendor/` directory containing a simplified, no-dependency JSON parser (`JSONX`) that can be vendored (copied) into other projects. See the [vendor README](vendor/README.md) for details.
77+
7478
## Contributing and Questions
7579

7680
Contributions are very welcome, as are feature requests and suggestions. Please open an

src/lazy.jl

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -269,8 +269,8 @@ function applyobject(keyvalfunc, x::LazyValues)
269269
@nextbyte
270270
b == UInt8('}') && return pos + 1
271271
while true
272-
# applystring returns key as a PtrString
273-
key, pos = @inline applystring(nothing, LazyValue(buf, pos, JSONTypes.STRING, getopts(x), false))
272+
# parsestring returns key as a PtrString
273+
key, pos = @inline parsestring(LazyValue(buf, pos, JSONTypes.STRING, getopts(x), false))
274274
@nextbyte
275275
if b != UInt8(':')
276276
error = ExpectedColon
@@ -455,7 +455,7 @@ StructUtils.keyeq(x::PtrString, y::Symbol) = convert(Symbol, x) == y
455455
# or not. It allows materialize, _binary, etc. to deal
456456
# with the string data appropriately without forcing a String allocation
457457
# PtrString should NEVER be visible to users though!
458-
function applystring(f, x::LazyValue)
458+
function parsestring(x::LazyValue)
459459
buf, pos = getbuf(x), getpos(x)
460460
len, b = getlength(buf), getbyte(buf, pos)
461461
if b != UInt8('"')
@@ -483,12 +483,7 @@ function applystring(f, x::LazyValue)
483483
@nextbyte(false)
484484
end
485485
str = PtrString(pointer(buf, spos), pos - spos, escaped)
486-
if f === nothing
487-
return str, pos + 1
488-
else
489-
f(str)
490-
return pos + 1
491-
end
486+
return str, pos + 1
492487

493488
@label invalid
494489
invalid(error, buf, pos, "string")
@@ -525,7 +520,30 @@ macro check_special(special, value)
525520
end)
526521
end
527522

528-
function applynumber(valfunc, x::LazyValue)
523+
const INT = 0x00
524+
const FLOAT = 0x01
525+
const BIGINT = 0x02
526+
const BIGFLOAT = 0x03
527+
const BIG_ZERO = BigInt(0)
528+
529+
struct NumberResult
530+
tag::UInt8
531+
int::Int64
532+
float::Float64
533+
bigint::BigInt
534+
bigfloat::BigFloat
535+
NumberResult(int::Int64) = new(INT, int)
536+
NumberResult(float::Float64) = new(FLOAT, Int64(0), float)
537+
NumberResult(bigint::BigInt) = new(BIGINT, Int64(0), 0.0, bigint)
538+
NumberResult(bigfloat::BigFloat) = new(BIGFLOAT, Int64(0), 0.0, BIG_ZERO, bigfloat)
539+
end
540+
541+
isint(x::NumberResult) = x.tag == INT
542+
isfloat(x::NumberResult) = x.tag == FLOAT
543+
isbigint(x::NumberResult) = x.tag == BIGINT
544+
isbigfloat(x::NumberResult) = x.tag == BIGFLOAT
545+
546+
@inline function parsenumber(x::LazyValue)
529547
buf = getbuf(x)
530548
pos = getpos(x)
531549
len = getlength(buf)
@@ -610,23 +628,20 @@ function applynumber(valfunc, x::LazyValue)
610628
# if we overflowed, then let's try BigFloat
611629
bres = Parsers.xparse2(BigFloat, buf, startpos, len)
612630
if !Parsers.invalid(bres.code)
613-
valfunc(bres.val)
614-
return startpos + bres.tlen
631+
return NumberResult(bres.val), startpos + bres.tlen
615632
end
616633
end
617634
if Parsers.invalid(res.code)
618635
error = InvalidNumber
619636
@goto invalid
620637
end
621-
valfunc(res.val)
622-
return startpos + res.tlen
638+
return NumberResult(res.val), startpos + res.tlen
623639
else
624640
if overflow
625-
valfunc(isneg ? -bval : bval)
641+
return NumberResult(isneg ? -bval : bval), pos
626642
else
627-
valfunc(isneg ? -val : val)
643+
return NumberResult(isneg ? -val : val), pos
628644
end
629-
return pos
630645
end
631646

632647
@label invalid
@@ -643,9 +658,11 @@ function skip(x::LazyValues)
643658
elseif T == JSONTypes.ARRAY
644659
return applyarray((i, v) -> 0, x)
645660
elseif T == JSONTypes.STRING
646-
return applystring(s -> 0, x)
661+
_, pos = parsestring(x)
662+
return pos
647663
elseif T == JSONTypes.NUMBER
648-
return applynumber(n -> 0, x)
664+
_, pos = parsenumber(x)
665+
return pos
649666
elseif T == JSONTypes.TRUE
650667
return getpos(x) + 4
651668
elseif T == JSONTypes.FALSE
@@ -716,7 +733,7 @@ function Base.show(io::IO, x::LazyValue)
716733
show(io, MIME"text/plain"(), la)
717734
end
718735
elseif T == JSONTypes.STRING
719-
str, _ = applystring(nothing, x)
736+
str, _ = parsestring(x)
720737
Base.print(io, "JSON.LazyValue(", repr(convert(String, str)), ")")
721738
elseif T == JSONTypes.NULL
722739
Base.print(io, "JSON.LazyValue(nothing)")

src/parse.jl

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -266,9 +266,21 @@ function applyvalue(f, x::LazyValues, null)
266266
f(arr)
267267
return pos
268268
elseif type == JSONTypes.STRING
269-
return applystring(s -> f(convert(String, s)), x)
269+
str, pos = parsestring(x)
270+
f(convert(String, str))
271+
return pos
270272
elseif type == JSONTypes.NUMBER
271-
return applynumber(f, x)
273+
num, pos = parsenumber(x)
274+
if isint(num)
275+
f(num.int)
276+
elseif isfloat(num)
277+
f(num.float)
278+
elseif isbigint(num)
279+
f(num.bigint)
280+
else
281+
f(num.bigfloat)
282+
end
283+
return pos
272284
elseif type == JSONTypes.NULL
273285
f(null)
274286
return getpos(x) + 4
@@ -312,15 +324,30 @@ StructUtils.liftkey(st::StructStyle, ::Type{T}, x::PtrString) where {T} =
312324
StructUtils.liftkey(st, T, convert(String, x))
313325
StructUtils.lift(f, st::StructStyle, ::Type{T}, x::PtrString, tags) where {T} =
314326
StructUtils.lift(f, st, T, convert(String, x), tags)
327+
StructUtils.lift(st::StructStyle, ::Type{T}, x::PtrString, tags) where {T} =
328+
StructUtils.lift(st, T, convert(String, x), tags)
315329
StructUtils.make!(f, st::StructStyle, ::Type{T}, x::PtrString, tags) where {T} =
316330
StructUtils.make!(f, st, T, convert(String, x), tags)
317331

318332
function StructUtils.lift(f, style::StructStyle, ::Type{T}, x::LazyValues, tags) where {T}
319333
type = gettype(x)
320334
if type == JSONTypes.STRING
321-
return applystring(s -> StructUtils.lift(f, style, T, s, tags), x)
335+
ptrstr, pos = parsestring(x)
336+
val = StructUtils.lift(style, T, ptrstr, tags)
337+
f(val)
338+
return pos
322339
elseif type == JSONTypes.NUMBER
323-
return applynumber(x -> StructUtils.lift(f, style, T, x, tags), x)
340+
num, pos = parsenumber(x)
341+
if isint(num)
342+
StructUtils.lift(f, style, T, num.int, tags)
343+
elseif isfloat(num)
344+
StructUtils.lift(f, style, T, num.float, tags)
345+
elseif isbigint(num)
346+
StructUtils.lift(f, style, T, num.bigint, tags)
347+
else
348+
StructUtils.lift(f, style, T, num.bigfloat, tags)
349+
end
350+
return pos
324351
elseif type == JSONTypes.NULL
325352
StructUtils.lift(f, style, T, nullvalue(style), tags)
326353
return getpos(x) + 4

src/utils.jl

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,6 @@ module JSONTypes
3838
Base.show(io::IO, x::T) = Base.print(io, "JSONTypes.", names[x])
3939
end
4040

41-
42-
4341
isjsonl(filename) = endswith(filename, ".jsonl") || endswith(filename, ".ndjson")
4442

4543
getlength(buf::AbstractVector{UInt8}) = length(buf)

test/lazy.jl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,9 @@ end
6767
# error cases
6868
x = JSON.lazy("{}")
6969
@test_throws ArgumentError JSON.applyarray((i, v) -> nothing, x)
70-
@test_throws ArgumentError JSON.applystring(nothing, x)
70+
@test_throws ArgumentError JSON.parsestring(x)
7171
x = JSON.lazy("{}"; allownan=true)
72-
@test_throws ArgumentError JSON.applynumber(x -> nothing, x)
72+
@test_throws ArgumentError JSON.parsenumber(x)
7373

7474
# lazy indexing selection support
7575
# examples from https://support.smartbear.com/alertsite/docs/monitors/api/endpoint/jsonpath.html

vendor/README.md

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# JSONX - Simple JSON Parser
2+
3+
A simple, no-dependency JSON parser that can be vendored (copied/pasted) into other packages.
4+
5+
## Features
6+
7+
### Parsing
8+
- `JSONX.parse(json_str::String)` - Parse a JSON string
9+
- `JSONX.parse(bytes::AbstractVector{UInt8})` - Parse JSON from byte array
10+
- `JSONX.parsefile(filename::String)` - Parse JSON from a file
11+
12+
### Writing
13+
- `JSONX.json(value)` - Convert a Julia value to JSON string
14+
15+
### Supported Types
16+
17+
**Reading (JSON → Julia):**
18+
- `null``nothing`
19+
- `true`/`false``Bool`
20+
- Numbers → `Float64` (all numbers are parsed as Float64)
21+
- Strings → `String` (with full Unicode support)
22+
- Arrays → `Vector{Any}`
23+
- Objects → `Dict{String, Any}`
24+
25+
**Writing (Julia → JSON):**
26+
- `nothing`/`missing``null`
27+
- `Bool``true`/`false`
28+
- `Number` → JSON number
29+
- `AbstractString` → JSON string
30+
- `AbstractVector`/`AbstractSet`/`Tuple` → JSON array
31+
- `AbstractDict`/`NamedTuple` → JSON object
32+
- `Symbol`/`Enum` → JSON string
33+
34+
### Unicode Support
35+
36+
JSONX includes full Unicode support:
37+
- Proper Unicode escape sequence parsing (`\uXXXX`)
38+
- UTF-16 surrogate pair handling
39+
- Lone surrogate handling
40+
- All standard JSON escape sequences (`\"`, `\\`, `\/`, `\b`, `\f`, `\n`, `\r`, `\t`)
41+
42+
## Usage
43+
44+
```julia
45+
using JSONX
46+
47+
# Parse JSON
48+
data = JSONX.parse("{\"name\":\"John\",\"age\":30}")
49+
# Returns: Dict("name" => "John", "age" => 30.0)
50+
51+
# Parse from bytes
52+
bytes = Vector{UInt8}("{\"key\":\"value\"}")
53+
data = JSONX.parse(bytes)
54+
55+
# Parse from file
56+
data = JSONX.parsefile("data.json")
57+
58+
# Write JSON
59+
json_str = JSONX.json(Dict("a" => 1, "b" => 2))
60+
# Returns: "{\"a\":1,\"b\":2}"
61+
62+
# Unicode examples
63+
JSONX.parse("\"Hello 世界! 🌍\"") # Full Unicode support
64+
JSONX.parse("\"\\u0048\\u0065\\u006C\\u006C\\u006F\"") # Unicode escapes
65+
```
66+
67+
## Error Handling
68+
69+
JSONX provides detailed error messages for invalid JSON:
70+
- Unexpected end of input
71+
- Invalid escape sequences
72+
- Malformed Unicode escapes
73+
- Trailing commas
74+
- Control characters in strings
75+
- Invalid number formats
76+
77+
## Limitations
78+
79+
Compared to the full JSON.jl package, JSONX is intentionally simplified:
80+
81+
- **No integer parsing**: All numbers are parsed as Float64
82+
- **No custom type parsing**: Only returns basic Julia types
83+
- **No configuration options**: Uses fixed defaults
84+
- **No streaming**: Loads entire input into memory
85+
- **No pretty printing**: Output is compact only
86+
- **No schema validation**: Basic JSON validation only
87+
- **No performance optimizations**: Simple, readable implementation
88+
89+
## Implementation Notes
90+
91+
- **No dependencies**: Uses only Base Julia functionality
92+
- **Byte-level processing**: Uses `codeunit` for accurate string handling
93+
- **Memory efficient**: Avoids unnecessary string concatenation
94+
- **Error robust**: Comprehensive error checking and reporting
95+
96+
Note: Functions are not exported, so use `JSONX.parse` and `JSONX.json` with the module prefix.

0 commit comments

Comments
 (0)