Pijul source control integration extension for Visual Studio Code
import * as cp from 'child_process';
import { EventEmitter } from 'events';
import * as iconv from 'iconv-lite-umd';
import * as path from 'path';
import { difference } from 'set-operations';
import { CancellationToken, QuickDiffProvider, TextDocument, TextDocumentContentProvider, Uri, workspace } from 'vscode';
import { Resource, ResourceStatus } from './resource';
import { dispose, IDisposable, toDisposable } from './utils/disposableUtils';
import { onceEvent } from './utils/eventUtils';
import { createResourceUri } from './utils/fileUtils';

/**
 * Interface representing the information needed to create an instance
 * of the Pijul class.
 */
export interface IPijulOptions {
  path: string
  version: string
}

/**
 * The Pijul class holds information about the Pijul installation
 * in use by the extension which it uses for executing commands with
 * the Pijul CLI. All interactions with the CLI come through this class.
 */
export class Pijul {
  readonly path: string;
  readonly version: string;

  private readonly _onOutput = new EventEmitter();

  /**
   * Accessor for the onOutput event
   */
  get onOutput (): EventEmitter { return this._onOutput; }

  /**
   * Create a new instance of a Pijul object
   * @param options The path to a Pijul installation and its version.
   */
  constructor (options: IPijulOptions) {
    this.path = options.path;
    this.version = options.version;
  }

  /**
   * Opens an existing Pijul repository at the given location
   * @param repository The path to the root of the repository
   */
  open (repository: string): Repository {
    return new Repository(this, repository);
  }

  /**
   * Runs the command to initialize a new pijul repository
   * @param repository The location where the new repository will be created
   */
  async init (repository: string): Promise<void> {
    await this.exec(repository, ['init']);
  }

  /**
   * Executes a new Pijul command in the given working directory.
   * @param cwd The current working directory in which the command will be run
   * @param args The arguments that will be passed to the command
   * @param options The options for the spawned child process
   */
  async exec (cwd: string, args: string[], options: SpawnOptions = {}): Promise<IExecutionResult<string>> {
    // If on Windows, need to remove starting forward slash provided in the CWD by vscode
    if (process.platform === 'win32' && cwd) {
      const driveLetterRegExp = new RegExp('[A-Za-z]:');
      if (cwd.charAt(0) === '/' && driveLetterRegExp.test(cwd.substr(1, 2))) {
        cwd = cwd.substr(1);
      }
    }
    options = Object.assign({ cwd }, options);
    return await this._exec(args, options);
  }

  /**
   * Executes a new pijul command with the given arguments and decodes the execution result from a buffer into a string
   * @param args The arguments that will be passed to the command
   * @param options The options for the spawned child process
   */
  private async _exec (args: string[], options: SpawnOptions = {}): Promise<IExecutionResult<string>> {
    const child = this.spawn(args, options);

    if (options.onSpawn) {
      options.onSpawn(child);
    }

    if (options.input) {
      child.stdin!.end(options.input, 'utf8');
    }

    const bufferResult = await exec(child, options.cancellationToken);

    if (options.log !== false && bufferResult.stderr.length > 0) {
      this.log(`${bufferResult.stderr}\n`);
    }

    let encoding = options.encoding ?? 'utf8';
    encoding = iconv.encodingExists(encoding) ? encoding : 'utf8';

    const result: IExecutionResult<string> = {
      exitCode: bufferResult.exitCode,
      stdout: iconv.decode(bufferResult.stdout, encoding),
      stderr: bufferResult.stderr
    };

    if (bufferResult.exitCode) {
      return await Promise.reject<IExecutionResult<string>>(new Error('Error executing pijul command: "' + bufferResult.stderr + '"'));
    }

    return result;
  }

