Skip to content

Commit

Permalink
Print methods of the Console effect should not depend on Abort[IOExce…
Browse files Browse the repository at this point in the history
…ption] (#1069)

Fixes #1037 

### Problem
The Scala `Console.println` doesn't throw an `IOException`. The method
uses the Java `PrintStream` type, which fails silently in case of
errors. So, the `kyo.Console` effect should not depend upon an
`Abort[IOException]` for printing text on the console.

We should rewrite the code such that the examples in the documentation
will change accordingly:

```scala
val b: Unit < IO = Console.print("ok")

// Print to stdout with a new line
val c: Unit < IO = Console.printLine("ok")

// Print to stderr
val d: Unit < IO = Console.printErr("fail")

// Print to stderr with a new line
val e: Unit < IO = Console.printLineErr("fail")

// Explicitly specifying the 'Console' implementation
val f: Unit < IO = Console.let(Console.live)(e)
```

See [PrintWriter and PrintStream never throw
IOExceptions](https://stackoverflow.com/questions/297303/printwriter-and-printstream-never-throw-ioexceptions)
for further details.

### Solution
I removed the `Abort[IOException]` effect from the return type of the
`kyo.Console` methods concerning printing. Moreover, I added the
following method, which returns whether the output or the error stream
behind the `scala.Console` encountered an `IOException` error:

```scala 3
def checkErrors(using Frame): Boolean < IO
```
  • Loading branch information
rcardin authored Feb 5, 2025
1 parent 2696d97 commit cdf9406
Show file tree
Hide file tree
Showing 2 changed files with 114 additions and 33 deletions.
89 changes: 62 additions & 27 deletions kyo-core/shared/src/main/scala/kyo/Console.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ import java.io.EOFException
import java.io.IOException

/** Represents a console for input and output operations.
*
* The methods that print to the console output and error streams ([[print]], [[printErr]], [[printLine]], [[printLineErr]]) don't return
* an [[Abort]] effect because they don't throw exceptions. The behavior is the same as the standard Scala `scala.Console` methods, which
* don't throw exceptions either. The cause is the underlying Java `PrintStream` class implementation, which doesn't throw exceptions when
* writing to the console output or error streams (see
* [[https://stackoverflow.com/questions/297303/printwriter-and-printstream-never-throw-ioexceptions PrintWriter and PrintStream never throw IOExceptions]]
* for more details).
*
* To check if an error occurred in the console output or error streams, use the [[checkErrors]], which returns a boolean indicating if an
* error occurred.
*/
final case class Console(unsafe: Console.Unsafe):

Expand All @@ -19,34 +29,41 @@ final case class Console(unsafe: Console.Unsafe):
* @param s
* The string to print.
*/
def print(s: Text)(using Frame): Unit < (IO & Abort[IOException]) = IO.Unsafe(Abort.get(unsafe.print(s.show)))
def print(s: Text)(using Frame): Unit < IO = IO.Unsafe(unsafe.print(s.show))

/** Prints a string to the console's error stream without a newline.
*
* @param s
* The string to print to the error stream.
*/
def printErr(s: Text)(using Frame): Unit < (IO & Abort[IOException]) = IO.Unsafe(Abort.get(unsafe.printErr(s.show)))
def printErr(s: Text)(using Frame): Unit < IO = IO.Unsafe(unsafe.printErr(s.show))

/** Prints a string to the console followed by a newline.
*
* @param s
* The string to print.
*/
def println(s: Text)(using Frame): Unit < (IO & Abort[IOException]) = IO.Unsafe(Abort.get(unsafe.printLine(s.show)))
def println(s: Text)(using Frame): Unit < IO = IO.Unsafe(unsafe.printLine(s.show))

/** Prints a string to the console's error stream followed by a newline.
*
* @param s
* The string to print to the error stream.
*/
def printLineErr(s: Text)(using Frame): Unit < (IO & Abort[IOException]) = IO.Unsafe(Abort.get(unsafe.printLineErr(s.show)))
def printLineErr(s: Text)(using Frame): Unit < IO = IO.Unsafe(unsafe.printLineErr(s.show))

/** Checks if an error occurred in the console output or error streams.
*
* @return
* True if an error occurred, false otherwise.
*/
def checkErrors(using Frame): Boolean < IO = IO.Unsafe(unsafe.checkErrors)

/** Flushes the console output streams.
*
* This method ensures that any buffered output is written to the console.
*/
def flush(using Frame): Unit < (IO & Abort[IOException]) = IO.Unsafe(Abort.get(unsafe.flush()))
def flush(using Frame): Unit < IO = IO.Unsafe(unsafe.flush())
end Console

/** Companion object for Console, providing utility methods and a live implementation.
Expand All @@ -60,11 +77,12 @@ object Console:
def readLine()(using AllowUnsafe) =
Result.catching[IOException](Maybe(scala.Console.in.readLine()))
.flatMap(_.toResult(Result.fail(new EOFException("Consoles.readLine failed."))))
def print(s: String)(using AllowUnsafe) = Result.catching[IOException](scala.Console.out.print(s))
def printErr(s: String)(using AllowUnsafe) = Result.catching[IOException](scala.Console.err.print(s))
def printLine(s: String)(using AllowUnsafe) = Result.catching[IOException](scala.Console.out.println(s))
def printLineErr(s: String)(using AllowUnsafe) = Result.catching[IOException](scala.Console.err.println(s))
def flush()(using AllowUnsafe) = Result.catching[IOException](scala.Console.flush())
def print(s: String)(using AllowUnsafe) = scala.Console.out.print(s)
def printErr(s: String)(using AllowUnsafe) = scala.Console.err.print(s)
def printLine(s: String)(using AllowUnsafe) = scala.Console.out.println(s)
def printLineErr(s: String)(using AllowUnsafe) = scala.Console.err.println(s)
def checkErrors(using AllowUnsafe): Boolean = scala.Console.out.checkError() || scala.Console.err.checkError()
def flush()(using AllowUnsafe) = scala.Console.flush()
)

private val local = Local.init(live)
Expand Down Expand Up @@ -153,10 +171,18 @@ object Console:
val stdErr = new StringBuffer
val proxy =
new Proxy(console.unsafe):
override def print(s: String)(using AllowUnsafe) = Result.succeed(stdOut.append(s)).unit
override def printErr(s: String)(using AllowUnsafe) = Result.succeed(stdErr.append(s)).unit
override def printLine(s: String)(using AllowUnsafe) = Result.succeed(stdOut.append(s + "\n")).unit
override def printLineErr(s: String)(using AllowUnsafe) = Result.succeed(stdErr.append(s + "\n")).unit
override def print(s: String)(using AllowUnsafe) =
stdOut.append(s)
()
override def printErr(s: String)(using AllowUnsafe) =
stdErr.append(s)
()
override def printLine(s: String)(using AllowUnsafe) =
stdOut.append(s + "\n")
()
override def printLineErr(s: String)(using AllowUnsafe) =
stdErr.append(s + "\n")
()
let(Console(proxy))(v)
.map(r => IO((Out(stdOut.toString(), stdErr.toString()), r)))
}
Expand All @@ -181,41 +207,49 @@ object Console:
* @param v
* The value to print.
*/
def print[A](v: A)(using Frame): Unit < (IO & Abort[IOException]) =
IO.Unsafe.withLocal(local)(console => Abort.get(console.unsafe.print(toString(v))))
def print[A](v: A)(using Frame): Unit < IO =
IO.Unsafe.withLocal(local)(console => console.unsafe.print(toString(v)))

/** Prints a value to the console's error stream without a newline.
*
* @param v
* The value to print to the error stream.
*/
def printErr[A](v: A)(using Frame): Unit < (IO & Abort[IOException]) =
IO.Unsafe.withLocal(local)(console => Abort.get(console.unsafe.printErr(toString(v))))
def printErr[A](v: A)(using Frame): Unit < IO =
IO.Unsafe.withLocal(local)(console => console.unsafe.printErr(toString(v)))

/** Prints a value to the console followed by a newline.
*
* @param v
* The value to print.
*/
def printLine[A](v: A)(using Frame): Unit < (IO & Abort[IOException]) =
IO.Unsafe.withLocal(local)(console => Abort.get(console.unsafe.printLine(toString(v))))
def printLine[A](v: A)(using Frame): Unit < IO =
IO.Unsafe.withLocal(local)(console => console.unsafe.printLine(toString(v)))

/** Prints a value to the console's error stream followed by a newline.
*
* @param v
* The value to print to the error stream.
*/
def printLineErr[A](v: A)(using Frame): Unit < (IO & Abort[IOException]) =
IO.Unsafe.withLocal(local)(console => Abort.get(console.unsafe.printLineErr(toString(v))))
def printLineErr[A](v: A)(using Frame): Unit < IO =
IO.Unsafe.withLocal(local)(console => console.unsafe.printLineErr(toString(v)))

/** Checks if an error occurred in the console output or error streams.
*
* @return
* True if an error occurred, false otherwise.
*/
def checkErrors(using Frame): Boolean < IO = IO.Unsafe.withLocal(local)(console => console.unsafe.checkErrors)

/** WARNING: Low-level API meant for integrations, libraries, and performance-sensitive code. See AllowUnsafe for more details. */
abstract class Unsafe:
def readLine()(using AllowUnsafe): Result[IOException, String]
def print(s: String)(using AllowUnsafe): Result[IOException, Unit]
def printErr(s: String)(using AllowUnsafe): Result[IOException, Unit]
def printLine(s: String)(using AllowUnsafe): Result[IOException, Unit]
def printLineErr(s: String)(using AllowUnsafe): Result[IOException, Unit]
def flush()(using AllowUnsafe): Result[IOException, Unit]
def print(s: String)(using AllowUnsafe): Unit
def printErr(s: String)(using AllowUnsafe): Unit
def printLine(s: String)(using AllowUnsafe): Unit
def printLineErr(s: String)(using AllowUnsafe): Unit
def checkErrors(using AllowUnsafe): Boolean
def flush()(using AllowUnsafe): Unit
def safe: Console = Console(this)
end Unsafe

Expand All @@ -225,6 +259,7 @@ object Console:
def printErr(s: String)(using AllowUnsafe) = underlying.printErr(s)
def printLine(s: String)(using AllowUnsafe) = underlying.printLine(s)
def printLineErr(s: String)(using AllowUnsafe) = underlying.printLineErr(s)
def checkErrors(using AllowUnsafe): Boolean = underlying.checkErrors
def flush()(using AllowUnsafe) = underlying.flush()
end Proxy

Expand Down
58 changes: 52 additions & 6 deletions kyo-core/shared/src/test/scala/kyo/ConsoleTest.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
package kyo

import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.OutputStream
import java.io.PrintStream
import java.io.StringWriter

class ConsoleTest extends Test:

case class Obj(a: String)
Expand Down Expand Up @@ -34,6 +40,40 @@ class ConsoleTest extends Test:
}
}

