diff --git a/CHANGELOG.md b/CHANGELOG.md index a97be43845..2cf5490b33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +### Stdlib + +- Added a `@acton/json` module for reading values out of JSON documents during + tests and scripts, complementing `fs.readFile`. Values are addressed with a + JSON Pointer (RFC 6901): `json.getInt`, `json.getString`, and `json.getBool` + return `null` on invalid JSON, a missing path, or a type mismatch (consistent + with `@acton/env`), and `json.exists` reports whether a pointer resolves. + ## [1.1.0] - 22.05.2026 Acton 1.1.0 is the first feature release after `v1.0.0`. It focuses on diff --git a/docs/content/docs/standard_library/json.mdx b/docs/content/docs/standard_library/json.mdx new file mode 100644 index 0000000000..e31585978e --- /dev/null +++ b/docs/content/docs/standard_library/json.mdx @@ -0,0 +1,112 @@ +--- +title: "json" +description: "json.tolk standard library file" +--- + +import { SourceCodeLink } from '@/components/SourceCodeLink'; + +{/* @generated by `acton docgen`. Do not edit directly. */} +{/* Source: `lib/json.tolk` */} + +Module for reading values out of JSON documents. + +This module provides a small, read-only JSON decoder for use during contract +testing or script execution. It is the natural companion to `fs.readFile`, +which loads a JSON file as a `string` but does not parse it. + +Values are addressed with a JSON Pointer (RFC 6901): a `/`-separated path +such as `/token/decimals` or `/items/0`. An empty pointer (`""`) selects the +whole document. + +Lookup semantics (consistent with `@acton/env`): +- Invalid JSON, a missing path, or a type mismatch all return `null`, so `??` + can supply a default. +- `getInt` accepts a JSON integer, or a JSON string holding a decimal or + `0x`-hex integer. Non-integers (floats) return `null`. To carry full + 257-bit values losslessly, encode the number as a JSON string. + +Examples: +```tolk +val src = fs.readFile("fixtures/jetton-metadata.json"); +if (src != null) { + val decimals = json.getInt(src!, "/decimals") ?? 9; + val name = json.getString(src!, "/name") ?? "unknown"; + val mintable = json.getBool(src!, "/mintable") ?? false; + if (json.exists(src!, "/admin")) { + println("metadata declares an admin"); + } +} +``` + +## Definitions + +## `json` + +```tolk +struct json +``` + +Namespace for JSON decoding operations. + + + +## `json.getInt` + +```tolk +fun json.getInt(src: string, path: string): int? +``` + +Reads an integer at the given JSON Pointer. + +Returns: +- the value when it is a JSON integer, or a JSON string containing a decimal + or `0x`-hex integer. +- `null` when the source is invalid JSON, the pointer is absent, or the value + is not an integer (for example a float, boolean, object, or array). + +Bare JSON numbers larger than 64 bits are not reliably representable; pass +such values as JSON strings to preserve full precision. + + + +## `json.getString` + +```tolk +fun json.getString(src: string, path: string): string? +``` + +Reads a string at the given JSON Pointer. + +Returns the value when it is a JSON string, otherwise `null` (invalid JSON, +missing pointer, or a non-string value). + + + +## `json.getBool` + +```tolk +fun json.getBool(src: string, path: string): bool? +``` + +Reads a boolean at the given JSON Pointer. + +Returns the value when it is a JSON boolean, otherwise `null` (invalid JSON, +missing pointer, or a non-boolean value). A present `false` is returned as +`false`, not `null`. + + + +## `json.exists` + +```tolk +fun json.exists(src: string, path: string): bool +``` + +Checks whether the given JSON Pointer resolves to a value. + +Returns: +- `true` when the pointer resolves to any JSON value (including `null`). +- `false` when the source is invalid JSON or the pointer is absent. + + + diff --git a/docs/content/docs/standard_library/overview.mdx b/docs/content/docs/standard_library/overview.mdx index a04630989e..d383d37033 100644 --- a/docs/content/docs/standard_library/overview.mdx +++ b/docs/content/docs/standard_library/overview.mdx @@ -47,6 +47,9 @@ The Tolk stdlib is documented in [Tolk standard library](/docs/tolk_standard_lib Module for input/output operations. + + Module for reading values out of JSON documents. + Module for user prompts. diff --git a/lib/json.tolk b/lib/json.tolk new file mode 100644 index 0000000000..eb9f1b8f5f --- /dev/null +++ b/lib/json.tolk @@ -0,0 +1,68 @@ +/// Module for reading values out of JSON documents. +/// +/// This module provides a small, read-only JSON decoder for use during contract +/// testing or script execution. It is the natural companion to `fs.readFile`, +/// which loads a JSON file as a `string` but does not parse it. +/// +/// Values are addressed with a JSON Pointer (RFC 6901): a `/`-separated path +/// such as `/token/decimals` or `/items/0`. An empty pointer (`""`) selects the +/// whole document. +/// +/// Lookup semantics (consistent with `@acton/env`): +/// - Invalid JSON, a missing path, or a type mismatch all return `null`, so `??` +/// can supply a default. +/// - `getInt` accepts a JSON integer, or a JSON string holding a decimal or +/// `0x`-hex integer. Non-integers (floats) return `null`. To carry full +/// 257-bit values losslessly, encode the number as a JSON string. +/// +/// Examples: +/// ```tolk +/// val src = fs.readFile("fixtures/jetton-metadata.json"); +/// if (src != null) { +/// val decimals = json.getInt(src!, "/decimals") ?? 9; +/// val name = json.getString(src!, "/name") ?? "unknown"; +/// val mintable = json.getBool(src!, "/mintable") ?? false; +/// if (json.exists(src!, "/admin")) { +/// println("metadata declares an admin"); +/// } +/// } +/// ``` + +/// Namespace for JSON decoding operations. +struct json + +/// Reads an integer at the given JSON Pointer. +/// +/// Returns: +/// - the value when it is a JSON integer, or a JSON string containing a decimal +/// or `0x`-hex integer. +/// - `null` when the source is invalid JSON, the pointer is absent, or the value +/// is not an integer (for example a float, boolean, object, or array). +/// +/// Bare JSON numbers larger than 64 bits are not reliably representable; pass +/// such values as JSON strings to preserve full precision. +fun json.getInt(src: string, path: string): int? + asm "300 EXTCALL" + +/// Reads a string at the given JSON Pointer. +/// +/// Returns the value when it is a JSON string, otherwise `null` (invalid JSON, +/// missing pointer, or a non-string value). +fun json.getString(src: string, path: string): string? + asm "301 EXTCALL" + +/// Reads a boolean at the given JSON Pointer. +/// +/// Returns the value when it is a JSON boolean, otherwise `null` (invalid JSON, +/// missing pointer, or a non-boolean value). A present `false` is returned as +/// `false`, not `null`. +fun json.getBool(src: string, path: string): bool? + asm "302 EXTCALL" + +/// Checks whether the given JSON Pointer resolves to a value. +/// +/// Returns: +/// - `true` when the pointer resolves to any JSON value (including `null`). +/// - `false` when the source is invalid JSON or the pointer is absent. +fun json.exists(src: string, path: string): bool + asm "303 EXTCALL" diff --git a/src/ffi/json.rs b/src/ffi/json.rs new file mode 100644 index 0000000000..ba45249233 --- /dev/null +++ b/src/ffi/json.rs @@ -0,0 +1,102 @@ +use crate::context::Context; +use num_bigint::BigInt; +use serde_json::Value; +use std::str::FromStr; +use ton_emulator::{extension, register_ext_methods}; +use ton_executor::BaseExecutor; +use tvm_ffi::stack::{Tuple, TupleItem}; + +/// Parses `src` as a JSON document and returns the value at the given +/// JSON Pointer (RFC 6901, e.g. `/token/decimals` or `/items/0`). +/// +/// Returns `None` when `src` is not valid JSON or when the pointer does not +/// resolve to a value. An empty pointer (`""`) selects the whole document. +fn lookup(src: &str, pointer: &str) -> Option { + let root: Value = serde_json::from_str(src).ok()?; + root.pointer(pointer).cloned() +} + +/// Parses an integer from a JSON string value: decimal, or hexadecimal when +/// prefixed with `0x`/`0X`. Mirrors the parsing accepted by `env`. +fn parse_int_str(raw: &str) -> Option { + let raw = raw.trim(); + if let Ok(value) = BigInt::from_str(raw) { + return Some(value); + } + let hex = raw.strip_prefix("0x").or_else(|| raw.strip_prefix("0X"))?; + BigInt::parse_bytes(hex.as_bytes(), 16) +} + +extension!(json_get_int in (Context) with (pointer: String, src: String) using json_get_int_impl); +fn json_get_int_impl( + _ctx: &mut Context, + stack: &mut Tuple, + pointer: String, + src: String, +) -> anyhow::Result<()> { + // NOTE: stack is LIFO, so the host receives the Tolk arguments reversed: + // Tolk `json.getInt(src, pointer)` -> Rust `(pointer, src)`. + let parsed = lookup(&src, &pointer).and_then(|value| match value { + // Bare JSON integers. Floats fail `BigInt::from_str` and yield `null`. + // Values beyond 64-bit should be encoded as JSON strings (see below), + // since serde_json parses oversized bare numbers as lossy floats. + Value::Number(number) => BigInt::from_str(&number.to_string()).ok(), + // Quoted numbers (decimal or 0x-hex). This is the lossless way to carry + // full 257-bit TON integers through JSON. + Value::String(text) => parse_int_str(&text), + _ => None, + }); + match parsed { + Some(value) => stack.push(TupleItem::Int(value)), + None => stack.push(TupleItem::Null), + } + Ok(()) +} + +extension!(json_get_string in (Context) with (pointer: String, src: String) using json_get_string_impl); +fn json_get_string_impl( + _ctx: &mut Context, + stack: &mut Tuple, + pointer: String, + src: String, +) -> anyhow::Result<()> { + match lookup(&src, &pointer) { + Some(Value::String(text)) => stack.push_string(&text), + _ => stack.push(TupleItem::Null), + } + Ok(()) +} + +extension!(json_get_bool in (Context) with (pointer: String, src: String) using json_get_bool_impl); +fn json_get_bool_impl( + _ctx: &mut Context, + stack: &mut Tuple, + pointer: String, + src: String, +) -> anyhow::Result<()> { + match lookup(&src, &pointer) { + Some(Value::Bool(flag)) => stack.push_bool(flag), + _ => stack.push(TupleItem::Null), + } + Ok(()) +} + +extension!(json_exists in (Context) with (pointer: String, src: String) using json_exists_impl); +fn json_exists_impl( + _ctx: &mut Context, + stack: &mut Tuple, + pointer: String, + src: String, +) -> anyhow::Result<()> { + stack.push_bool(lookup(&src, &pointer).is_some()); + Ok(()) +} + +pub fn register_extensions(executor: &mut T, ctx: &mut Context) { + register_ext_methods!(executor, ctx, { + 300 => json_get_int : 2, + 301 => json_get_string : 2, + 302 => json_get_bool : 2, + 303 => json_exists : 2, + }); +} diff --git a/src/ffi/mod.rs b/src/ffi/mod.rs index ec254d9925..3bbd3a2c8f 100644 --- a/src/ffi/mod.rs +++ b/src/ffi/mod.rs @@ -9,6 +9,7 @@ pub mod emulation; pub mod env; pub mod fs; pub mod io; +pub mod json; #[derive(Clone, Copy)] #[repr(usize)] @@ -39,6 +40,7 @@ impl SearchParamIndex { pub fn register(executor: &mut T, ctx: &mut Context) { io::register_extensions(executor, ctx); fs::register_extensions(executor, ctx); + json::register_extensions(executor, ctx); boc::register_extensions(executor, ctx); env::register_extensions(executor, ctx); assert::register_extensions(executor, ctx); diff --git a/tests/integration/test_runner/json_reads_typed_values_by_pointer_tests.rs b/tests/integration/test_runner/json_reads_typed_values_by_pointer_tests.rs new file mode 100644 index 0000000000..83e466b3d4 --- /dev/null +++ b/tests/integration/test_runner/json_reads_typed_values_by_pointer_tests.rs @@ -0,0 +1,114 @@ +use crate::support::TestOutputExt; +use crate::support::project::ProjectBuilder; + +const METADATA_JSON: &str = r#"{ + "name": "Demo", + "decimals": 9, + "mintable": true, + "frozen": false, + "big": "123456789012345678901234567890", + "hex": "0x1a", + "ratio": 1.5, + "token": { "symbol": "DEMO" }, + "items": [10, 20, 30] +}"#; + +const JSON_IMPORTS: &str = r#" +import "../../lib/fs" +import "../../lib/json" +import "../../lib/testing/expect" +"#; + +fn run_case(project_name: &str, test_body: &str) { + let test_code = format!("{JSON_IMPORTS}\n{test_body}\n"); + + ProjectBuilder::new(project_name) + .test_file("json_behavior", &test_code) + .raw_file("fixtures/metadata.json", METADATA_JSON) + .build() + .acton() + .test() + .run() + .success() + .assert_passed(1); +} + +#[test] +fn json_get_int_reads_numbers_strings_and_hex_and_rejects_floats() { + run_case( + "z-stdlib-json-get-int", + r#" +get fun `test z stdlib json get int`() { + val src = fs.readFile("fixtures/metadata.json")!; + + expect(json.getInt(src, "/decimals")).toEqual(9); + expect(json.getInt(src, "/items/1")).toEqual(20); + expect(json.getInt(src, "/hex")).toEqual(26); + expect(json.getInt(src, "/big")).toEqual(123456789012345678901234567890); + + expect(json.getInt(src, "/ratio")).toBeNull(); + expect(json.getInt(src, "/name")).toBeNull(); + expect(json.getInt(src, "/missing")).toBeNull(); +} +"#, + ); +} + +#[test] +fn json_get_string_reads_strings_and_nested_paths() { + run_case( + "z-stdlib-json-get-string", + r#" +get fun `test z stdlib json get string`() { + val src = fs.readFile("fixtures/metadata.json")!; + + expect(json.getString(src, "/name")!).toEqual("Demo"); + expect(json.getString(src, "/token/symbol")!).toEqual("DEMO"); + + expect(json.getString(src, "/decimals")).toBeNull(); + expect(json.getString(src, "/missing")).toBeNull(); +} +"#, + ); +} + +#[test] +fn json_get_bool_distinguishes_present_false_from_null() { + run_case( + "z-stdlib-json-get-bool", + r#" +get fun `test z stdlib json get bool`() { + val src = fs.readFile("fixtures/metadata.json")!; + + expect(json.getBool(src, "/mintable")!).toEqual(true); + + val frozen = json.getBool(src, "/frozen"); + expect(frozen).toBeNotNull(); + expect(frozen!).toEqual(false); + + expect(json.getBool(src, "/decimals")).toBeNull(); + expect(json.getBool(src, "/missing")).toBeNull(); +} +"#, + ); +} + +#[test] +fn json_exists_and_invalid_source_return_expected_flags() { + run_case( + "z-stdlib-json-exists-and-invalid", + r#" +get fun `test z stdlib json exists and invalid source`() { + val src = fs.readFile("fixtures/metadata.json")!; + + expect(json.exists(src, "/token")).toEqual(true); + expect(json.exists(src, "/frozen")).toEqual(true); + expect(json.exists(src, "/missing")).toEqual(false); + + // Invalid JSON collapses to null / false rather than throwing. + expect(json.getString("this is not json", "/x")).toBeNull(); + expect(json.exists("this is not json", "/x")).toEqual(false); +} +"#, + ); +} diff --git a/tests/integration/test_runner/mod.rs b/tests/integration/test_runner/mod.rs index 2d01f585a5..94b0299bd7 100644 --- a/tests/integration/test_runner/mod.rs +++ b/tests/integration/test_runner/mod.rs @@ -86,6 +86,7 @@ mod get_account_state_after_top_up_returns_account_info_with_expected_balance_te mod get_deployed_code_transitions_from_null_to_non_null_in_project_builder_tests; mod get_method_current_balance_matches_account_balance_tests; mod global_version_roundtrip_persists_after_net_set_config_tests; +mod json_reads_typed_values_by_pointer_tests; mod keeps_stdout_and_stderr_separated_when_stderr_happens_first_tests; mod load_library_prefers_world_state_library_before_network_tests; mod load_library_unknown_hash_returns_null_in_project_builder_tests;