  /**
   * Spawn a Pijul process using the given arguments to utilize the Pijul CLI
   * @param args The arguments that will be passed to the child process
   * @param options The options that will be used to spawn the new process
   */
  private spawn (args: string[], options: SpawnOptions = {}): cp.ChildProcess {
    if (!this.path) {
      throw new Error('Pijul could not be found in the system.');
    }

    if (!options.stdio && !options.input) {
      options.stdio = ['ignore', null, null]; // Unless provided, ignore stdin and leave default streams for stdout and stderr
    }

    if (!options.log) {
      this.log(`> pijul ${args.join(' ')}\n`);
    }

    return cp.spawn(this.path, args, options);
  }

  /**
   * Emit a log message as an event
   * @param message The message to be emitted
   */
  private log (message: string): void {
    this._onOutput.emit('log', message);
  }
}

/**
 * The repository class represents a single Pijul repository.
 * It holds a reference to the Pijul installation which it uses
 * for interacting with the Pijul CLI. The Repository class exposes
 * functions for all of the Pijul CLI commands which require an
 * existing repository to run.
 */
export class Repository implements TextDocumentContentProvider, QuickDiffProvider {
  /**
   * A dictionary for holding a cache of change hashes to avoid recalculating them each time.
   * Persists between refreshes.
   */
  private readonly changeCache: Record<string, PijulChange> = {};

  // TODO: Create a number of sets which hold the state of the channels in memory.
  // Instead of each log, remote, or channel listing operation interacting with the CLI,
  // the state of the repository will be loaded each refresh and then accessed through the cache.

  /**
   * Create a new repository instance
   * @param _pijul The Pijul instance use to execute commands with the CLI
   * @param repositoryRoot The root directory of the repository
   */
  constructor (
    private readonly _pijul: Pijul,
    private readonly repositoryRoot: string
  ) { }

  /**
   * Accessor for the underlying Pijul instance
   */
  get pijul (): Pijul {
    return this._pijul;
  }

  /**
   * Accessor for the path to the root directory of the repository
   */
  get root (): string {
    return this.repositoryRoot;
  }

  /**
   * Implementation of QuickDiffProvider, sets the scheme of a given URI to 'pijul' so that VS Code can access the last recorded copy of a file
   * @param uri The URI of the real file which VS Code is trying to quick diff
   * @param _token An unused cancellation token
   */
  async provideOriginalResource (uri: Uri, _token: CancellationToken): Promise<Uri> {
    return uri.with({ scheme: 'pijul' });
  }

  /**
   * Implementation of the TextDocumentContentProvider. Depending on the scheme of the given URI, either returns
   * a the last recorded version of a file using `pijul reset` or the stdout of the `pijul change` command.
   * @param uri The URI, with scheme 'pijul' or 'pijul-change', which will be used to determine which text document should be returned.
   * @param token A cancellation token for stopping the asynchronous child process execution
   */
  async provideTextDocumentContent (uri: Uri, token: CancellationToken): Promise<string> {
    if (uri.scheme === 'pijul-change') {
      return await this.getChangeToml(uri.path, token);
    } else {
      // return stdout of a pijul reset dry run, which is the last recorded version of the file
      return (await this._pijul.exec(this.repositoryRoot, ['reset', uri.fsPath, '--dry-run'], { cancellationToken: token })).stdout;
    }
  }

  /**
   * Get the files in the Repository which Pijul is aware of, the results of `pijul ls`
   */
  async getTrackedFiles (): Promise<Uri[]> {
    return (await this.pijul.exec(this.repositoryRoot, ['ls'])).stdout.split('\n').map(f => createResourceUri(f));
  }