"checkErrors on out channel" in {
val buffer = new PrintStream(new OutputStream:
override def write(b: Int): Unit = throw IOException())
scala.Console.withOut(buffer) {
import AllowUnsafe.embrace.danger
val (r1, r2) =
IO.Unsafe.evalOrThrow {
for
r1 <- Abort.run(Console.print("test"))
r2 <- Abort.run(Console.checkErrors)
yield (r1, r2)
}
assert(r1.isSuccess && r2.isSuccess)
assert(r2.getOrThrow)
}
}

"checkErrors on err channel" in {
val buffer = new PrintStream(new OutputStream:
override def write(b: Int): Unit = throw IOException())
scala.Console.withErr(buffer) {
import AllowUnsafe.embrace.danger
val (r1, r2) =
IO.Unsafe.evalOrThrow {
for
r1 <- Abort.run(Console.printErr("test"))
r2 <- Abort.run(Console.checkErrors)
yield (r1, r2)
}
assert(r1.isSuccess && r2.isSuccess)
assert(r2.getOrThrow)
}
}

"live" in {
val output = new StringBuilder
val error = new StringBuilder
Expand Down Expand Up @@ -90,14 +130,19 @@ class ConsoleTest extends Test:
assert(testUnsafe.printlnErrs.head == "test error line")
}

