├── .gitignore ├── gleam.toml ├── .github └── workflows │ └── test.yml ├── manifest.toml ├── README.md ├── src ├── gleam_json_ffi.erl ├── gleam_json_ffi.mjs └── gleam │ └── json.gleam ├── CHANGELOG.md ├── test ├── gleam_json_js_ffi_test.gleam └── gleam_json_test.gleam └── LICENCE /.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.iml 3 | *.o 4 | *.plt 5 | *.swo 6 | *.swp 7 | *~ 8 | .erlang.cookie 9 | .eunit 10 | .idea 11 | .rebar 12 | .rebar3 13 | _* 14 | _build 15 | docs 16 | ebin 17 | erl_crash.dump 18 | gen 19 | log 20 | logs 21 | rebar3.crashdump 22 | build 23 | -------------------------------------------------------------------------------- /gleam.toml: -------------------------------------------------------------------------------- 1 | name = "gleam_json" 2 | version = "3.1.0" 3 | gleam = ">= 1.13.0" 4 | 5 | licences = ["Apache-2.0"] 6 | description = "Work with JSON in Gleam" 7 | 8 | repository = { type = "github", user = "gleam-lang", repo = "json" } 9 | links = [ 10 | { title = "Website", href = "https://gleam.run" }, 11 | { title = "Sponsor", href = "https://github.com/sponsors/lpil" }, 12 | ] 13 | 14 | [dependencies] 15 | gleam_stdlib = ">= 0.51.0 and < 2.0.0" 16 | 17 | [dev-dependencies] 18 | gleeunit = ">= 1.2.0 and < 2.0.0" 19 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: erlef/setup-beam@v1 15 | with: 16 | otp-version: "27" 17 | gleam-version: "1.13.0" 18 | rebar3-version: "3" 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: '18.x' 22 | - run: gleam test 23 | - run: gleam test --target javascript 24 | - run: gleam format --check src test 25 | -------------------------------------------------------------------------------- /manifest.toml: -------------------------------------------------------------------------------- 1 | # This file was generated by Gleam 2 | # You typically do not need to edit this file 3 | 4 | packages = [ 5 | { name = "gleam_stdlib", version = "0.60.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "621D600BB134BC239CB2537630899817B1A42E60A1D46C5E9F3FAE39F88C800B" }, 6 | { name = "gleeunit", version = "1.3.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "A7DD6C07B7DA49A6E28796058AA89E651D233B357D5607006D70619CD89DAAAB" }, 7 | ] 8 | 9 | [requirements] 10 | gleam_stdlib = { version = ">= 0.51.0 and < 2.0.0" } 11 | gleeunit = { version = ">= 1.2.0 and < 2.0.0" } 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # json 🐑 2 | 3 | Work with JSON in Gleam! 4 | 5 | ## Installation 6 | 7 | ```shell 8 | gleam add gleam_json@3 9 | ``` 10 | 11 | ## Encoding 12 | 13 | ```gleam 14 | import myapp.{type Cat} 15 | import gleam/json 16 | 17 | pub fn cat_to_json(cat: Cat) -> String { 18 | json.object([ 19 | #("name", json.string(cat.name)), 20 | #("lives", json.int(cat.lives)), 21 | #("flaws", json.null()), 22 | #("nicknames", json.array(cat.nicknames, of: json.string)), 23 | ]) 24 | |> json.to_string 25 | } 26 | ``` 27 | 28 | ## Parsing 29 | 30 | JSON is parsed into a `Dynamic` value which can be decoded using the 31 | `gleam/dynamic/decode` module from the Gleam standard library. 32 | 33 | ```gleam 34 | import myapp.{Cat} 35 | import gleam/json 36 | import gleam/dynamic/decode 37 | 38 | pub fn cat_from_json(json_string: String) -> Result(Cat, json.DecodeError) { 39 | let cat_decoder = { 40 | use name <- decode.field("name", decode.string) 41 | use lives <- decode.field("lives", decode.int) 42 | use nicknames <- decode.field("nicknames", decode.list(decode.string)) 43 | decode.success(Cat(name:, lives:, nicknames:)) 44 | } 45 | json.parse(from: json_string, using: cat_decoder) 46 | } 47 | ``` 48 | -------------------------------------------------------------------------------- /src/gleam_json_ffi.erl: -------------------------------------------------------------------------------- 1 | -module(gleam_json_ffi). 2 | 3 | -export([ 4 | decode/1, json_to_iodata/1, json_to_string/1, int/1, float/1, string/1, 5 | bool/1, null/0, array/1, object/1 6 | ]). 7 | 8 | -if(?OTP_RELEASE < 27). 9 | -define(bad_version, 10 | error({erlang_otp_27_required, << "Insufficient Erlang/OTP version. 11 | 12 | `gleam_json` uses the Erlang `json` module introduced in Erlang/OTP 27. 13 | You are using Erlang/OTP "/utf8, (integer_to_binary(?OTP_RELEASE))/binary, " 14 | Please upgrade your Erlang install or downgrade to `gleam_json` v1.0.1. 15 | "/utf8>>})). 16 | 17 | decode(_) -> ?bad_version. 18 | json_to_iodata(_) -> ?bad_version. 19 | json_to_string(_) -> ?bad_version. 20 | int(_) -> ?bad_version. 21 | float(_) -> ?bad_version. 22 | string(_) -> ?bad_version. 23 | bool(_) -> ?bad_version. 24 | array(_) -> ?bad_version. 25 | object(_) -> ?bad_version. 26 | null() -> ?bad_version. 27 | -else. 28 | 29 | decode(Json) -> 30 | try 31 | {ok, json:decode(Json)} 32 | catch 33 | error:unexpected_end -> {error, unexpected_end_of_input}; 34 | error:{invalid_byte, Byte} -> {error, {unexpected_byte, hex(Byte)}}; 35 | error:{unexpected_sequence, Byte} -> {error, {unexpected_sequence, Byte}} 36 | end. 37 | 38 | hex(I) -> 39 | H = list_to_binary(integer_to_list(I, 16)), 40 | <<"0x"/utf8, H/binary>>. 41 | 42 | json_to_iodata(Json) -> 43 | Json. 44 | 45 | json_to_string(Json) when is_binary(Json) -> 46 | Json; 47 | json_to_string(Json) when is_list(Json) -> 48 | list_to_binary(Json). 49 | 50 | null() -> <<"null">>. 51 | bool(true) -> <<"true">>; 52 | bool(false) -> <<"false">>. 53 | int(X) -> json:encode_integer(X). 54 | float(X) -> json:encode_float(X). 55 | string(X) -> json:encode_binary(X). 56 | 57 | array([]) -> <<"[]">>; 58 | array([First | Rest]) -> [$[, First | array_loop(Rest)]. 59 | array_loop([]) -> "]"; 60 | array_loop([Elem | Rest]) -> [$,, Elem | array_loop(Rest)]. 61 | 62 | object(List) -> encode_object([[$,, string(Key), $: | Value] || {Key, Value} <- List]). 63 | encode_object([]) -> <<"{}">>; 64 | encode_object([[_Comma | Entry] | Rest]) -> ["{", Entry, Rest, "}"]. 65 | 66 | -endif. 67 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v3.1.0 - 2025-11-08 4 | 5 | - Updated for Gleam v1.13.0. 6 | 7 | ## v3.0.2 - 2025-07-01 8 | 9 | - Updated for lastest `gleam_stdlib`. 10 | 11 | ## v3.0.1 - 2025-05-14 12 | 13 | - The `to_string_builder` function has been removed. 14 | 15 | ## v3.0.0 - 2025-05-13 16 | 17 | - The `decode` and `decode_bits` functions, and the `UnexpectedFormat` variant, 18 | relying on the deprecated `gleam/dynamic` API have been removed from the 19 | `json` module. 20 | 21 | ## v2.3.0 - 2025-01-04 22 | 23 | - Add `dict` function 24 | 25 | ## v2.2.0 - 2024-11-22 26 | 27 | - `parse`, `parse_bits`, and `UnableToDecode` have been added to work with the 28 | new `gleam/dynamic/decode` API. 29 | 30 | ## v2.1.0 - 2024-11-19 31 | 32 | - `to_string_builder` has been deprecated in favour of `to_string_tree`. 33 | 34 | ## v2.0.0 - 2024-05-23 35 | 36 | - The Thoas Erlang library has been removed as a dependency in favour of the 37 | built-in Erlang JSON library. This means that Erlang/OTP 27.0 or higher is now 38 | required. 39 | - The `UnexpectedSequence` and `UnexpectedByte` error variants no longer have 40 | labels for their arguments. 41 | 42 | ## v1.0.1 - 2024-04-25 43 | 44 | - Relaxed Thoas version requirement. 45 | 46 | ## v1.0.0 - 2024-01-16 47 | 48 | - Relaxed stdlib version requirement. 49 | 50 | ## v0.7.0 - 2023-11-05 51 | 52 | - Updated for Gleam v0.32.0. 53 | 54 | ## v0.6.0 - 2023-08-03 55 | 56 | - Updated error parsing for current V8 runtimes. 57 | - Updated for Gleam v0.30.0. 58 | 59 | ## v0.5.1 - 2023-03-02 60 | 61 | - Updated for Gleam v0.27.0. 62 | 63 | ## v0.5.0 - 2022-06-11 64 | 65 | - This library now works when running Gleam on JavaScript. 66 | 67 | ## v0.4.0 - 2022-02-05 68 | 69 | - The `decode_bits` function has been added. 70 | 71 | ## v0.3.0 - 2022-01-09 72 | 73 | - The `decode` function now takes a `gleam/dynamic.Decoder`. 74 | 75 | ## v0.2.0 - 2022-01-01 76 | 77 | - Converted to use the Gleam build tool. 78 | - The underlying `jsone` Erlang JSON libary has been replaced with the new 79 | `thoas` Erlang JSON library. 80 | - The `encode` function has been replaced by the `to_string` and 81 | `to_string_builder` functions. 82 | - The `list` function has been replaced by the `array` and `preprocessed_array` 83 | functions. 84 | - The `nullable` function gains argument labels. 85 | - The `float` function has been added. 86 | 87 | ## v0.1.0 - 2020-07-30 88 | 89 | - Initial release. 90 | -------------------------------------------------------------------------------- /test/gleam_json_js_ffi_test.gleam: -------------------------------------------------------------------------------- 1 | @target(javascript) 2 | import gleam/json.{type DecodeError, UnexpectedByte, UnexpectedEndOfInput} 3 | @target(javascript) 4 | import gleeunit/should 5 | 6 | @target(javascript) 7 | type StandardError { 8 | StandardError(message: String) 9 | } 10 | 11 | // === End of input tests === // 12 | @target(javascript) 13 | pub fn chromium_end_of_input_test() { 14 | "Unexpected end of JSON input" 15 | |> StandardError 16 | |> get_json_decode_error("") 17 | |> should.equal(UnexpectedEndOfInput) 18 | } 19 | 20 | @target(javascript) 21 | pub fn spidermonkey_end_of_input_test() { 22 | "JSON.parse: unexpected end of data at line 1 column 1 of the JSON data" 23 | |> StandardError 24 | |> get_json_decode_error("") 25 | |> should.equal(UnexpectedEndOfInput) 26 | } 27 | 28 | @target(javascript) 29 | pub fn javascript_core_end_of_input_test() { 30 | "JSON Parse error: Unexpected EOF" 31 | |> StandardError 32 | |> get_json_decode_error("") 33 | |> should.equal(UnexpectedEndOfInput) 34 | } 35 | 36 | // === Unexpected byte tests === // 37 | @target(javascript) 38 | pub fn chromium_unexpected_byte_test() { 39 | "Unexpected token a in JSON at position 5" 40 | |> StandardError 41 | |> get_json_decode_error("{\"b\":a}") 42 | |> should.equal(UnexpectedByte("0x61")) 43 | } 44 | 45 | @target(javascript) 46 | pub fn spidermonkey_unexpected_byte_test() { 47 | "JSON.parse: expected property name or '}' at line 1 column 6 of the JSON data" 48 | |> StandardError 49 | |> get_json_decode_error("{\"b\":a}") 50 | |> should.equal(UnexpectedByte("0x61")) 51 | } 52 | 53 | @target(javascript) 54 | pub fn javascript_core_unexpected_byte_test() { 55 | "JSON Parse error: Unexpected identifier \"a\"" 56 | |> StandardError 57 | |> get_json_decode_error("{\"b\":a}") 58 | |> should.equal(UnexpectedByte("0x61")) 59 | } 60 | 61 | @target(javascript) 62 | pub fn spidermonkey_multiline_unexpected_byte_test() { 63 | "JSON.parse: expected property name or '}' at line 2 column 6 of the JSON data" 64 | |> StandardError 65 | |> get_json_decode_error("{\n\"b\": a\n}") 66 | |> should.equal(UnexpectedByte("0x61")) 67 | 68 | "JSON.parse: expected double-quoted property name at line 3 column 1 of the JSON data" 69 | |> StandardError 70 | |> get_json_decode_error("{\n\"b\": \"x\",\na\n}") 71 | |> should.equal(UnexpectedByte("0x61")) 72 | } 73 | 74 | @target(javascript) 75 | @external(javascript, "./gleam_json_ffi.mjs", "getJsonDecodeError") 76 | fn get_json_decode_error(a: StandardError, b: String) -> DecodeError 77 | -------------------------------------------------------------------------------- /test/gleam_json_test.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dict 2 | import gleam/dynamic/decode 3 | import gleam/int 4 | import gleam/json.{type Json} 5 | import gleam/option.{None, Some} 6 | import gleam/string 7 | import gleam/string_tree 8 | import gleeunit 9 | import gleeunit/should 10 | 11 | pub fn main() { 12 | gleeunit.main() 13 | } 14 | 15 | @target(erlang) 16 | const list_found = "List" 17 | 18 | @target(javascript) 19 | const list_found = "Array" 20 | 21 | pub fn parse_test() { 22 | json.parse(from: "5", using: decode.int) 23 | |> should.equal(Ok(5)) 24 | } 25 | 26 | pub fn parse_empty_test() { 27 | json.parse(from: "", using: decode.int) 28 | |> should.equal(Error(json.UnexpectedEndOfInput)) 29 | } 30 | 31 | pub fn parse_unexpected_byte_test() { 32 | let assert Error(error) = json.parse(from: "[}", using: decode.int) 33 | let assert json.UnexpectedByte(byte) = error 34 | let assert "0x7D" = byte 35 | } 36 | 37 | pub fn parse_unexpected_format_test() { 38 | json.parse(from: "[]", using: decode.int) 39 | |> should.equal( 40 | Error( 41 | json.UnableToDecode([ 42 | decode.DecodeError(expected: "Int", found: list_found, path: []), 43 | ]), 44 | ), 45 | ) 46 | } 47 | 48 | pub fn parse_unable_to_decode_test() { 49 | json.parse(from: "[]", using: decode.int) 50 | |> should.equal( 51 | Error( 52 | json.UnableToDecode([ 53 | decode.DecodeError(expected: "Int", found: list_found, path: []), 54 | ]), 55 | ), 56 | ) 57 | } 58 | 59 | pub fn parse_bits_test() { 60 | json.parse_bits(from: <<"5":utf8>>, using: decode.int) 61 | |> should.equal(Ok(5)) 62 | } 63 | 64 | pub fn parse_bits_empty_test() { 65 | json.parse_bits(from: <<"":utf8>>, using: decode.int) 66 | |> should.equal(Error(json.UnexpectedEndOfInput)) 67 | } 68 | 69 | pub fn parse_bits_unexpected_byte_test() { 70 | let assert Error(error) = json.parse(from: "[}", using: decode.int) 71 | let assert json.UnexpectedByte(byte) = error 72 | let assert "0x7D" = byte 73 | } 74 | 75 | pub fn parse_bits_unexpected_format_test() { 76 | json.parse_bits(from: <<"[]":utf8>>, using: decode.int) 77 | |> should.equal( 78 | Error( 79 | json.UnableToDecode([ 80 | decode.DecodeError(expected: "Int", found: list_found, path: []), 81 | ]), 82 | ), 83 | ) 84 | } 85 | 86 | pub fn parse_unexpected_sequence_test() { 87 | let assert Error(error) = json.parse(from: "\"\\uxxxx\"", using: decode.float) 88 | case error { 89 | json.UnexpectedSequence("\\uxxxx") -> Nil 90 | json.UnexpectedByte("0x78") -> Nil 91 | _ -> panic as { "unexpected error: " <> string.inspect(error) } 92 | } 93 | } 94 | 95 | pub fn encode_string_test() { 96 | json.string("hello") 97 | |> should_encode("\"hello\"") 98 | } 99 | 100 | pub fn encode_null_test() { 101 | json.null() 102 | |> should_encode("null") 103 | } 104 | 105 | pub fn encode_int_test() { 106 | json.int(-50) 107 | |> should_encode("-50") 108 | 109 | json.int(100) 110 | |> should_encode("100") 111 | } 112 | 113 | pub fn encode_float_test() { 114 | json.float(-50.5) 115 | |> should_encode("-50.5") 116 | 117 | json.float(100.1) 118 | |> should_encode("100.1") 119 | } 120 | 121 | pub fn encode_object_test() { 122 | json.object([#("foo", json.int(5))]) 123 | |> should_encode("{\"foo\":5}") 124 | } 125 | 126 | pub fn encode_empty_object_test() { 127 | json.object([]) 128 | |> should_encode("{}") 129 | } 130 | 131 | pub fn encode_empty_array_test() { 132 | [] 133 | |> json.array(of: json.int) 134 | |> should_encode("[]") 135 | } 136 | 137 | pub fn encode_array_test() { 138 | [5, 6, 1, 4] 139 | |> json.array(of: json.int) 140 | |> should_encode("[5,6,1,4]") 141 | } 142 | 143 | pub fn encode_preprocessed_array_test() { 144 | json.preprocessed_array([json.int(5), json.int(6)]) 145 | |> should_encode("[5,6]") 146 | } 147 | 148 | pub fn encode_empty_preprocessed_array_test() { 149 | json.preprocessed_array([]) 150 | |> should_encode("[]") 151 | } 152 | 153 | pub fn encode_nullable_some_test() { 154 | json.nullable(Some(5), of: json.int) 155 | |> should_encode("5") 156 | } 157 | 158 | pub fn encode_nullable_none_test() { 159 | json.nullable(None, of: json.int) 160 | |> should_encode("null") 161 | } 162 | 163 | pub fn encode_bool_true_test() { 164 | json.bool(True) 165 | |> should_encode("true") 166 | } 167 | 168 | pub fn encode_bool_false_test() { 169 | json.bool(False) 170 | |> should_encode("false") 171 | } 172 | 173 | pub fn encode_dict_test() { 174 | json.dict(dict.from_list([#(3, 3)]), int.to_string, json.int) 175 | |> should_encode("{\"3\":3}") 176 | } 177 | 178 | fn should_encode(data: Json, expected: String) { 179 | data 180 | |> json.to_string() 181 | |> should.equal(expected) 182 | 183 | data 184 | |> json.to_string_tree 185 | |> string_tree.to_string 186 | |> should.equal(json.to_string(data)) 187 | } 188 | -------------------------------------------------------------------------------- /src/gleam_json_ffi.mjs: -------------------------------------------------------------------------------- 1 | import { 2 | Result$Ok, 3 | Result$Error, 4 | List$isNonEmpty, 5 | List$NonEmpty$first, 6 | List$NonEmpty$rest, 7 | } from "./gleam.mjs"; 8 | import { 9 | DecodeError$UnexpectedByte, 10 | DecodeError$UnexpectedEndOfInput, 11 | } from "./gleam/json.mjs"; 12 | 13 | export function json_to_string(json) { 14 | return JSON.stringify(json); 15 | } 16 | 17 | export function object(entries) { 18 | return Object.fromEntries(entries); 19 | } 20 | 21 | export function identity(x) { 22 | return x; 23 | } 24 | 25 | export function array(list) { 26 | const array = []; 27 | while (List$isNonEmpty(list)) { 28 | array.push(List$NonEmpty$first(list)); 29 | list = List$NonEmpty$rest(list); 30 | } 31 | return array; 32 | } 33 | 34 | export function do_null() { 35 | return null; 36 | } 37 | 38 | export function decode(string) { 39 | try { 40 | const result = JSON.parse(string); 41 | return Result$Ok(result); 42 | } catch (err) { 43 | return Result$Error(getJsonDecodeError(err, string)); 44 | } 45 | } 46 | 47 | export function getJsonDecodeError(stdErr, json) { 48 | if (isUnexpectedEndOfInput(stdErr)) return DecodeError$UnexpectedEndOfInput(); 49 | return toUnexpectedByteError(stdErr, json); 50 | } 51 | 52 | /** 53 | * Matches unexpected end of input messages in: 54 | * - Chromium (edge, chrome, node) 55 | * - Spidermonkey (firefox) 56 | * - JavascriptCore (safari) 57 | * 58 | * Note that Spidermonkey and JavascriptCore will both incorrectly report some 59 | * UnexpectedByte errors as UnexpectedEndOfInput errors. For example: 60 | * 61 | * @example 62 | * // in JavascriptCore 63 | * JSON.parse('{"a"]: "b"}) 64 | * // => JSON Parse error: Expected ':' before value 65 | * 66 | * JSON.parse('{"a"') 67 | * // => JSON Parse error: Expected ':' before value 68 | * 69 | * // in Chromium (correct) 70 | * JSON.parse('{"a"]: "b"}) 71 | * // => Unexpected token ] in JSON at position 4 72 | * 73 | * JSON.parse('{"a"') 74 | * // => Unexpected end of JSON input 75 | */ 76 | function isUnexpectedEndOfInput(err) { 77 | const unexpectedEndOfInputRegex = 78 | /((unexpected (end|eof))|(end of data)|(unterminated string)|(json( parse error|\.parse)\: expected '(\:|\}|\])'))/i; 79 | return unexpectedEndOfInputRegex.test(err.message); 80 | } 81 | 82 | /** 83 | * Converts a SyntaxError to an UnexpectedByte error based on the JS runtime. 84 | * 85 | * For Chromium, the unexpected byte and position are reported by the runtime. 86 | * 87 | * For JavascriptCore, only the unexpected byte is reported by the runtime, so 88 | * there is no way to know which position that character is in unless we then 89 | * parse the string again ourselves. So instead, the position is reported as 0. 90 | * 91 | * For Spidermonkey, the position is reported by the runtime as a line and column number 92 | * and the unexpected byte is found using those coordinates. 93 | */ 94 | function toUnexpectedByteError(err, json) { 95 | let converters = [ 96 | v8UnexpectedByteError, 97 | oldV8UnexpectedByteError, 98 | jsCoreUnexpectedByteError, 99 | spidermonkeyUnexpectedByteError, 100 | ]; 101 | 102 | for (let converter of converters) { 103 | let result = converter(err, json); 104 | if (result) return result; 105 | } 106 | 107 | return DecodeError$UnexpectedByte(""); 108 | } 109 | 110 | /** 111 | * Matches unexpected byte messages in: 112 | * - V8 (edge, chrome, node) 113 | * 114 | * Matches the character but not the position as this is no longer reported by 115 | * V8. Boo! 116 | */ 117 | function v8UnexpectedByteError(err) { 118 | const regex = /unexpected token '(.)', ".+" is not valid JSON/i; 119 | const match = regex.exec(err.message); 120 | if (!match) return null; 121 | const byte = toHex(match[1]); 122 | return DecodeError$UnexpectedByte(byte); 123 | } 124 | 125 | /** 126 | * Matches unexpected byte messages in: 127 | * - V8 (edge, chrome, node) 128 | * 129 | * No longer works in current versions of V8. 130 | * 131 | * Matches the character and its position. 132 | */ 133 | function oldV8UnexpectedByteError(err) { 134 | const regex = /unexpected token (.) in JSON at position (\d+)/i; 135 | const match = regex.exec(err.message); 136 | if (!match) return null; 137 | const byte = toHex(match[1]); 138 | return DecodeError$UnexpectedByte(byte); 139 | } 140 | 141 | /** 142 | * Matches unexpected byte messages in: 143 | * - Spidermonkey (firefox) 144 | * 145 | * Matches the position in a 2d grid only and not the character. 146 | */ 147 | function spidermonkeyUnexpectedByteError(err, json) { 148 | const regex = 149 | /(unexpected character|expected .*) at line (\d+) column (\d+)/i; 150 | const match = regex.exec(err.message); 151 | if (!match) return null; 152 | const line = Number(match[2]); 153 | const column = Number(match[3]); 154 | const position = getPositionFromMultiline(line, column, json); 155 | const byte = toHex(json[position]); 156 | return DecodeError$UnexpectedByte(byte); 157 | } 158 | 159 | /** 160 | * Matches unexpected byte messages in: 161 | * - JavascriptCore (safari) 162 | * 163 | * JavascriptCore only reports what the character is and not its position. 164 | */ 165 | function jsCoreUnexpectedByteError(err) { 166 | const regex = /unexpected (identifier|token) "(.)"/i; 167 | const match = regex.exec(err.message); 168 | if (!match) return null; 169 | const byte = toHex(match[2]); 170 | return DecodeError$UnexpectedByte(byte); 171 | } 172 | 173 | function toHex(char) { 174 | return "0x" + char.charCodeAt(0).toString(16).toUpperCase(); 175 | } 176 | 177 | /** 178 | * Gets the position of a character in a flattened (i.e. single line) string 179 | * from a line and column number. Note that the position is 0-indexed and 180 | * the line and column numbers are 1-indexed. 181 | * 182 | * @param {number} line 183 | * @param {number} column 184 | * @param {string} string 185 | */ 186 | function getPositionFromMultiline(line, column, string) { 187 | if (line === 1) return column - 1; 188 | 189 | let currentLn = 1; 190 | let position = 0; 191 | string.split("").find((char, idx) => { 192 | if (char === "\n") currentLn += 1; 193 | if (currentLn === line) { 194 | position = idx + column; 195 | return true; 196 | } 197 | return false; 198 | }); 199 | 200 | return position; 201 | } 202 | -------------------------------------------------------------------------------- /src/gleam/json.gleam: -------------------------------------------------------------------------------- 1 | import gleam/bit_array 2 | import gleam/dict.{type Dict} 3 | import gleam/dynamic.{type Dynamic} 4 | import gleam/dynamic/decode 5 | import gleam/list 6 | import gleam/option.{type Option, None, Some} 7 | import gleam/result 8 | import gleam/string_tree.{type StringTree} 9 | 10 | pub type Json 11 | 12 | pub type DecodeError { 13 | UnexpectedEndOfInput 14 | UnexpectedByte(String) 15 | UnexpectedSequence(String) 16 | UnableToDecode(List(decode.DecodeError)) 17 | } 18 | 19 | /// Decode a JSON string into dynamically typed data which can be decoded into 20 | /// typed data with the `gleam/dynamic` module. 21 | /// 22 | /// ## Examples 23 | /// 24 | /// ```gleam 25 | /// > parse("[1,2,3]", decode.list(of: decode.int)) 26 | /// Ok([1, 2, 3]) 27 | /// ``` 28 | /// 29 | /// ```gleam 30 | /// > parse("[", decode.list(of: decode.int)) 31 | /// Error(UnexpectedEndOfInput) 32 | /// ``` 33 | /// 34 | /// ```gleam 35 | /// > parse("1", decode.string) 36 | /// Error(UnableToDecode([decode.DecodeError("String", "Int", [])])) 37 | /// ``` 38 | /// 39 | pub fn parse( 40 | from json: String, 41 | using decoder: decode.Decoder(t), 42 | ) -> Result(t, DecodeError) { 43 | do_parse(from: json, using: decoder) 44 | } 45 | 46 | @target(erlang) 47 | fn do_parse( 48 | from json: String, 49 | using decoder: decode.Decoder(t), 50 | ) -> Result(t, DecodeError) { 51 | let bits = bit_array.from_string(json) 52 | parse_bits(bits, decoder) 53 | } 54 | 55 | @target(javascript) 56 | fn do_parse( 57 | from json: String, 58 | using decoder: decode.Decoder(t), 59 | ) -> Result(t, DecodeError) { 60 | use dynamic_value <- result.try(decode_string(json)) 61 | decode.run(dynamic_value, decoder) 62 | |> result.map_error(UnableToDecode) 63 | } 64 | 65 | @external(javascript, "../gleam_json_ffi.mjs", "decode") 66 | fn decode_string(a: String) -> Result(Dynamic, DecodeError) 67 | 68 | /// Decode a JSON bit string into dynamically typed data which can be decoded 69 | /// into typed data with the `gleam/dynamic` module. 70 | /// 71 | /// ## Examples 72 | /// 73 | /// ```gleam 74 | /// > parse_bits(<<"[1,2,3]">>, decode.list(of: decode.int)) 75 | /// Ok([1, 2, 3]) 76 | /// ``` 77 | /// 78 | /// ```gleam 79 | /// > parse_bits(<<"[">>, decode.list(of: decode.int)) 80 | /// Error(UnexpectedEndOfInput) 81 | /// ``` 82 | /// 83 | /// ```gleam 84 | /// > parse_bits(<<"1">>, decode.string) 85 | /// Error(UnableToDecode([decode.DecodeError("String", "Int", [])])), 86 | /// ``` 87 | /// 88 | pub fn parse_bits( 89 | from json: BitArray, 90 | using decoder: decode.Decoder(t), 91 | ) -> Result(t, DecodeError) { 92 | use dynamic_value <- result.try(decode_to_dynamic(json)) 93 | decode.run(dynamic_value, decoder) 94 | |> result.map_error(UnableToDecode) 95 | } 96 | 97 | @external(erlang, "gleam_json_ffi", "decode") 98 | fn decode_to_dynamic(json: BitArray) -> Result(Dynamic, DecodeError) { 99 | case bit_array.to_string(json) { 100 | Ok(string) -> decode_string(string) 101 | Error(Nil) -> Error(UnexpectedByte("")) 102 | } 103 | } 104 | 105 | /// Convert a JSON value into a string. 106 | /// 107 | /// Where possible prefer the `to_string_tree` function as it is faster than 108 | /// this function, and BEAM VM IO is optimised for sending `StringTree` data. 109 | /// 110 | /// ## Examples 111 | /// 112 | /// ```gleam 113 | /// > to_string(array([1, 2, 3], of: int)) 114 | /// "[1,2,3]" 115 | /// ``` 116 | /// 117 | pub fn to_string(json: Json) -> String { 118 | do_to_string(json) 119 | } 120 | 121 | @external(erlang, "gleam_json_ffi", "json_to_string") 122 | @external(javascript, "../gleam_json_ffi.mjs", "json_to_string") 123 | fn do_to_string(a: Json) -> String 124 | 125 | /// Convert a JSON value into a string tree. 126 | /// 127 | /// Where possible prefer this function to the `to_string` function as it is 128 | /// slower than this function, and BEAM VM IO is optimised for sending 129 | /// `StringTree` data. 130 | /// 131 | /// ## Examples 132 | /// 133 | /// ```gleam 134 | /// > to_string_tree(array([1, 2, 3], of: int)) 135 | /// string_tree.from_string("[1,2,3]") 136 | /// ``` 137 | /// 138 | @external(erlang, "gleam_json_ffi", "json_to_iodata") 139 | @external(javascript, "../gleam_json_ffi.mjs", "json_to_string") 140 | pub fn to_string_tree(json: Json) -> StringTree 141 | 142 | /// Encode a string into JSON, using normal JSON escaping. 143 | /// 144 | /// ## Examples 145 | /// 146 | /// ```gleam 147 | /// > to_string(string("Hello!")) 148 | /// "\"Hello!\"" 149 | /// ``` 150 | /// 151 | pub fn string(input: String) -> Json { 152 | do_string(input) 153 | } 154 | 155 | @external(erlang, "gleam_json_ffi", "string") 156 | @external(javascript, "../gleam_json_ffi.mjs", "identity") 157 | fn do_string(a: String) -> Json 158 | 159 | /// Encode a bool into JSON. 160 | /// 161 | /// ## Examples 162 | /// 163 | /// ```gleam 164 | /// > to_string(bool(False)) 165 | /// "false" 166 | /// ``` 167 | /// 168 | pub fn bool(input: Bool) -> Json { 169 | do_bool(input) 170 | } 171 | 172 | @external(erlang, "gleam_json_ffi", "bool") 173 | @external(javascript, "../gleam_json_ffi.mjs", "identity") 174 | fn do_bool(a: Bool) -> Json 175 | 176 | /// Encode an int into JSON. 177 | /// 178 | /// ## Examples 179 | /// 180 | /// ```gleam 181 | /// > to_string(int(50)) 182 | /// "50" 183 | /// ``` 184 | /// 185 | pub fn int(input: Int) -> Json { 186 | do_int(input) 187 | } 188 | 189 | @external(erlang, "gleam_json_ffi", "int") 190 | @external(javascript, "../gleam_json_ffi.mjs", "identity") 191 | fn do_int(a: Int) -> Json 192 | 193 | /// Encode a float into JSON. 194 | /// 195 | /// ## Examples 196 | /// 197 | /// ```gleam 198 | /// > to_string(float(4.7)) 199 | /// "4.7" 200 | /// ``` 201 | /// 202 | pub fn float(input: Float) -> Json { 203 | do_float(input) 204 | } 205 | 206 | @external(erlang, "gleam_json_ffi", "float") 207 | @external(javascript, "../gleam_json_ffi.mjs", "identity") 208 | fn do_float(input input: Float) -> Json 209 | 210 | /// The JSON value null. 211 | /// 212 | /// ## Examples 213 | /// 214 | /// ```gleam 215 | /// > to_string(null()) 216 | /// "null" 217 | /// ``` 218 | /// 219 | pub fn null() -> Json { 220 | do_null() 221 | } 222 | 223 | @external(erlang, "gleam_json_ffi", "null") 224 | @external(javascript, "../gleam_json_ffi.mjs", "do_null") 225 | fn do_null() -> Json 226 | 227 | /// Encode an optional value into JSON, using null if it is the `None` variant. 228 | /// 229 | /// ## Examples 230 | /// 231 | /// ```gleam 232 | /// > to_string(nullable(Some(50), of: int)) 233 | /// "50" 234 | /// ``` 235 | /// 236 | /// ```gleam 237 | /// > to_string(nullable(None, of: int)) 238 | /// "null" 239 | /// ``` 240 | /// 241 | pub fn nullable(from input: Option(a), of inner_type: fn(a) -> Json) -> Json { 242 | case input { 243 | Some(value) -> inner_type(value) 244 | None -> null() 245 | } 246 | } 247 | 248 | /// Encode a list of key-value pairs into a JSON object. 249 | /// 250 | /// ## Examples 251 | /// 252 | /// ```gleam 253 | /// > to_string(object([ 254 | /// #("game", string("Pac-Man")), 255 | /// #("score", int(3333360)), 256 | /// ])) 257 | /// "{\"game\":\"Pac-Mac\",\"score\":3333360}" 258 | /// ``` 259 | /// 260 | pub fn object(entries: List(#(String, Json))) -> Json { 261 | do_object(entries) 262 | } 263 | 264 | @external(erlang, "gleam_json_ffi", "object") 265 | @external(javascript, "../gleam_json_ffi.mjs", "object") 266 | fn do_object(entries entries: List(#(String, Json))) -> Json 267 | 268 | /// Encode a list into a JSON array. 269 | /// 270 | /// ## Examples 271 | /// 272 | /// ```gleam 273 | /// > to_string(array([1, 2, 3], of: int)) 274 | /// "[1, 2, 3]" 275 | /// ``` 276 | /// 277 | pub fn array(from entries: List(a), of inner_type: fn(a) -> Json) -> Json { 278 | entries 279 | |> list.map(inner_type) 280 | |> preprocessed_array 281 | } 282 | 283 | /// Encode a list of JSON values into a JSON array. 284 | /// 285 | /// ## Examples 286 | /// 287 | /// ```gleam 288 | /// > to_string(preprocessed_array([int(1), float(2.0), string("3")])) 289 | /// "[1, 2.0, \"3\"]" 290 | /// ``` 291 | /// 292 | pub fn preprocessed_array(from: List(Json)) -> Json { 293 | do_preprocessed_array(from) 294 | } 295 | 296 | @external(erlang, "gleam_json_ffi", "array") 297 | @external(javascript, "../gleam_json_ffi.mjs", "array") 298 | fn do_preprocessed_array(from from: List(Json)) -> Json 299 | 300 | /// Encode a Dict into a JSON object using the supplied functions to encode 301 | /// the keys and the values respectively. 302 | /// 303 | /// ## Examples 304 | /// 305 | /// ```gleam 306 | /// > to_string(dict(dict.from_list([ #(3, 3.0), #(4, 4.0)]), int.to_string, float) 307 | /// "{\"3\": 3.0, \"4\": 4.0}" 308 | /// ``` 309 | /// 310 | pub fn dict( 311 | dict: Dict(k, v), 312 | keys: fn(k) -> String, 313 | values: fn(v) -> Json, 314 | ) -> Json { 315 | object(dict.fold(dict, [], fn(acc, k, v) { [#(keys(k), values(v)), ..acc] })) 316 | } 317 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | Copyright 2021 - present, Louis Pilfold . 179 | 180 | Licensed under the Apache License, Version 2.0 (the "License"); 181 | you may not use this file except in compliance with the License. 182 | You may obtain a copy of the License at 183 | 184 | http://www.apache.org/licenses/LICENSE-2.0 185 | 186 | Unless required by applicable law or agreed to in writing, software 187 | distributed under the License is distributed on an "AS IS" BASIS, 188 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 189 | See the License for the specific language governing permissions and 190 | limitations under the License. 191 | 192 | --------------------------------------------------------------------------------