  /**
   * Get all the files in the repository, including those which haven't been added to Pijul
   */
  async getNotIgnoredFiles (): Promise<Uri[]> {
    // TODO: More robust handling of ignore files

    let ignoreFile: TextDocument | undefined;
    try {
      ignoreFile = await workspace.openTextDocument(path.join(this.repositoryRoot, '.ignore'));
    } catch (err) {
      try {
        ignoreFile = await workspace.openTextDocument(path.join(this.repositoryRoot, '.pijulignore'));
      } catch (err) {
        // No ignore file exists, continue
      }
    }
    const ignoreGlobs: string[] = [];

    if (ignoreFile) {
      for (let i = 0; i < ignoreFile.lineCount; i++) {
        const line = ignoreFile.lineAt(i).text;
        if (!line.startsWith('#')) {
          ignoreGlobs.push(line);
        }
      }
    }
    // Make sure files in the .pijul folder aren't being tracked
    ignoreGlobs.push('**/.pijul/**');
    const fullIgnoreGlob = '{' + ignoreGlobs.join(',') + '}';
    return await workspace.findFiles('**', fullIgnoreGlob);
  }

  /**
   * Calculate the difference between the tracked files and the full set of files in the repository
   * to determine which of the files are untracked.
   *
   * TODO: Maybe consider adding this functionality to Pijul?
   */
  async getUntrackedFiles (): Promise<Resource[]> {
    const output = (await this.pijul.exec(this.repositoryRoot, ['diff', '--json', '--untracked'])).stdout;
    const files: string[] = JSON.parse(output);
    const resources = files.map(file => new Resource(createResourceUri(file), ResourceStatus.FileAdd));
    return resources;
  }

  /**
   * Get the list of file URIs which have unrecorded changes using `pijul diff`
   *
   * TODO: Fix handling of file deletions
   */
  async getChangedFiles (): Promise<Resource[]> {
    const changedFiles: Resource[] = [];
    const output = (await this.pijul.exec(this.repositoryRoot, ['diff', '--json'])).stdout;
    if (output !== '') {
      const pijulDiff = JSON.parse(output);
      for (const file in pijulDiff) {
        const fileChanges = pijulDiff[file] as IPijulFileDiff[];
        changedFiles.push(new Resource(createResourceUri(file), fileChanges[0].operation as ResourceStatus));
      }
    }
    return changedFiles;
  }

  /**
   * Get the TOML document describing a specific change
   * @param hash The hash of the change
   * @param cancellationToken A token for cancelling the CLI interaction
   */
  async getChangeToml (hash: string, cancellationToken?: CancellationToken): Promise<string> {
    return (await this._pijul.exec(this.repositoryRoot, ['change', hash], { cancellationToken })).stdout;
  }

  /**
   * Get the Pijul changes which the given change is dependent on
   * @param change The change to get the dependencies of
\   */
  async getChangeDependencies (change: PijulChange): Promise<PijulChange[]> {
    const changeToml = await this.getChangeToml(change.hash);
    const dependencyPattern = /[0-9A-Z]{53}/g;
    const dependencies: Set<PijulChange> = new Set();

    let match = dependencyPattern.exec(changeToml);
    while (match) {
      dependencies.add(this.changeCache[match[0]]);
      match = dependencyPattern.exec(changeToml);
    }

    return [...dependencies];
  }

  /**
   * Gets a list of the files which were altered in a change
   * @param change The change to retrieve the altered files for
   */
  async getChangeFiles (change: PijulChange): Promise<PijulFileChange[]> {
    const changeToml = await this.getChangeToml(change.hash);
    // Big complicated regex needs to match both the 'Edit in src/repository.ts' format
    // and the 'File addition: "yarn.lock" format.
    const changePattern = /\d\.\s(?:(?:([A-Za-z]+)\sin\s)(.*):|(.*):\s"(\S+)")/g;
    const fileOperations: Record<string, Set<string>> = {};

    let match = changePattern.exec(changeToml);
    while (match) {
      // Since their are two formats the operation and path can be in, there are four
      // capture groups for two properties. These two statements take whichever is defined.
      const operation = match[1] ?? match[3];
      const path = match[2] ?? match[4];

      if (fileOperations[path]) {
        fileOperations[path].add(operation);
      } else {
        fileOperations[path] = new Set([operation]);
      }
      match = changePattern.exec(changeToml);
    }

    // TODO: cache results
    const files: PijulFileChange[] = [];
    for (const path in fileOperations) {
      files.push(new PijulFileChange(createResourceUri(path), [...fileOperations[path]]));
    }
    return files;
  }