"should check errors correctly" in {
val testUnsafe = new TestUnsafeConsole(error = true)
assert(testUnsafe.checkErrors)
}

"should convert to safe Console" in {
val testUnsafe = new TestUnsafeConsole()
val safeConsole = testUnsafe.safe
assert(safeConsole.isInstanceOf[Console])
}
}

class TestUnsafeConsole(input: String = "") extends Console.Unsafe:
class TestUnsafeConsole(input: String = "", error: Boolean = false) extends Console.Unsafe:
var readlnInput = input
var prints = List.empty[String]
var printErrs = List.empty[String]
Expand All @@ -108,17 +153,18 @@ class ConsoleTest extends Test:
Result.succeed(readlnInput)
def print(s: String)(using AllowUnsafe) =
prints = s :: prints
Result.unit
()
def printErr(s: String)(using AllowUnsafe) =
printErrs = s :: printErrs
Result.unit
()
def printLine(s: String)(using AllowUnsafe) =
printlns = s :: printlns
Result.unit
()
def printLineErr(s: String)(using AllowUnsafe) =
printlnErrs = s :: printlnErrs
Result.unit
def flush()(using AllowUnsafe) = Result.unit
()
def checkErrors(using AllowUnsafe): Boolean = error
def flush()(using AllowUnsafe) = ()
end TestUnsafeConsole

end ConsoleTest

0 comments on commit cdf9406

Please sign in to comment.