\( \DeclareMathOperator{\tr}{tr} \newcommand\D{\mathrm{d}} \newcommand\E{\mathrm{e}} \newcommand\I{\mathrm{i}} \newcommand\bigOh{\mathcal{O}} \newcommand{\cat}[1]{\mathbf{#1}} \newcommand\curl{\vec{\nabla}\times} \newcommand{\CC}{\mathbb{C}} \newcommand{\NN}{\mathbb{N}} \newcommand{\QQ}{\mathbb{Q}} \newcommand{\RR}{\mathbb{R}} \newcommand{\ZZ}{\mathbb{Z}} % For +---- metric \newcommand{\BDpos}{} \newcommand{\BDneg}{-} \newcommand{\BDposs}{\phantom{-}} \newcommand{\BDnegg}{-} \newcommand{\BDplus}{+} \newcommand{\BDminus}{-} % For -+++ metric \newcommand{\BDpos}{-} \newcommand{\BDposs}{-} \newcommand{\BDneg}{} \newcommand{\BDnegg}{\phantom{-}} \newcommand{\BDplus}{-} \newcommand{\BDminus}{+} \)
UP | HOME

StringStream - SML

Table of Contents

1. Introduction

For debugging purposes, it's useful to have a way to use a string as an input stream instead of a file.

It's also useful to have a "string writer" (analogous to Java's StringStream) for building up a string.

1.1. String reader

We would create a TextPrimIO.reader object, something like:

fun mk_string_reader (source : string) : TextPrimIO.reader =
  let
    val pos = ref 0;
    fun read n =
      let
        val p = !pos;
        val m = min(n, size source - p);
      in
        pos := p + m;
        substring(source, p, m)
      end;
    val name = "<string \"" ^
               (if size source <= 8
                then source
                else (substring(source, 0, 8) ^"...")) ^
               "\">";
  in
    TextPrimIO.RD{name = name,
                  chunkSize = size source,
                  readVec = SOME(read),
                  readArr = NONE,
                  readVecNB = SOME(fn n => SOME(read n)),
                  readArrNB = NONE,
                  block = SOME(fn () => ()),
                  canInput = SOME(fn () => true),
                  avail = SOME(fn () => size source - !pos),
                  getPos = SOME(fn () => !pos),
                  setPos = SOME(fn k =>
                               if 0 <= k andalso k <= size source
                               then pos := k
                               else raise Fail "position out of bounds"),
                  verifyPos = NONE,
                  close = (fn () => ()),
                  ioDesc = NONE}
  end;

This can be found in, e.g., section 8.3 of Andrew W Appel's Proposed interface for Standard ML Stream I/O (dated February 6, 1995).

If we wish to adhere to the Standard Basis documentation more faithfully, we should have a way to check if the stream was closed. Something like:

fun mk_string_reader (source : string) : TextPrimIO.reader =
  let
    val pos = ref 0;
    val is_closed = ref false;
    fun read n =
      if !is_closed then raise IO.ClosedStream
      else
        let
          val p = !pos;
          val m = min(n, size source - p);
        in
          pos := p + m;
          substring(source, p, m)
        end;
    val name = "<string \"" ^
               (if size source <= 8
                then source
                else (substring(source, 0, 8) ^"...")) ^
               "\">";
  in
    TextPrimIO.RD{name = name,
                  chunkSize = size source,
                  readVec = SOME(read),
                  readArr = NONE,
                  readVecNB = SOME(fn n => SOME(read n)),
                  readArrNB = NONE,
                  block = SOME(fn () => ()),
                  canInput = SOME(fn () => !is_closed),
                  avail = SOME(fn () =>
                                  if !is_closed
                                  then raise IO.ClosedStream
                                  else size source - !pos),
                  getPos = SOME(fn () => !pos),
                  setPos = SOME(fn k =>
                               if !is_closed
                               then raise IO.ClosedStream
                               else if 0 <= k andalso k <= size source
                               then pos := k
                               else raise Fail "position out of bounds"),
                  verifyPos = NONE,
                  close = (fn () => is_closed := true),
                  ioDesc = NONE}
  end;

We then have a "smart constructor" to make this an appropriate input stream type:

fun stringReader(source : string) =
  let
    val reader : TextIO.StreamIO.reader = mk_string_reader source;
  in
    TextIO.StreamIO.mkInstream(reader, "")
  end;

Ostensibly, we can use the result when trying to invoke TextIO.StramIO.input1.

1.2. String writer

I found myself looking for something analogous to Java's StringStream, when writing things out.

First, we need to construct a TextPrimIO.writer for the stream, which will just write to a string ref buffer. This is the lowest level to Standard ML's I/O model (hence the TextPrimIO implements the PRIM_IO signature).

We do not need to make the string buffer block upon writing to it, at least I don't think so. A minimal implementation just requires the writeVec to write to a string buffer. For our purposes, a "string buffer" is a reference to a string; "writing to it" amounts to updating the reference by appending to it.

On SML/NJ, it seems that only writeVec' is needed for an unbuffered string stream. MLton also appears to only require writeVec'. I should really test this on other implementations…

(*
Creates a string writer, which writes to the supplied string buffer.

@param: buffer is the underlying string reference which accumulates
        everything the writer writes.

@see: https://smlfamily.github.io/Basis/prim-io.html#SIG:PRIM_IO.writer:TY
@see: https://smlfamily.github.io/Basis/mono-array-slice.html
@see: https://smlfamily.github.io/Basis/mono-array.html
*)
fun stringWriter(buffer : string ref) : TextPrimIO.writer =
  let
    fun writeVec' (v : TextPrimIO.vector_slice)
      = (buffer := (!buffer)^(CharVectorSlice.vector v);
         (CharVectorSlice.length v))
    fun writeArr' (arr : TextPrimIO.array_slice)
      = (buffer:=(!buffer)^(CharArraySlice.vector arr);
         (CharArraySlice.length arr))
    fun writeVecNB' (v : TextPrimIO.vector_slice)
      = (buffer := (!buffer)^(CharVectorSlice.vector v);
         SOME (CharVectorSlice.length v))
    fun writeArrNB' (arr : TextPrimIO.array_slice)
      = (buffer:=(!buffer)^(CharArraySlice.vector arr);
         SOME (CharArraySlice.length arr))
    fun closing () = print ("Trying to close string stream?!?!?\n\n")
  in
    TextPrimIO.WR{
      name = "<string>",
      chunkSize = 1,
      writeVec = SOME writeVec',
      writeArr = SOME writeArr',
      writeVecNB = SOME writeVecNB',
      writeArrNB = SOME writeArrNB',
      block = NONE,
      canOutput = NONE,
      getPos = NONE,
      setPos = NONE,
      endPos = NONE,
      verifyPos = NONE,
      close = closing,
      ioDesc = NONE}
  end;

Now, the Standard ML I/O model has a stream "facade" (if I may borrow such an anachronistic term) wrapping around a "primitive" writer object. We use an outstream when invoking TextIO.output, in order to write to the stream. We will have IO.NO_BUF mode for our string output stream, meaning we write directly to the writer without buffering (thus no need for "flushing" the stream).

We have a smart constructor for a "string stream" from a "string writer":

fun stringStream(buffer : string ref) =
  let
    val writer : TextIO.StreamIO.writer = stringWriter(buffer)
  in
    TextIO.StreamIO.mkOutstream(writer, IO.NO_BUF)
  end;

Now we can show an example usage:

val b1 : string ref = ref "";
val ss1 = stringStream(b1);

fun ss_print (s : string) =
  let
    val cv : CharVector.vector = s
  in
    TextIO.StreamIO.output(ss1, cv)
  end;

ss_print("foobar");

print("\n\n!b1 = "^(!b1)^"\n\n");
    
(* then `!b1` evaluates to "foobar" *)

Another approach to constructing a string stream would be to have its buffer be a reference to a list of strings, but this would be mildly inefficient since it'd be a stack (and thus a reverse would be needed to make the order correct).

Last Updated: Fri, 4 Jul 2025 12:32:40 -0700