  /**
   * Record all diffs in the given files as a new change
   * @param files The files that will be included in the new change
   * @param message The message for the new change
   */
  async recordChanges (files: Uri[], message: string): Promise<void> {
    await this._pijul.exec(this.repositoryRoot, ['record', ...files.map(f => f.fsPath), '-a', '-m', message]);
  }

  /**
   * Unrecord a change
   * @param change The change that will be unrecorded
   * @param options The options for unrecording a change, indicating if the changes should be reset
   */
  async unrecordChange (change: PijulChange, options: IUnrecordOptions = {}): Promise<void> {
    // TODO: Consider working around issues with the reset flag
    const optionArray: string[] = options.reset ? ['--reset'] : [];
    await this._pijul.exec(this.repositoryRoot, ['unrecord', change.hash, ...optionArray]);
  }

  /**
   * Apply a change to a given channel or the current channel if no channel name is provided
   * @param change The change to apply to a channel
   * @param channelName The name of the channel the change will be applied to
   */
  async applyChange (change: PijulChange, channelName?: string): Promise<void> {
    const additionalArgs: string[] = [];
    if (channelName) {
      additionalArgs.push('--channel', channelName);
    }
    await this._pijul.exec(this.repositoryRoot, ['apply', change.hash, ...additionalArgs]);
  }

  /**
   * Record all diffs from the pristine as a new change
   * @param message The message for the amended change
   */
  async recordAllChanges (message: string): Promise<void> {
    await this._pijul.exec(this.repositoryRoot, ['record', '-a', '-m', message]);
  }

  /**
   * Record all diffs from the pristine, amending the most recent change instead of creating a new one
   * @param message An updated message for the change
   */
  async amendAllChanges (message?: string): Promise<void> {
    if (message) {
      await this._pijul.exec(this.repositoryRoot, ['record', '-a', '--amend', '-m', message]);
    } else {
      await this._pijul.exec(this.repositoryRoot, ['record', '-a', '--amend']);
    }
  }

  /**
   * Add a specific file to the repository for tracking
   * @param path The URI of the file to add
   */
  async addFile (path: Uri): Promise<void> {
    await this._pijul.exec(this.repositoryRoot, ['add', path.fsPath]);
  }

  /**
   * Reset all files in the repository by undoing unrecorded changes
   */
  async resetAll (): Promise<void> {
    await this._pijul.exec(this.repositoryRoot, ['reset']);
  }

  /**
   * Reset specific files in the repository by undoing unrecorded
   */
  async reset (files: Uri[]): Promise<void> {
    await this._pijul.exec(this.repositoryRoot, ['reset', ...files.map(f => f.fsPath)]);
  }

  /**
   * Rename a channel in the repository.
   * @param channel The channel to rename
   * @param newName The new name that will be given to the channel
   */
  async renameChannel (channel: PijulChannel, newName: string): Promise<void> {
    await this._pijul.exec(this.repositoryRoot, ['channel', 'rename', channel.name, newName]);
  }

  /**
   * Switch to the given channel from the current channel
   * @param targetChannel The channel that will be switched to
   */
  async switchChannel (channel: PijulChannel): Promise<void> {
    await this._pijul.exec(this.repositoryRoot, ['channel', 'switch', channel.name]);
  }

  /**
   * Delete a channel
   * @param channel The channel that will be deleted
   */
  async deleteChannel (channel: PijulChannel): Promise<void> {
    await this._pijul.exec(this.repositoryRoot, ['channel', 'delete', channel.name]);
  }

  /**
   * Fork a new channel from the current one
   * @param channelName The name of the new channel
   */
  async forkChannel (channelName: string): Promise<void> {
    await this._pijul.exec(this.repositoryRoot, ['fork', channelName]);
  }

