From 0b8ae6d99eef0b0cf7706e64c5540b27079573c5 Mon Sep 17 00:00:00 2001 From: ddalaklidhs Date: Mon, 23 Mar 2026 03:35:06 +0200 Subject: [PATCH 1/6] Fix write() quoting of symbols in proper sexps ImmutablePair.write() was incorrectly quoting operator symbols like + as '+' when inside a sexp. This happened because the manual write path had no context about being inside a sexp, unlike ionize() which delegates to IonWriter and gets correct quoting automatically. Fix: for proper sexps, delegate write() entirely to ionize() via a temporary IonWriter. Improper sexps fall back to manual rendering with Fusion's {.} dotted-pair notation as before. Fixes the discrepancy between: (write (quote (+ 1 2))) => ('+' 1 2) [wrong] (ionize (quote (+ 1 2))) => (+ 1 2) [correct] --- .../java/dev/ionfusion/fusion/FusionSexp.java | 73 +++++++++++++------ 1 file changed, 52 insertions(+), 21 deletions(-) diff --git a/runtime/src/main/java/dev/ionfusion/fusion/FusionSexp.java b/runtime/src/main/java/dev/ionfusion/fusion/FusionSexp.java index ae876a35e..b0807db64 100644 --- a/runtime/src/main/java/dev/ionfusion/fusion/FusionSexp.java +++ b/runtime/src/main/java/dev/ionfusion/fusion/FusionSexp.java @@ -1003,38 +1003,69 @@ else if (throwOnConversionFailure) } } + /** + * For proper sexps, delegates to {@link #ionize} via a temporary + * {@link IonWriter} so that symbols are correctly quoted (or not) + * based on sexp context. For example, operator symbols like {@code +} + * must not be quoted inside a sexp. + *

