diff --git a/runtime/src/main/java/dev/ionfusion/fusion/BaseValue.java b/runtime/src/main/java/dev/ionfusion/fusion/BaseValue.java index 41ef9140..89830d18 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 f0084715..62898ae9 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 + { + // quoteOperators defaults to true for non-BaseValue objects. + 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 ae876a35..c9422e71 100644 --- a/runtime/src/main/java/dev/ionfusion/fusion/FusionSexp.java +++ b/runtime/src/main/java/dev/ionfusion/fusion/FusionSexp.java @@ -1003,6 +1003,21 @@ else if (throwOnConversionFailure) } } + /** + * Writes this sexp using Fusion's write format. + *

+ * 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 @@ -1011,11 +1026,17 @@ void write(Evaluator eval, Appendable out) out.append('('); ImmutablePair pair = this; + boolean first = true; while (true) { - if (pair != this) out.append(' '); + if (!first) out.append(' '); + first = false; - dispatchWrite(eval, out, pair.myHead); + // 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); Object tail = pair.myTail; if (tail instanceof ImmutablePair) @@ -1028,8 +1049,10 @@ else if (tail instanceof EmptySexp) } 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); + dispatchWrite(eval, out, tail, false); break; } } diff --git a/runtime/src/main/java/dev/ionfusion/fusion/FusionSymbol.java b/runtime/src/main/java/dev/ionfusion/fusion/FusionSymbol.java index e99a741e..1c71dd89 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; @@ -304,6 +307,26 @@ void write(Evaluator eval, Appendable out) IonTextUtils.printSymbol(out, myContent); } + @Override + void write(Evaluator eval, Appendable out, boolean quoteOperators) + throws IOException + { + if (!quoteOperators && symbolVariant(myContent) == OPERATOR) + { + // 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 + { + // 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); + } + } + @Override void display(Evaluator eval, Appendable out) throws IOException @@ -421,6 +444,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 54eeeb58..b5f182c9 100644 --- a/runtime/src/test/fusion/scripts/io.test.fusion +++ b/runtime/src/test/fusion/scripts/io.test.fusion @@ -152,4 +152,97 @@ (display "display"))) -"SUCCESS (io.test)" +//============================================================================= +// write: symbols + +// Plain identifier: written as-is, no quotes needed. +(check === "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')))) + +// 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 + (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 + (write (sexp)))) + +(check === "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))))) + +(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 === "(+ {.} =)" + (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')))))