  /**
   * Use the `pijul log` command and parse the results
   * to generate a log of pijul changes.
   * @param channel optional name of a channel to get the log for
   */
  async getLog (channel?: string): Promise<PijulChange[]> {
    const changes: PijulChange[] = [];
    const additionalArgs: string[] = [];

    if (channel) {
      additionalArgs.push('--channel', channel);
    }

    // TODO: Test how this scales to repositories with thousands or hundreds of thousands of changes
    const result = await this._pijul.exec(this.repositoryRoot, ['log', ...additionalArgs]);
    const parsePattern = /Change\s([0-9A-Z]{53})\nAuthor:\s(.*)\nDate:\s(.*)\n\n\s+(.*)/gm;

    do {
      const match = parsePattern.exec(result.stdout);
      if (match === null) break;

      // Skip the first item, which is the entire match and select the individual capture groups
      const [, hash, author, date, message] = match;
      const cachedChange = this.changeCache[hash];
      if (cachedChange) {
        changes.push(cachedChange);
      } else {
        const change = new PijulChange(hash, message.trim(), parsePijulChangeAuthor(author), new Date(date));
        this.changeCache[change.hash] = change;
        changes.push(change);
      }
    } while (true);

    return changes;
  }

  /**
   * Use the `pijul channel` command and parse the results
   * to generate the list of channels in this repository.
   */
  async getChannels (): Promise<PijulChannel[]> {
    // TODO: Test how this scales to repositories with thousands or hundreds of thousands of changes
    const result = await this._pijul.exec(this.repositoryRoot, ['channel']);
    const lines = result.stdout.split(/\r?\n/);
    const channels = lines.filter(line => line.length > 0).map((line) => {
      return new PijulChannel(line.substring(2), line.startsWith('*'));
    });
    return channels;
  }

  /**
   * Compare the changes in another channel against the main channel
   * @param otherChannelName The name of the other channel to compare against
   */
  async compareChannelChanges (otherChannelName: string): Promise<PijulChange[]> {
    // TODO: Caching and other easy optimizations
    return difference(await this.getLog(otherChannelName), await this.getLog(), true);
  }

  /**
   * Get the remotes of this repository with `pijul remote`
   */
  async getRemotes (): Promise<PijulRemote[]> {
    const remotes = (await this._pijul.exec(this.repositoryRoot, ['remote'])).stdout;
    const result = [];
    for (const l of remotes.split(/\r?\n/)) {
      if (l !== '') {
        const i = l.search(':');
        result.push(new PijulRemote(l.slice(i + 1).trim()));
      }
    }
    return result;
  }
}

/**
 * Handles the execution of a child process and the capture of its results, cancelling it if necessary
 * @param child The child process being executed
 * @param cancellationToken A cancellation token that can be used to cancel the child process
 */
