lib/bookmarkFacilitator.js

import CommandRunner from './commandRunner.js';
import DiffInteraction from './diffInteraction.js';
import { NoEditor } from './editor.js';
import GitInteraction from './gitInteraction.js';

const UNTRACKED_WINDOW_SIZE = 15;

class MarkNotFound extends Error {
  constructor(props) {
    super();
    Object.assign(this, props);
  }
}

/**
 * @typedef {Object} Bookmark
 *
 * @property {string} file
 *    Path within the project to the file
 * @property {number} line
 *    Line number (1-based) in file when bookmark was constructed
 * @property {string} text
 *    Text marked by bookmark
 * @property {Array.<Bookmark>} [children]
 *    Child bookmarks
 * @property {object} [peg]
 *    Persistent location identity within Git repository
 * @property {string} peg.commit
 *    Commit in which bookmarked line exists
 * @property {number} peg.line
 *    Line number within *peg.commit* version of *file*
 */

/**
 * @summary Class implementing bookmark-related operations
 *
 * @property {Logger} logger
 *    Logger used for error and warning messages
 * @property {Editor} editor
 *    Editor integration object, providing access to unsaved file content
 * @property {GitInteraction} gitOps
 *    Used to execute `git` commands
 * @property {DiffInteraction} diffOps
 *    Used to execute `diff`
 */
class BookmarkFacilitator {
  /**
   * Construct an instance
   *
   * @param {object} [kwargs]
   * @param {Editor} [kwargs.editor]
   *    Object used for interacting with the conceptual editor that might hold
   *    live changes to a given file in the working tree
   * @param {GitInteraction} [kwargs.gitOps]
   *    Alternate implementation of Git operations
   * @param {ToolkitRunnerFunc} [kwargs.runGitCommand]
   *    Alternate command runner for executing `git` program used to construct
   *    a {@link GitInteraction} object if *kwargs.gitOps* is not given
   * @param {DiffInteraction} [kwargs.diffOps]
   *    Alternate implementation of diff operations
   * @param {CommandRunnerFunc} [kwargs.runDiffCommand]
   *    Alternate command runner for executing `diff` program used to construct
   *    a {@link DiffInteraction} object if *kwargs.diffOps* is not given
   * @param {object} [kwargs.toolOptions={}]
   *    Tool options passed to {@link CommandRunner}, used if functions for
   *    invoking `git` or `diff` are needed
   * @param {Logger} [kwargs.logger=console]
   *    A `console`-like object used for logging warnings and errors
   *
   * @description
   * A BookmarkFacilitator needs a {@link GitInteraction} and a
   * {@link DiffInteraction} for carrying out its various methods.  If these
   * are not provided in the *kwargs.gitOps* and *kwargs.diffOps* parameters,
   * they will be constructed from the parameters that are provided.
   *
   * Construction of a {@link GitInteraction} requires a `runGitCommand`
   * which, if not provided in *kwargs.runGitCommand*, is constructed based
   * on *kwargs.toolOptions* (though passing `usesSubcommands` as `true`).
   * Similarly, construction of a {@link DiffInteraction} requires a
   * `runDiffCommand` which, if not provided in *kwargs.runDiffCommand*,
   * is constructed based on *kwargs.toolOptions* (though passing
   * `usesSubcommands` as `false`).
   */
  constructor({ editor, gitOps, runGitCommand, diffOps, runDiffCommand, toolOptions = {}, logger = console } = {}) {
    this.logger = logger;
    this.editor = editor || new NoEditor({...toolOptions});
    this.gitOps = gitOps || new GitInteraction({
      runGitCommand: runGitCommand || CommandRunner('git', {
        ...toolOptions,
        usesSubcommands: true,
      }),
    });
    this.diffOps = diffOps || new DiffInteraction({
      runDiffCommand: runDiffCommand || CommandRunner('diff', {
        ...toolOptions,
        usesSubcommands: false,
      }),
    });
  }
  
  /**
   * @summary Find the location of a bookmark in the current file content
   *
   * @param {Bookmark} bookmark
   *    Bookmark whose current location to determine
   * @returns {Promise.<{file: string, line: number, col: number}>}
   */
  async currentLocation({file: filePath, line, markText: text, peg: gitPeg}) {
    const editBuffer = await this.editor.open(filePath);
    
    const rowHasText = (i) => {
      const lineText = editBuffer.lineText(i);
      return lineText && lineText.includes(text);
    };
    
    return new Promise((resolve, reject) => {
      const findAndReportTextInRow = (i) => {
        if (rowHasText(i)) {
          return resolve({
            file: filePath,
            line: i,
            col: editBuffer.lineText(i).indexOf(text) + 1,
          }) || true;
        }
      };
      
      const reportMarkLocationWithoutTracking = () => {
        if (!findAndReportTextInRow(line)) {
          for (let i = 1; i <= UNTRACKED_WINDOW_SIZE; ++i) {
            if (findAndReportTextInRow(line + i) || findAndReportTextInRow(line - i)) {
              return;
            }
          }
        }
      };
      
      if (gitPeg) {
        return this.retrieveBlameMatchLine(filePath, gitPeg)
          .then(({ line }) => {
            let val;
            val = findAndReportTextInRow(line);
            if (!val) {
              this.logger.warn(`blame was wrong, text %o not in line %d`, text, line);
              throw new MarkNotFound({ file: filePath, line, markText: text });
            }
          })
          .catch((e) => {
            if (!(e instanceof MarkNotFound) && !(e && e.code === 'LineNotFound')) {
              this.logger.error(e);
            }
            return this.computeCurrentLineRange(filePath, gitPeg)
            .then(({ start, prime, end }) => {
              if (findAndReportTextInRow(prime)) return;
              const iLimit = Math.max(prime - start, end - prime);
              for (let i = 1; i <= iLimit; ++i) {
                if (start <= prime - i && findAndReportTextInRow(prime - i)) return;
                if (prime + i < end && findAndReportTextInRow(prime + i)) return;
              }
              throw new MarkNotFound({ file: filePath, start, end });
            })
          })
          .catch((e) => {
            if (!(e instanceof MarkNotFound)) {
              this.logger.error(e);
            }
            reportMarkLocationWithoutTracking(e);
          })
          .finally(() => {
            reject(new MarkNotFound({ file: filePath, line, markText: text }));
          })
          ;
      }
      
      reportMarkLocationWithoutTracking();
      reject(new MarkNotFound({ file: filePath, line, markText: text }));
    });
  }
  