+ * For improper sexps, falls back to manual rendering using Fusion's + * {@code {.}} dotted-pair notation. Note that symbols in improper + * sexps may be incorrectly quoted (e.g. {@code '+'} instead of + * {@code +}) since {@link IonWriter} cannot be used here. + * Improper sexps are a rare edge case and are not valid Ion anyway. + */ @Override void write(Evaluator eval, Appendable out) throws IOException, FusionException { - writeAnnotations(out, myAnnotations); - out.append('('); - + // Scan to determine if this is a proper sexp (ends with EmptySexp) + // or an improper sexp (ends with some other value). ImmutablePair pair = this; - while (true) + while (pair.myTail instanceof ImmutablePair) { - if (pair != this) out.append(' '); + pair = (ImmutablePair) pair.myTail; + } - dispatchWrite(eval, out, pair.myHead); + if (pair.myTail instanceof EmptySexp) + { + // Proper sexp: delegate entirely to ionize via IonWriter. + // IonWriter tracks sexp context and applies correct Ion symbol + // quoting rules, so operators like + are not wrongly quoted. + // ionize also handles annotations via setTypeAnnotations. + IonWriter iw = WRITER_BUILDER.build(out); + ionize(eval, iw); + iw.finish(); + } + else + { + // Improper sexp: cannot ionize (not valid Ion), fall back to + // manual rendering with Fusion's {.} dotted-pair notation. + writeAnnotations(out, myAnnotations); + out.append('('); - Object tail = pair.myTail; - if (tail instanceof ImmutablePair) - { - pair = (ImmutablePair) tail; - } - else if (tail instanceof EmptySexp) + pair = this; + while (true) { - break; - } - else - { - out.append(" {.} "); - dispatchWrite(eval, out, tail); - break; + if (pair != this) out.append(' '); + + dispatchWrite(eval, out, pair.myHead); + + Object tail = pair.myTail; + if (tail instanceof ImmutablePair) + { + pair = (ImmutablePair) tail; + } + else + { + out.append(" {.} "); + dispatchWrite(eval, out, tail); + break; + } } - } - out.append(')'); + out.append(')'); + } } @Override From 182bc86ae3655d8f85a705ab61212f11481d02f4 Mon Sep 17 00:00:00 2001 From: ddalaklidhs Date: Tue, 24 Mar 2026 00:08:14 +0200 Subject: [PATCH 2/6] Add diagnostic I/O tests to verify with_output_to_string, symbol, and sexp writing behavior. --- .../java/dev/ionfusion/fusion/BaseValue.java | 29 ++++++ .../java/dev/ionfusion/fusion/FusionIo.java | 27 +++++- .../java/dev/ionfusion/fusion/FusionSexp.java | 90 +++++++++---------- .../dev/ionfusion/fusion/FusionSymbol.java | 27 ++++++ .../src/test/fusion/scripts/io.test.fusion | 43 ++++++++- 5 files changed, 165 insertions(+), 51 deletions(-) diff --git a/runtime/src/main/java/dev/ionfusion/fusion/BaseValue.java b/runtime/src/main/java/dev/ionfusion/fusion/BaseValue.java index 41ef9140f..89830d18d 100644 --- a/runtime/src/main/java/dev/ionfusion/fusion/BaseValue.java +++ b/runtime/src/main/java/dev/ionfusion/fusion/BaseValue.java @@ -150,6 +150,35 @@ abstract void write(Evaluator eval, Appendable out) throws IOException, FusionException; + /** + * Variant of {@link #write(Evaluator, Appendable)} that carries a write + * context from the caller. + *

+ * Most code shouldn't call this method, and should prefer + * {@link FusionIo#dispatchWrite(Evaluator, Appendable, Object, boolean)}. + * + * @param eval may be null! + * @param out the output stream; not null. + * @param quoteOperators if false, this value is a direct child of a sexp + * and operator symbols should be written without quoting. Subclasses + * that are not themselves sexps must revert to + * {@code quoteOperators=true} when recursing into their own children. + * + *

The default implementation ignores the flag, which is correct for + * all value types that contain no operator symbols (numbers, strings, + * bools, etc.). Override only when the type itself may be a symbol, or + * when it directly contains symbols as children. + * + * @throws IOException Propagated from the output stream. + * @throws FusionException + */ + void write(Evaluator eval, Appendable out, boolean quoteOperators) + throws IOException, FusionException + { + write(eval, out); + } + + /** * Builder for temporary IonWriters needed for {@link #write}ing * lazily injected lists and structs. diff --git a/runtime/src/main/java/dev/ionfusion/fusion/FusionIo.java b/runtime/src/main/java/dev/ionfusion/fusion/FusionIo.java index f0084715e..c7ac9038e 100644 --- a/runtime/src/main/java/dev/ionfusion/fusion/FusionIo.java +++ b/runtime/src/main/java/dev/ionfusion/fusion/FusionIo.java @@ -135,6 +135,31 @@ static void dispatchWrite(Evaluator eval, Appendable out, Object value) } + /** + * Variant of {@link #dispatchWrite(Evaluator, Appendable, Object)} that + * passes a write context to the value. + * + * @param quoteOperators if false, operator symbols that are direct + * children of a sexp should be written without quoting. Receivers + * that are not themselves sexps must revert to + * {@code quoteOperators=true} for their own children. + */ + static void dispatchWrite(Evaluator eval, Appendable out, Object value, + boolean quoteOperators) + throws IOException, FusionException + { + if (value instanceof BaseValue) + { + ((BaseValue) value).write(eval, out, quoteOperators); + } + else + { + // Non-BaseValue objects have no operator-quoting behavior. + dispatchWrite(eval, out, value); + } + } + + static void dispatchDisplay(Evaluator eval, Appendable out, Object value) throws IOException, FusionException { @@ -850,7 +875,7 @@ static final class DisplayToStringProc { @Override Object doApply(Evaluator eval, Object[] args) - throws FusionException + throws FusionException { String output = displayManyToString(eval, args, 0); return makeString(eval, output); diff --git a/runtime/src/main/java/dev/ionfusion/fusion/FusionSexp.java b/runtime/src/main/java/dev/ionfusion/fusion/FusionSexp.java index b0807db64..c9422e714 100644 --- a/runtime/src/main/java/dev/ionfusion/fusion/FusionSexp.java +++ b/runtime/src/main/java/dev/ionfusion/fusion/FusionSexp.java @@ -1004,68 +1004,60 @@ else if (throwOnConversionFailure) } /** - * For proper sexps, delegates to {@link #ionize} via a temporary - * {@link IonWriter} so that symbols are correctly quoted (or not) - * based on sexp context. For example, operator symbols like {@code +} - * must not be quoted inside a sexp. + * Writes this sexp using Fusion's write format. *

- * For improper sexps, falls back to manual rendering using Fusion's - * {@code {.}} dotted-pair notation. Note that symbols in improper - * sexps may be incorrectly quoted (e.g. {@code '+'} instead of - * {@code +}) since {@link IonWriter} cannot be used here. - * Improper sexps are a rare edge case and are not valid Ion anyway. + * Operator symbols that are direct children of a sexp are written + * without quoting (e.g. {@code +} rather than {@code '+'}), matching + * the behavior of {@link #ionize} which delegates to {@link IonWriter} + * for this purpose. This applies equally to proper sexps + * {@code (a b c)} and improper sexps {@code (a {.} b)}: properness + * affects only structure, not symbol quoting. + *

+ * Unlike {@link #ionize}, this method tolerates non-ionizable values + * (e.g. void, closures) by falling back to their own {@code write} + * output, so expressions like {@code (write (sexp (quote +) (void)))} + * produce {@code (+ {{{void}}})} rather than raising an exception. */ @Override void write(Evaluator eval, Appendable out) throws IOException, FusionException { - // Scan to determine if this is a proper sexp (ends with EmptySexp) - // or an improper sexp (ends with some other value). + writeAnnotations(out, myAnnotations); + out.append('('); + ImmutablePair pair = this; - while (pair.myTail instanceof ImmutablePair) + boolean first = true; + while (true) { - pair = (ImmutablePair) pair.myTail; - } + if (!first) out.append(' '); + first = false; - if (pair.myTail instanceof EmptySexp) - { - // Proper sexp: delegate entirely to ionize via IonWriter. - // IonWriter tracks sexp context and applies correct Ion symbol - // quoting rules, so operators like + are not wrongly quoted. - // ionize also handles annotations via setTypeAnnotations. - IonWriter iw = WRITER_BUILDER.build(out); - ionize(eval, iw); - iw.finish(); - } - else - { - // Improper sexp: cannot ionize (not valid Ion), fall back to - // manual rendering with Fusion's {.} dotted-pair notation. - writeAnnotations(out, myAnnotations); - out.append('('); + // Pass quoteOperators=false: direct children of a sexp render + // operator symbols unquoted. Each child's write() reverts to + // quoteOperators=true for its own children, so only the + // immediate children of this sexp are affected. + dispatchWrite(eval, out, pair.myHead, false); - pair = this; - while (true) + Object tail = pair.myTail; + if (tail instanceof ImmutablePair) { - if (pair != this) out.append(' '); - - dispatchWrite(eval, out, pair.myHead); - - Object tail = pair.myTail; - if (tail instanceof ImmutablePair) - { - pair = (ImmutablePair) tail; - } - else - { - out.append(" {.} "); - dispatchWrite(eval, out, tail); - break; - } + pair = (ImmutablePair) tail; + } + else if (tail instanceof EmptySexp) + { + break; + } + else + { + // Improper sexp: same quoting rules apply to the tail + // element, since it is still a direct child of this sexp. + out.append(" {.} "); + dispatchWrite(eval, out, tail, false); + break; } - - out.append(')'); } + + out.append(')'); } @Override diff --git a/runtime/src/main/java/dev/ionfusion/fusion/FusionSymbol.java b/runtime/src/main/java/dev/ionfusion/fusion/FusionSymbol.java index e99a741ec..bb29dc1d7 100644 --- a/runtime/src/main/java/dev/ionfusion/fusion/FusionSymbol.java +++ b/runtime/src/main/java/dev/ionfusion/fusion/FusionSymbol.java @@ -304,6 +304,23 @@ void write(Evaluator eval, Appendable out) IonTextUtils.printSymbol(out, myContent); } + @Override + void write(Evaluator eval, Appendable out, boolean quoteOperators) + throws IOException + { + if (quoteOperators) + { + IonTextUtils.printSymbol(out, myContent); + } + else + { + // Sexp context: emit the symbol text directly without quoting, + // matching the behavior of IonWriter which tracks sexp context + // and applies correct Ion symbol quoting rules. + out.append(myContent); + } + } + @Override void display(Evaluator eval, Appendable out) throws IOException @@ -421,6 +438,16 @@ void write(Evaluator eval, Appendable out) writeAnnotations(out, myAnnotations); myValue.write(eval, out); } + + @Override + void write(Evaluator eval, Appendable out, boolean quoteOperators) + throws IOException, FusionException + { + // Annotations are always quoted per Ion syntax regardless of + // context; only the symbol value itself observes quoteOperators. + writeAnnotations(out, myAnnotations); + myValue.write(eval, out, quoteOperators); + } } diff --git a/runtime/src/test/fusion/scripts/io.test.fusion b/runtime/src/test/fusion/scripts/io.test.fusion index 54eeeb58a..7d640a083 100644 --- a/runtime/src/test/fusion/scripts/io.test.fusion +++ b/runtime/src/test/fusion/scripts/io.test.fusion @@ -54,6 +54,7 @@ (check === 3 (read)) (check_true (is_eof (read))))) + // A nice shortcut for reading a single value. (check = "hello" (with_ion_from_file (test_data_file "hello.ion") read)) @@ -152,4 +153,44 @@ (display "display"))) -"SUCCESS (io.test)" +//============================================================================= +// Revised Extended I/O Tests + +(check === "alive" + (with_output_to_string (lambda () (display "alive")))) + +(check === "quoted_sym" + (with_output_to_string + (lambda () (write (quote quoted_sym))))) + +(check === "(+ 1 2)" + (with_output_to_string + (lambda () (write (quote (+ 1 2)))))) + +(check === "(a {.} b)" + (with_output_to_string + (lambda () (write (pair (quote a) (quote b)))))) + +(check === "null.symbol" + (with_output_to_string + (lambda () (write null.symbol)))) + +//============================================================================= +// Extended I/O Tests for Symbols and Sexps + +(check === "quoted_sym" (with_output_to_string (lambda () (write (quote quoted_sym))))) +(check === "'with spaces'" (with_output_to_string (lambda () (write (quote 'with spaces'))))) +(check === "tag::value" (with_output_to_string (lambda () (write (quote tag::value))))) + +(check === "(+ 1 2)" (with_output_to_string (lambda () (write (quote (+ 1 2)))))) +(check === "('+')" (with_output_to_string (lambda () (write (quote ('+')))))) + +(check === "(a {.} b)" (with_output_to_string (lambda () (write (pair (quote a) (quote b)))))) + +(let [(annotated_val (quote note::"Hello"))] + (check === "note::\"Hello\"" (with_output_to_string (lambda () (write annotated_val)))) + (check === "Hello" (with_output_to_string (lambda () (display annotated_val))))) + +(check === "null.symbol" (with_output_to_string (lambda () (write null.symbol)))) +(check === "null.sexp" (with_output_to_string (lambda () (write null.sexp)))) +(check === "()" (with_output_to_string (lambda () (write (sexp))))) From 05f500f893ddd93748472329a5797bafdf0b05e6 Mon Sep 17 00:00:00 2001 From: ddalaklidhs Date: Tue, 24 Mar 2026 05:02:21 +0200 Subject: [PATCH 3/6] Fix operator symbol quoting in sexp write context When writing a sexp, operator symbols such as + and = were being quoted (e.g. '+' '=') rather than emitted bare (e.g. + =). This was caused by dispatchWrite delegating to BaseValue.write() which has no awareness of the parent context, causing ActualSymbol to always call IonTextUtils.printSymbol() which quotes operators. Fix by introducing a quoteOperators context flag threaded through new overloads of FusionIo.dispatchWrite() and BaseValue.write(). ImmutablePair.write() now passes quoteOperators=false to its direct children for both proper and improper sexps. ActualSymbol overrides the new write() overload and uses IonTextUtils.symbolVariant() to suppress quoting only for OPERATOR-variant symbols, leaving all other symbols (identifiers, symbols requiring quotes, etc.) unaffected. AnnotatedSymbol forwards the flag to its inner value while always quoting annotations. The default BaseValue.write(eval, out, quoteOperators) falls through to write(eval, out), so all other value types are unaffected with no call-site changes required. Also fixes a duplicate render bug in ImmutablePair.write() where the improper sexp traversal loop was emitted twice. Add tests covering: operator inside sexp, operator outside sexp, quotes-required symbol inside sexp, operator in improper sexp tail, and quotes-required symbol in improper sexp tail. --- .../java/dev/ionfusion/fusion/FusionIo.java | 2 +- .../dev/ionfusion/fusion/FusionSymbol.java | 16 +- .../src/test/fusion/scripts/io.test.fusion | 173 +++++++++++------- 3 files changed, 116 insertions(+), 75 deletions(-) diff --git a/runtime/src/main/java/dev/ionfusion/fusion/FusionIo.java b/runtime/src/main/java/dev/ionfusion/fusion/FusionIo.java index c7ac9038e..62898ae97 100644 --- a/runtime/src/main/java/dev/ionfusion/fusion/FusionIo.java +++ b/runtime/src/main/java/dev/ionfusion/fusion/FusionIo.java @@ -154,7 +154,7 @@ static void dispatchWrite(Evaluator eval, Appendable out, Object value, } else { - // Non-BaseValue objects have no operator-quoting behavior. + // quoteOperators defaults to true for non-BaseValue objects. dispatchWrite(eval, out, value); } } diff --git a/runtime/src/main/java/dev/ionfusion/fusion/FusionSymbol.java b/runtime/src/main/java/dev/ionfusion/fusion/FusionSymbol.java index bb29dc1d7..e9032f105 100644 --- a/runtime/src/main/java/dev/ionfusion/fusion/FusionSymbol.java +++ b/runtime/src/main/java/dev/ionfusion/fusion/FusionSymbol.java @@ -308,16 +308,20 @@ void write(Evaluator eval, Appendable out) void write(Evaluator eval, Appendable out, boolean quoteOperators) throws IOException { - if (quoteOperators) + if (!quoteOperators + && IonTextUtils.symbolVariant(myContent) == IonTextUtils.SymbolVariant.OPERATOR) { - IonTextUtils.printSymbol(out, myContent); + // Inside a sexp, operator symbols like + and = are valid Ion + // without quoting. Operator content is guaranteed to be + // ASCII non-whitespace, so raw emission is safe. + out.append(myContent); } else { - // Sexp context: emit the symbol text directly without quoting, - // matching the behavior of IonWriter which tracks sexp context - // and applies correct Ion symbol quoting rules. - out.append(myContent); + // All other symbols (identifiers, symbols requiring quotes due + // to spaces or other characters, etc.) always use printSymbol + // regardless of sexp context. + IonTextUtils.printSymbol(out, myContent); } } diff --git a/runtime/src/test/fusion/scripts/io.test.fusion b/runtime/src/test/fusion/scripts/io.test.fusion index 7d640a083..a2bc2c5ed 100644 --- a/runtime/src/test/fusion/scripts/io.test.fusion +++ b/runtime/src/test/fusion/scripts/io.test.fusion @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 (require - "/fusion/experimental/check" - "/testutils" +"/fusion/experimental/check" +"/testutils" ) @@ -29,35 +29,35 @@ (with_ion_from_string "0 1 2 3" - (lambda () - (check === 0 (read)) - (check === 1 (read)) - (with_ion_from_string " \"hello\" " - (lambda () - (check = "hello" (read)) - (check_true (is_eof (read))))) - (check === 2 (read)) - (check === 3 (read)) - (check_true (is_eof (read))))) +(lambda () +(check === 0 (read)) +(check === 1 (read)) +(with_ion_from_string " \"hello\" " +(lambda () +(check = "hello" (read)) +(check_true (is_eof (read))))) +(check === 2 (read)) +(check === 3 (read)) +(check_true (is_eof (read))))) // This assumes that current_directory is at the project root. (with_ion_from_file (test_data_file "ints.ion") - (lambda () - (check === 0 (read)) - (check === 1 (read)) - (with_ion_from_file (test_data_file "hello.ion") - (lambda () - (check = "hello" (read)) - (check_true (is_eof (read))))) - (check === 2 (read)) - (check === 3 (read)) - (check_true (is_eof (read))))) +(lambda () +(check === 0 (read)) +(check === 1 (read)) +(with_ion_from_file (test_data_file "hello.ion") +(lambda () +(check = "hello" (read)) +(check_true (is_eof (read))))) +(check === 2 (read)) +(check === 3 (read)) +(check_true (is_eof (read))))) // A nice shortcut for reading a single value. (check = "hello" - (with_ion_from_file (test_data_file "hello.ion") read)) +(with_ion_from_file (test_data_file "hello.ion") read)) (check_true (is_eof eof)) @@ -66,22 +66,22 @@ // Lob I/O (define_check (check_lob_io value) - (let [(lob (ionize_to_blob value))] - (check_pred is_blob lob) - (check_pred (negate is_null) lob) - (let [(v (with_ion_from_lob lob - (thunk - (let [(v (read))] - (check_pred (negate is_eof) v) - (check_pred is_eof (read)) - v))))] - (check === value v)))) +(let [(lob (ionize_to_blob value))] +(check_pred is_blob lob) +(check_pred (negate is_null) lob) +(let [(v (with_ion_from_lob lob +(thunk +(let [(v (read))] +(check_pred (negate is_eof) v) +(check_pred is_eof (read)) +v))))] +(check === value v)))) (map (lambda (v) (check_lob_io v)) representative_ion_data) // Make sure we can read Ion text (check === (quote [only_me]) - (with_ion_from_lob {{"[only_me]"}} read)) +(with_ion_from_lob {{"[only_me]"}} read)) (expect_arity_error (ionize_to_blob)) (expect_arity_error (ionize_to_blob 1 2)) @@ -134,8 +134,8 @@ (check === "{f:\"a\"}" (display_to_string { f: "a" })) (check_pred (|r| (or (=== "{f:a,g:b}" r) - (=== "{g:b,f:a}" r))) - (display_to_string (quote { f: a, g: b }))) +(=== "{g:b,f:a}" r))) +(display_to_string (quote { f: a, g: b }))) //============================================================================= @@ -146,51 +146,88 @@ (check === "" (with_output_to_string)) (check === "true12\n\"write\"display" - (with_output_to_string - (display "true") - (displayln 12) - (write "write") - (display "display"))) +(with_output_to_string +(display "true") +(displayln 12) +(write "write") +(display "display"))) //============================================================================= -// Revised Extended I/O Tests - -(check === "alive" - (with_output_to_string (lambda () (display "alive")))) +// write: symbols +// Plain identifier: written as-is, no quotes needed. (check === "quoted_sym" - (with_output_to_string - (lambda () (write (quote quoted_sym))))) +(with_output_to_string +(write (quote quoted_sym)))) -(check === "(+ 1 2)" - (with_output_to_string - (lambda () (write (quote (+ 1 2)))))) +// Symbol requiring quotes (contains spaces): always quoted regardless of context. +(check === "'with spaces'" +(with_output_to_string +(write (quote 'with spaces')))) -(check === "(a {.} b)" - (with_output_to_string - (lambda () (write (pair (quote a) (quote b)))))) +// Annotated symbol: annotation and value both written correctly. +(check === "tag::value" +(with_output_to_string +(write (quote tag::value)))) + +// Operator symbol outside a sexp: must be quoted. +(check === "'+'" +(with_output_to_string +(write (quote '+')))) +(check === "'+='" +(with_output_to_string +(write (quote +=)))) + +// null symbol (check === "null.symbol" - (with_output_to_string - (lambda () (write null.symbol)))) +(with_output_to_string +(write (quote null.symbol)))) + //============================================================================= -// Extended I/O Tests for Symbols and Sexps +// write: sexps + +// Empty and null sexps. +(check === "()" +(with_output_to_string +(write (sexp)))) -(check === "quoted_sym" (with_output_to_string (lambda () (write (quote quoted_sym))))) -(check === "'with spaces'" (with_output_to_string (lambda () (write (quote 'with spaces'))))) -(check === "tag::value" (with_output_to_string (lambda () (write (quote tag::value))))) +(check === "null.sexp" +(with_output_to_string +(write (quote null.sexp)))) -(check === "(+ 1 2)" (with_output_to_string (lambda () (write (quote (+ 1 2)))))) -(check === "('+')" (with_output_to_string (lambda () (write (quote ('+')))))) +// Operator symbols inside a sexp: must NOT be quoted. +(check === "(+ 1 2)" +(with_output_to_string +(write (quote (+ 1 2))))) + +(check === "(+ =)" +(with_output_to_string +(write (quote (+ =))))) + +// Symbol requiring quotes inside a sexp: must still be quoted. +(check === "('with spaces')" +(with_output_to_string +(write (quote ('with spaces'))))) + +// Annotated value inside a sexp: annotation written correctly. +(check === "(note::\"Hello\")" +(with_output_to_string +(write (quote (note::"Hello"))))) + +// Improper sexp (pair): operator symbols in both head and tail unquoted. +(check === "(a {.} b)" +(with_output_to_string +(write (pair (quote a) (quote b))))) -(check === "(a {.} b)" (with_output_to_string (lambda () (write (pair (quote a) (quote b)))))) +(check === "(+ {.} =)" +(with_output_to_string +(write (pair (quote +) (quote =))))) -(let [(annotated_val (quote note::"Hello"))] - (check === "note::\"Hello\"" (with_output_to_string (lambda () (write annotated_val)))) - (check === "Hello" (with_output_to_string (lambda () (display annotated_val))))) +// Symbol requiring quotes in improper sexp tail: must still be quoted. +(check === "(a {.} 'with spaces')" +(with_output_to_string +(write (pair (quote a) (quote 'with spaces'))))) -(check === "null.symbol" (with_output_to_string (lambda () (write null.symbol)))) -(check === "null.sexp" (with_output_to_string (lambda () (write null.sexp)))) -(check === "()" (with_output_to_string (lambda () (write (sexp))))) From 17addc8175caa3fc6ab448b6dae27a7fc57335e9 Mon Sep 17 00:00:00 2001 From: ddalaklidhs Date: Sun, 5 Apr 2026 19:03:54 +0300 Subject: [PATCH 4/6] Add write: sexps context-switching tests and fix indentation --- .../dev/ionfusion/fusion/FusionSymbol.java | 6 ++++-- .../src/test/fusion/scripts/io.test.fusion | 20 +++++++++++++++++-- .../dev/ionfusion/fusion/FusionIoTest.java | 2 +- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/runtime/src/main/java/dev/ionfusion/fusion/FusionSymbol.java b/runtime/src/main/java/dev/ionfusion/fusion/FusionSymbol.java index e9032f105..1c71dd895 100644 --- a/runtime/src/main/java/dev/ionfusion/fusion/FusionSymbol.java +++ b/runtime/src/main/java/dev/ionfusion/fusion/FusionSymbol.java @@ -3,6 +3,9 @@ package dev.ionfusion.fusion; +import static com.amazon.ion.util.IonTextUtils.symbolVariant; +import static com.amazon.ion.util.IonTextUtils.SymbolVariant.OPERATOR; + import static dev.ionfusion.fusion.FusionBool.falseBool; import static dev.ionfusion.fusion.FusionBool.makeBool; import static dev.ionfusion.fusion.FusionBool.trueBool; @@ -308,8 +311,7 @@ void write(Evaluator eval, Appendable out) void write(Evaluator eval, Appendable out, boolean quoteOperators) throws IOException { - if (!quoteOperators - && IonTextUtils.symbolVariant(myContent) == IonTextUtils.SymbolVariant.OPERATOR) + if (!quoteOperators && symbolVariant(myContent) == OPERATOR) { // Inside a sexp, operator symbols like + and = are valid Ion // without quoting. Operator content is guaranteed to be diff --git a/runtime/src/test/fusion/scripts/io.test.fusion b/runtime/src/test/fusion/scripts/io.test.fusion index a2bc2c5ed..e2469b561 100644 --- a/runtime/src/test/fusion/scripts/io.test.fusion +++ b/runtime/src/test/fusion/scripts/io.test.fusion @@ -186,9 +186,26 @@ v))))] (write (quote null.symbol)))) -//============================================================================= + // write: sexps +// Context-switching: operator symbols in lists, structs, and sexps containing non-Ion values. + +// List: operator symbols must be quoted (list is not a sexp context). +(check === "['+', ['+'], {'+':'+'}, (+ {{{void}}})]" +(with_output_to_string +(write (list (quote +) (list (quote +)) (struct "+" (quote +)) (sexp (quote +) (void)))))) + +// Struct: operator symbols in field names and values must be quoted. +(check === "{f:'+'}" +(with_output_to_string +(write (quote {f: '+'})))) + +// Sexp: operator symbols must NOT be quoted; void verified to stay in write mode. +(check === "(+ ['+', {{{void}}}] {'+':\"+\",f:{{{void}}}} (+ {{{void}}}) {{{void}}})" +(with_output_to_string +(write (sexp (quote +) (list (quote +) (void)) (struct "+" "+" "f" (void)) (sexp (quote +) (void)) (void))))) + // Empty and null sexps. (check === "()" (with_output_to_string @@ -230,4 +247,3 @@ v))))] (check === "(a {.} 'with spaces')" (with_output_to_string (write (pair (quote a) (quote 'with spaces'))))) - diff --git a/runtime/src/test/java/dev/ionfusion/fusion/FusionIoTest.java b/runtime/src/test/java/dev/ionfusion/fusion/FusionIoTest.java index 7df859321..d2afce889 100644 --- a/runtime/src/test/java/dev/ionfusion/fusion/FusionIoTest.java +++ b/runtime/src/test/java/dev/ionfusion/fusion/FusionIoTest.java @@ -84,7 +84,6 @@ public void testDisplayGoesToStdout() } - @Test public void testLoadCurrentNamespace() throws Exception @@ -187,6 +186,7 @@ private void ionizeInjectedDom(String ion) assertEquals(iv, container.get(0)); } + @Test public void testIonizeInjectedDom() throws Exception From c986740ed2cf5b6f92e7ad2cebbbb8863e4c76a1 Mon Sep 17 00:00:00 2001 From: ddalaklidhs Date: Wed, 8 Apr 2026 03:14:06 +0300 Subject: [PATCH 5/6] fix indentation in require block, IDE was altering my indentation, so I fixed directly through termninal --- runtime/src/test/fusion/scripts/io.test.fusion | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/runtime/src/test/fusion/scripts/io.test.fusion b/runtime/src/test/fusion/scripts/io.test.fusion index e2469b561..df81f17cf 100644 --- a/runtime/src/test/fusion/scripts/io.test.fusion +++ b/runtime/src/test/fusion/scripts/io.test.fusion @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 (require -"/fusion/experimental/check" -"/testutils" + "/fusion/experimental/check" + "/testutils" ) From 2701e84762059616bb739dd1f7865d9a094a47df Mon Sep 17 00:00:00 2001 From: "Todd V. Jonker" Date: Wed, 8 Apr 2026 12:07:42 -0700 Subject: [PATCH 6/6] Fix indentation in `io.test.fusion` --- .../src/test/fusion/scripts/io.test.fusion | 151 +++++++++--------- .../dev/ionfusion/fusion/FusionIoTest.java | 2 +- 2 files changed, 76 insertions(+), 77 deletions(-) diff --git a/runtime/src/test/fusion/scripts/io.test.fusion b/runtime/src/test/fusion/scripts/io.test.fusion index df81f17cf..b5f182c9d 100644 --- a/runtime/src/test/fusion/scripts/io.test.fusion +++ b/runtime/src/test/fusion/scripts/io.test.fusion @@ -29,35 +29,34 @@ (with_ion_from_string "0 1 2 3" -(lambda () -(check === 0 (read)) -(check === 1 (read)) -(with_ion_from_string " \"hello\" " -(lambda () -(check = "hello" (read)) -(check_true (is_eof (read))))) -(check === 2 (read)) -(check === 3 (read)) -(check_true (is_eof (read))))) + (lambda () + (check === 0 (read)) + (check === 1 (read)) + (with_ion_from_string " \"hello\" " + (lambda () + (check = "hello" (read)) + (check_true (is_eof (read))))) + (check === 2 (read)) + (check === 3 (read)) + (check_true (is_eof (read))))) // This assumes that current_directory is at the project root. (with_ion_from_file (test_data_file "ints.ion") -(lambda () -(check === 0 (read)) -(check === 1 (read)) -(with_ion_from_file (test_data_file "hello.ion") -(lambda () -(check = "hello" (read)) -(check_true (is_eof (read))))) -(check === 2 (read)) -(check === 3 (read)) -(check_true (is_eof (read))))) - + (lambda () + (check === 0 (read)) + (check === 1 (read)) + (with_ion_from_file (test_data_file "hello.ion") + (lambda () + (check = "hello" (read)) + (check_true (is_eof (read))))) + (check === 2 (read)) + (check === 3 (read)) + (check_true (is_eof (read))))) // A nice shortcut for reading a single value. (check = "hello" -(with_ion_from_file (test_data_file "hello.ion") read)) + (with_ion_from_file (test_data_file "hello.ion") read)) (check_true (is_eof eof)) @@ -66,22 +65,22 @@ // Lob I/O (define_check (check_lob_io value) -(let [(lob (ionize_to_blob value))] -(check_pred is_blob lob) -(check_pred (negate is_null) lob) -(let [(v (with_ion_from_lob lob -(thunk -(let [(v (read))] -(check_pred (negate is_eof) v) -(check_pred is_eof (read)) -v))))] -(check === value v)))) + (let [(lob (ionize_to_blob value))] + (check_pred is_blob lob) + (check_pred (negate is_null) lob) + (let [(v (with_ion_from_lob lob + (thunk + (let [(v (read))] + (check_pred (negate is_eof) v) + (check_pred is_eof (read)) + v))))] + (check === value v)))) (map (lambda (v) (check_lob_io v)) representative_ion_data) // Make sure we can read Ion text (check === (quote [only_me]) -(with_ion_from_lob {{"[only_me]"}} read)) + (with_ion_from_lob {{"[only_me]"}} read)) (expect_arity_error (ionize_to_blob)) (expect_arity_error (ionize_to_blob 1 2)) @@ -134,8 +133,8 @@ v))))] (check === "{f:\"a\"}" (display_to_string { f: "a" })) (check_pred (|r| (or (=== "{f:a,g:b}" r) -(=== "{g:b,f:a}" r))) -(display_to_string (quote { f: a, g: b }))) + (=== "{g:b,f:a}" r))) + (display_to_string (quote { f: a, g: b }))) //============================================================================= @@ -146,11 +145,11 @@ v))))] (check === "" (with_output_to_string)) (check === "true12\n\"write\"display" -(with_output_to_string -(display "true") -(displayln 12) -(write "write") -(display "display"))) + (with_output_to_string + (display "true") + (displayln 12) + (write "write") + (display "display"))) //============================================================================= @@ -158,32 +157,32 @@ v))))] // Plain identifier: written as-is, no quotes needed. (check === "quoted_sym" -(with_output_to_string -(write (quote quoted_sym)))) + (with_output_to_string + (write (quote quoted_sym)))) // Symbol requiring quotes (contains spaces): always quoted regardless of context. (check === "'with spaces'" -(with_output_to_string -(write (quote 'with spaces')))) + (with_output_to_string + (write (quote 'with spaces')))) // Annotated symbol: annotation and value both written correctly. (check === "tag::value" -(with_output_to_string -(write (quote tag::value)))) + (with_output_to_string + (write (quote tag::value)))) // Operator symbol outside a sexp: must be quoted. (check === "'+'" -(with_output_to_string -(write (quote '+')))) + (with_output_to_string + (write (quote '+')))) (check === "'+='" -(with_output_to_string -(write (quote +=)))) + (with_output_to_string + (write (quote +=)))) // null symbol (check === "null.symbol" -(with_output_to_string -(write (quote null.symbol)))) + (with_output_to_string + (write (quote null.symbol)))) @@ -193,57 +192,57 @@ v))))] // List: operator symbols must be quoted (list is not a sexp context). (check === "['+', ['+'], {'+':'+'}, (+ {{{void}}})]" -(with_output_to_string -(write (list (quote +) (list (quote +)) (struct "+" (quote +)) (sexp (quote +) (void)))))) + (with_output_to_string + (write (list (quote +) (list (quote +)) (struct "+" (quote +)) (sexp (quote +) (void)))))) // Struct: operator symbols in field names and values must be quoted. (check === "{f:'+'}" -(with_output_to_string -(write (quote {f: '+'})))) + (with_output_to_string + (write (quote {f: '+'})))) // Sexp: operator symbols must NOT be quoted; void verified to stay in write mode. (check === "(+ ['+', {{{void}}}] {'+':\"+\",f:{{{void}}}} (+ {{{void}}}) {{{void}}})" -(with_output_to_string -(write (sexp (quote +) (list (quote +) (void)) (struct "+" "+" "f" (void)) (sexp (quote +) (void)) (void))))) + (with_output_to_string + (write (sexp (quote +) (list (quote +) (void)) (struct "+" "+" "f" (void)) (sexp (quote +) (void)) (void))))) // Empty and null sexps. (check === "()" -(with_output_to_string -(write (sexp)))) + (with_output_to_string + (write (sexp)))) (check === "null.sexp" -(with_output_to_string -(write (quote null.sexp)))) + (with_output_to_string + (write (quote null.sexp)))) // Operator symbols inside a sexp: must NOT be quoted. (check === "(+ 1 2)" -(with_output_to_string -(write (quote (+ 1 2))))) + (with_output_to_string + (write (quote (+ 1 2))))) (check === "(+ =)" -(with_output_to_string -(write (quote (+ =))))) + (with_output_to_string + (write (quote (+ =))))) // Symbol requiring quotes inside a sexp: must still be quoted. (check === "('with spaces')" -(with_output_to_string -(write (quote ('with spaces'))))) + (with_output_to_string + (write (quote ('with spaces'))))) // Annotated value inside a sexp: annotation written correctly. (check === "(note::\"Hello\")" -(with_output_to_string -(write (quote (note::"Hello"))))) + (with_output_to_string + (write (quote (note::"Hello"))))) // Improper sexp (pair): operator symbols in both head and tail unquoted. (check === "(a {.} b)" -(with_output_to_string -(write (pair (quote a) (quote b))))) + (with_output_to_string + (write (pair (quote a) (quote b))))) (check === "(+ {.} =)" -(with_output_to_string -(write (pair (quote +) (quote =))))) + (with_output_to_string + (write (pair (quote +) (quote =))))) // Symbol requiring quotes in improper sexp tail: must still be quoted. (check === "(a {.} 'with spaces')" -(with_output_to_string -(write (pair (quote a) (quote 'with spaces'))))) + (with_output_to_string + (write (pair (quote a) (quote 'with spaces'))))) diff --git a/runtime/src/test/java/dev/ionfusion/fusion/FusionIoTest.java b/runtime/src/test/java/dev/ionfusion/fusion/FusionIoTest.java index d2afce889..7df859321 100644 --- a/runtime/src/test/java/dev/ionfusion/fusion/FusionIoTest.java +++ b/runtime/src/test/java/dev/ionfusion/fusion/FusionIoTest.java @@ -84,6 +84,7 @@ public void testDisplayGoesToStdout() } + @Test public void testLoadCurrentNamespace() throws Exception @@ -186,7 +187,6 @@ private void ionizeInjectedDom(String ion) assertEquals(iv, container.get(0)); } - @Test public void testIonizeInjectedDom() throws Exception