async function exec (child: cp.ChildProcess, cancellationToken?: CancellationToken): Promise<IExecutionResult<Buffer>> {
  if (!child.stdout || !child.stderr) {
    throw new Error('Failed to get stdout or stderr from git process.');
  }

  if (cancellationToken?.isCancellationRequested) {
    throw new Error('Cancelled');
  }

  const disposables: IDisposable[] = [];

  // Create handles for stdout and stderr events
  const once = (ee: NodeJS.EventEmitter, name: string, fn: (...args: any[]) => void): void => {
    ee.once(name, fn);
    disposables.push(toDisposable(() => ee.removeListener(name, fn)));
  };

  const on = (ee: NodeJS.EventEmitter, name: string, fn: (...args: any[]) => void): void => {
    ee.on(name, fn);
    disposables.push(toDisposable(() => ee.removeListener(name, fn)));
  };

  // Create a promise representing all the different results of the child process
  let result = Promise.all<any>([
    new Promise<number>((resolve, reject) => {
      once(child, 'error', (err) => (reject(err)));
      once(child, 'exit', resolve);
    }),
    new Promise<Buffer>(resolve => {
      const buffers: Buffer[] = [];
      on(child.stdout!, 'data', (b: Buffer) => buffers.push(b));
      once(child.stdout!, 'close', () => resolve(Buffer.concat(buffers)));
    }),
    new Promise<string>(resolve => {
      const buffers: Buffer[] = [];
      on(child.stderr!, 'data', (b: Buffer) => buffers.push(b));
      once(child.stderr!, 'close', () => resolve(Buffer.concat(buffers).toString('utf8')));
    })
  ]) as Promise<[number, Buffer, string]>;

  if (cancellationToken) {
    // eslint-disable-next-line promise/param-names
    const cancellationPromise = new Promise<[number, Buffer, string]>((_resolve, reject) => {
      onceEvent(cancellationToken.onCancellationRequested)(() => {
        try {
          child.kill();
        } catch (err) {
          // Do nothing
        }

        reject(new Error('Cancelled'));
      });
    });

    result = Promise.race([result, cancellationPromise]);
  }

  try {
    const [exitCode, stdout, stderr] = await result;
    return { exitCode, stdout, stderr };
  } finally {
    dispose(disposables);
  }
}

/**
 * Interface for representing the results of the execution of a child process
 */
export interface IExecutionResult<T extends string | Buffer> {
  exitCode: number
  stdout: T
  stderr: string
}

/**
 * Interface for representing the options for spawning a child process
 */
export interface SpawnOptions extends cp.SpawnOptions {
  input?: string
  encoding?: string
  log?: boolean
  cancellationToken?: CancellationToken
  onSpawn?: (childProcess: cp.ChildProcess) => void
}

export interface IPijulFileDiff {
  operation: string
  line: number
}

/**
 * Class representing a single change in the repository.
 */
export class PijulChange {
  /**
   * Create a new instance of a Pijul change object.
   * @param hash The change hash
   * @param message The message for the change
   * @param author The author of the change
   * @param date The date the change was made
   */
  constructor (
    public readonly hash: string,
    public readonly message: string,
    public readonly author: PijulChangeAuthor,
    public readonly date: Date
  ) { }
}

/**
 * Class representing a change that was made to a file
 */
export class PijulFileChange {
  /**
   * Create a new instance of a pijul file change
   * @param path The path of the file that was changed
   * @param operations The operations that were performed on the file (Edit, Replacement, etc.)
   */
  constructor (
    public readonly path: Uri,
    public readonly operations: string[]
  ) { }
}

/**
   * Creates a new PijulChangeAuthor. This is mostly redundant now and can be removed now that
   * the author field in the pijul change log no longer has the Rust debug format.
   * @param authorString The name or key of the author
   */
export function parsePijulChangeAuthor (authorString: string): PijulChangeAuthor {
  // Now only dis
  return new PijulChangeAuthor(authorString);
}

/**
 * Class representing the Author of a change
 * TODO: Handle multiple authors
 */
export class PijulChangeAuthor {
  /**
   * Creates a new PijulChangeAuthor instance
   */
  constructor (
    public readonly name: string,
    public readonly fullName?: string,
    public readonly email?: string
  ) { }
}

/**
 * Class representing a channel in the repository.
 */
export class PijulChannel {
  /**
   * Create a new instance of a Pijul change object.
   * @param name The change hash
   * @param isCurrent Indicates if the channel is the default channel
   */
  constructor (
    public readonly name: string,
    public readonly isCurrent: boolean
  ) { }
}

/**
 * Class representing a remote repository
 */
export class PijulRemote {
  /**
   * Create a new instance of a PijulRemote object
   * @param url The url of the remote
   */
  constructor (
    public readonly url: string
  ) { }
}

/**
 * Options for unrecording a change
 */
export interface IUnrecordOptions {
  reset?: boolean
}