  /**
   * @summary Compute *peg* for bookmark
   *
   * @param {string} filePath
   *    Path of file
   * @param {number} currentLine
   *    Line (1-based) of file
   * @param {object} [kwargs]
   * @param {string} [kwargs.commit]
   *    Start point for the search
   * @returns {Promise.<{ line: number, commit: ?string }>}
   */
  async computeLinePeg(filePath, currentLine, {commit=null}={}) {
    // Try to get result via 'git blame'
    try {
      return await this.gitOps.lineIntroduction(
        filePath,
        currentLine,
        { commit, liveContent: await this.editor.liveContent(filePath) }
      );
    } catch (e) {
      // Continue on in this function
    }
    
    const promiseOfCommit = commit ? Promise.resolve(commit) : this.gitOps.revParse('HEAD');
    
    let promiseOfCurrentContent = this.editor.liveContent(filePath).then(
      content => (
        content == null
        ? { path: filePath }
        : { immediate: content }
      )
    );
    
    let promiseOfBaseContent = this.gitOps.getBlobContent(filePath, { commit })
      .then(content => ({ immediate: content }));
    
    try {
      const [ commit, baseContent, currentContent ] = await Promise.all(
        [ promiseOfCommit, promiseOfBaseContent, promiseOfCurrentContent ]
      );
      
      const hunks = await this.diffOps.getHunks(baseContent, currentContent);
      
      let currentOffset = 0;
      for (const hunk of hunks) {
        if (currentLine < hunk.currentStart) {
          return { line: currentLine - currentOffset };
        } else if (hunk.currentStart <= currentLine && currentLine < hunk.currentEnd) {
          return {
            line: Math.floor(
              (currentLine - hunk.currentStart) / (hunk.currentEnd - hunk.currentStart) * (hunk.baseEnd - hunk.baseStart)
            ) + hunk.baseStart,
            commit,
          };
        }
        currentOffset = hunk.currentEnd - hunk.baseEnd;
      }
      return { line: currentLine - currentOffset, commit };
    } catch (e) {
      return { line: currentLine };
    }
  }
  
  /**
   * @private
   * @returns {Promise.<{line: number}>}
   */
  async retrieveBlameMatchLine(filePath, {commit, line}) {
    const content = await this.editor.liveContent(filePath);
    return this.gitOps.findCurrentLinePosition(filePath, {commit, line}, content);
  }
  
  /**
   * @private
   * @returns {Promise.<{ start: number, prime: number, end: number }>}
   */
  async computeCurrentLineRange(filePath, {line, commit}) {
    line = Number(line);
    /* istanbul ignore if (method only called when git peg is pressent) */
    if (!commit) {
      return {start: line, prime: line, end: line + 1};
    }
    
    try {
      const liveContent = await this.editor.liveContent(filePath);
      const hunks = await this.diffOps.getHunks(
        { immediate: await this.gitOps.getBlobContent(filePath, { commit }) },
        liveContent == null ? { path: filePath } : { immediate: liveContent }
      );
      
      let currentOffset = 0;
      for (const hunk of hunks) {
        if (line < hunk.baseStart) {
          return {
            start: line + currentOffset,
            prime: line + currentOffset,
            end: line + currentOffset + 1,
          };
        } else if (hunk.baseStart <= line && line < hunk.baseEnd) {
          return {
            start: hunk.currentStart,
            prime: hunk.currentStart + Math.floor(
              (line - hunk.baseStart) / (hunk.baseEnd - hunk.baseStart) * (hunk.currentEnd - hunk.currentStart)
            ),
            end: hunk.currentEnd,
          };
        } else if (hunk.baseStart == line) {
          return {
            start: hunk.currentStart,
            prime: Math.floor((hunk.currentStart + hunk.currentEnd) / 2),
            end: hunk.currentEnd,
          };
        }
        currentOffset = hunk.currentEnd - hunk.baseEnd;
      }
      
      return {
        start: line + currentOffset,
        prime: line + currentOffset,
        end: line + currentOffset + 1,
      };
    } catch (e) {
      return {
        start: line,
        prime: line,
        end: line + 1,
      };
    }
  }
}

export default BookmarkFacilitator;