All Downloads are FREE. Search and download functionalities are using the official Maven repository.

replpp.Operators.scala Maven / Gradle / Ivy

The newest version!
package replpp

import replpp.shaded.os
import replpp.shaded.os.{ProcessInput, ProcessOutput, SubProcess}

import java.io.FileWriter
import java.lang.ProcessBuilder.Redirect
import java.lang.System.lineSeparator
import java.nio.charset.StandardCharsets
import java.nio.file.{Path, Paths}
import scala.jdk.CollectionConverters.*
import scala.util.{Try, Using}
import scala.util.control.NonFatal

/**
 * Operators to redirect output to files or pipe them into external commands / processes,
 * inspired by unix shell redirection and pipe operators: `>`, `>>` and `|`.
 * Naming convention: similar to scala.sys.process we prefix all operators with `#`
 * to avoid naming clashes with more basic operators like `>` for greater-than-comparisons.
 *
 * They are declared as extension types for `Any` and pretty-print the results using our `PPrinter`.
 * List types (IterableOnce, java.lang.Iterable, Array, ...) are being unwrapped (only at the root level).
 * */
object Operators {

  def main(args: Array[String]): Unit = {
    given Colors = Colors.BlackWhite
    val value = "foo"
    val result = value #| "cat"
    println(result)
  }

  /** output from an external command, e.g. when using `#|` */
  case class ProcessResults(stdout: String, stderr: String)

  extension (value: Any)(using Colors) {

    /** Redirect output into file, overriding that file - similar to `>` redirection in unix. */
    def #>(outFile: Path): Unit =
      writeToFile(valueAsString, outFile, append = false)

    /** Redirect output into file, overriding that file - similar to `>` redirection in unix. */
    def #>(outFileName: String): Unit =
      #>(Paths.get(outFileName))

    /** Redirect output into file, appending to that file - similar to `>>` redirection in unix. */
    def #>>(outFile: Path): Unit =
      writeToFile(valueAsString, outFile, append = true)

    /** Redirect output into file, appending to that file - similar to `>>` redirection in unix. */
    def #>>(outFileName: String): Unit =
      #>>(Paths.get(outFileName))

    /**
     * Pipe output into an external process, i.e. pass the valueAsString into the command's InputStream.
     * Returns a concatenation of the stdout and stderr of the external command.
     * Executing an external command may fail, and this will throw an exception in that case.
     * see `#|^` for a variant that inherits IO (e.g. for `less`)
     */
    def #|(commandAndArguments: String*): String = {
      val ProcessResults(stdout, stderr) = pipeToCommand(valueAsString, commandAndArguments, inheritIO = false).get
      Seq(stdout, stderr).filter(_.nonEmpty).mkString(lineSeparator)
    }

    /**
     * Pipe output into an external process, i.e. pass the valueAsString into the command's InputStream.
     * Executing an external command may fail, and this will throw an exception in that case.
     * This is a variant of `#|` which inherits IO (e.g. for `less`) - therefor it doesn't capture stdout/stderr. */
    def #|^(commandAndArguments: String*): Unit =
      pipeToCommand(valueAsString, commandAndArguments, inheritIO = true).get


    /**
     * If `value` is a list-type: unwrap it (only at the root level).
     * Then, pretty-print the results using our `PPrinter`.
     * This is to ensure we get the same output as we would get on the REPL (apart from the list-unwrapping).
     */
    private def valueAsString: String = {
      val topLevelListTypeMaybe: Option[Iterator[?]] =
        value match {
          case iter: IterableOnce[_] => Some(iter.iterator)
          case iter: java.lang.Iterable[_] => Some(iter.iterator.asScala)
          case iter: java.util.Iterator[_] => Some(iter.asScala)
          case array: Array[_] => Some(array.iterator)
          case _ => None
        }

      topLevelListTypeMaybe match {
        case None =>
          render(value)
        case Some(iter) =>
          iter.map(render).mkString(lineSeparator)
      }
    }

    /**
     * Pretty-print the results using our `PPrinter`. Special handling for top level strings: render without quotes
     */
    private def render(obj: Any): String = {
      obj match {
        case string: String =>
          string
        case other =>
          PPrinter(
            other,
            nocolors = summon[Colors] == Colors.BlackWhite
          )
      }
    }
  }

  /**
   * Pipe output into an external process, i.e. pass the value into the command's InputStream.
   * Executing an external command may fail, hence returning a `Try`.
   *
   * @param inheritIO : set to true for commands like `less` that are supposed to capture the entire IO
   */
  def pipeToCommand(value: String, commandAndArguments: Seq[String], inheritIO: Boolean): Try[ProcessResults] = {
    val stdout = new StringBuilder
    val stderr = new StringBuilder

    Try {
      os.proc(commandAndArguments).call(
        stdin = pipeInput(value),
        stdout =
          if (inheritIO) os.Inherit
          else lineReader(stdout),
        stderr =
          if (inheritIO) os.Inherit
          else lineReader(stderr),
      )

      ProcessResults(stdout.result(), stderr.result())
    }
  }

  private def pipeInput(value: String) = new ProcessInput {
    def redirectFrom: Redirect = ProcessBuilder.Redirect.PIPE

    def processInput(stdin: => SubProcess.InputStream): Option[Runnable] = {
      Some { () =>
        val bytes = value.getBytes(StandardCharsets.UTF_8)
        val chunkSize = 8192
        var remaining = bytes.length
        var pos = 0
        var stopped = false
        Using.resource(stdin) { stdin =>
          while (remaining > 0 && !stopped) {
            val currentWindow = math.min(remaining, chunkSize)
            try {
              stdin.buffered.write(value, pos, currentWindow)
              pos += currentWindow
              remaining -= currentWindow
            } catch {
              case t: Throwable =>
                // most likely the user exited the subprocess
                stopped = true
            }
          }
          // flush stdin, but ignore errors
          try {stdin.flush()} catch { case NonFatal(_) => /*ignore*/ }
        }
      }
    }
  }

  private def lineReader(stringBuilder: StringBuilder): os.ProcessOutput = {
    os.ProcessOutput.Readlines { s =>
      if (stringBuilder.nonEmpty) {
        stringBuilder.addAll(lineSeparator)
      }
      stringBuilder.addAll(s)
    }
  }

  private def writeToFile(value: String, outFile: Path, append: Boolean): Unit = {
    Using.resource(new FileWriter(outFile.toFile, append)) { fw =>
      fw.write(value)
      /* The convention on UNIX-like systems is that text files are supposed to end with a new-line.
       * This is mostly a side-effect of the fact that UNIX tools must end their output with a newline if they
       * don't want to mess up the prompt of the shell. And then piping the output of these tools to a file
       * means these files always end with a newline. And so appending on the shell also just needs to do
       * open(.., O_APPEND) and the appended output will automatically start in a new line.
       */
      fw.write(lineSeparator)
    }
  }

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy