IdAShell.java

/*
 * Copyright © 2023 Mark Raynsford <code@io7m.com> https://www.io7m.com
 *
 * Permission to use, copy, modify, and/or distribute this software for any
 * purpose with or without fee is hereby granted, provided that the above
 * copyright notice and this permission notice appear in all copies.
 *
 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
 * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
 * IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 */

package com.io7m.idstore.shell.admin.internal;

import com.io7m.idstore.admin_client.api.IdAClientSynchronousType;
import com.io7m.idstore.shell.admin.IdAShellType;
import com.io7m.idstore.shell.admin.IdAShellValueConverters;
import com.io7m.jmulticlose.core.CloseableCollection;
import com.io7m.jmulticlose.core.CloseableCollectionType;
import com.io7m.jmulticlose.core.ClosingResourceFailedException;
import com.io7m.quarrel.core.QCommandContextType;
import com.io7m.quarrel.core.QCommandOrGroupType;
import com.io7m.quarrel.core.QCommandParserConfiguration;
import com.io7m.quarrel.core.QCommandParsers;
import com.io7m.quarrel.core.QCommandStatus;
import com.io7m.quarrel.core.QCommandType;
import com.io7m.quarrel.core.QErrorFormatting;
import com.io7m.quarrel.core.QException;
import com.io7m.quarrel.core.QLocalization;
import com.io7m.quarrel.core.QLocalizationType;
import com.io7m.seltzer.api.SStructuredErrorType;
import org.jline.reader.EndOfFileException;
import org.jline.reader.LineReader;
import org.jline.reader.MaskingCallback;
import org.jline.reader.UserInterruptException;
import org.jline.terminal.Terminal;

import java.io.PrintWriter;
import java.util.Collection;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.SortedMap;
import java.util.TreeMap;

import static com.io7m.quarrel.core.QCommandStatus.FAILURE;
import static com.io7m.quarrel.core.QCommandStatus.SUCCESS;

/**
 * The basic shell.
 */

public final class IdAShell implements IdAShellType
{
  private final CloseableCollectionType<ClosingResourceFailedException> resources;
  private final IdAClientSynchronousType client;
  private final LineReader reader;
  private final PrintWriter writer;
  private final QCommandParserConfiguration parserConfiguration;
  private final QCommandParsers parsers;
  private final QLocalizationType localizer;
  private final SortedMap<String, IdAShellCmdType> commandsNamed;
  private final SortedMap<String, QCommandOrGroupType> commandsView;
  private final IdAShellOptions options;
  private final Terminal terminal;
  private volatile QCommandStatus status;

  /**
   * The basic shell.
   *
   * @param inClient        The client
   * @param inOptions       The shell options
   * @param inCommandsNamed The named commands
   * @param inReader        The line reader
   * @param inTerminal      The terminal
   * @param inWriter        The writer
   */

  public IdAShell(
    final IdAClientSynchronousType inClient,
    final IdAShellOptions inOptions,
    final Terminal inTerminal,
    final PrintWriter inWriter,
    final Map<String, IdAShellCmdType> inCommandsNamed,
    final LineReader inReader)
  {
    this.client =
      Objects.requireNonNull(inClient, "client");
    this.options =
      Objects.requireNonNull(inOptions, "options");
    this.terminal =
      Objects.requireNonNull(inTerminal, "terminal");
    this.writer =
      Objects.requireNonNull(inWriter, "writer");
    this.commandsNamed =
      new TreeMap<>(
        Objects.requireNonNull(inCommandsNamed, "commandsNamed")
      );
    this.commandsView =
      new TreeMap<>(this.commandsNamed);
    this.reader =
      Objects.requireNonNull(inReader, "reader");
    this.resources =
      CloseableCollection.create();
    this.parsers =
      new QCommandParsers();
    this.parserConfiguration =
      new QCommandParserConfiguration(
        IdAShellValueConverters.get(),
        QCommandParsers.emptyResources()
      );
    this.localizer =
      QLocalization.create(Locale.getDefault());
    this.status =
      SUCCESS;

    this.resources.add(this.client);
    this.resources.add(this.terminal);
    this.resources.add(this.writer);
    this.resources.add(this.resources);
  }

  @Override
  public void close()
    throws Exception
  {
    this.resources.close();
  }

  @Override
  public Collection<QCommandType> commands()
  {
    return this.commandsNamed.values()
      .stream()
      .map((IdAShellCmdType x) -> (QCommandType) x)
      .toList();
  }

  @Override
  public void run()
  {
    while (true) {
      try {
        this.runForOneLine();
      } catch (final EndOfFileException e) {
        break;
      } catch (final ShellCommandFailed e) {
        this.status = FAILURE;
        if (this.options.terminateOnErrors().get()) {
          break;
        }
      }
    }
  }

  @Override
  public int exitCode()
  {
    return this.status.exitCode();
  }

  private static final class ShellCommandFailed
    extends Exception
  {
    ShellCommandFailed()
    {

    }

    ShellCommandFailed(
      final Exception ex)
    {
      super(ex);
    }
  }

  private void runForOneLine()
    throws EndOfFileException, ShellCommandFailed
  {
    String line = null;

    try {
      line = this.reader.readLine(
        "[idstore]# ",
        null,
        (MaskingCallback) null,
        null);
    } catch (final UserInterruptException e) {
      // Ignore
    }

    if (line == null) {
      this.status = SUCCESS;
      return;
    }

    line = line.trim();
    if (line.isEmpty()) {
      this.status = SUCCESS;
      return;
    }

    final var parsed =
      this.reader.getParser()
        .parse(line, 0);

    final var commandName = parsed.word();
    if (!this.commandsNamed.containsKey(commandName)) {
      this.writer.append("Unrecognized command: ");
      this.writer.append(commandName);
      this.writer.println();
      this.writer.flush();
      throw new ShellCommandFailed();
    }

    final var command =
      this.commandsNamed.get(commandName);

    final var arguments =
      parsed.words()
        .stream()
        .skip(1L)
        .toList();

    final var parser =
      this.parsers.create(this.parserConfiguration);

    final QCommandContextType context;
    try {
      context = parser.execute(
        this.commandsView,
        this.writer,
        command,
        arguments
      );
    } catch (final QException ex) {
      this.formatException(ex);
      throw new ShellCommandFailed(ex);
    }

    try {
      this.status = command.onExecute(context);
    } catch (final InterruptedException e) {
      Thread.currentThread().interrupt();
    } catch (final Exception e) {
      if (e instanceof final SStructuredErrorType<?> q) {
        this.formatException(q);
      }

      e.printStackTrace(this.writer);
      this.writer.println();
      this.writer.flush();
      throw new ShellCommandFailed(e);
    }
  }

  private void formatException(
    final SStructuredErrorType<?> ex)
  {
    this.writer.printf("Error: ");

    QErrorFormatting.format(
      this.localizer, ex, s -> {
        this.writer.append(s);
        this.writer.println();
        this.writer.flush();
      }
    );

    if (ex instanceof final QException q) {
      q.extraErrors().forEach(e -> {
        QErrorFormatting.format(
          this.localizer, e, s -> {
            this.writer.append(s);
            this.writer.println();
            this.writer.flush();
          }
        );
      });
    }
  }

  @Override
  public String toString()
  {
    return "[IdAShell 0x%s]"
      .formatted(Integer.toUnsignedString(this.hashCode(), 16));
  }
}