src/grammar-registry.js

Summary

Maintainability
F
3 days
Test Coverage
const _ = require('underscore-plus');
const Grim = require('grim');
const CSON = require('season');
const FirstMate = require('first-mate');
const { Disposable, CompositeDisposable } = require('event-kit');
const TextMateLanguageMode = require('./text-mate-language-mode');
const TreeSitterLanguageMode = require('./tree-sitter-language-mode');
const TreeSitterGrammar = require('./tree-sitter-grammar');
const ScopeDescriptor = require('./scope-descriptor');
const Token = require('./token');
const fs = require('fs-plus');
const { Point, Range } = require('text-buffer');

const PATH_SPLIT_REGEX = new RegExp('[/.]');

// Extended: This class holds the grammars used for tokenizing.
//
// An instance of this class is always available as the `atom.grammars` global.
module.exports = class GrammarRegistry {
  constructor({ config } = {}) {
    this.config = config;
    this.subscriptions = new CompositeDisposable();
    this.textmateRegistry = new FirstMate.GrammarRegistry({
      maxTokensPerLine: 100,
      maxLineLength: 1000
    });
    this.clear();
  }

  clear() {
    this.textmateRegistry.clear();
    this.treeSitterGrammarsById = {};
    if (this.subscriptions) this.subscriptions.dispose();
    this.subscriptions = new CompositeDisposable();
    this.languageOverridesByBufferId = new Map();
    this.grammarScoresByBuffer = new Map();
    this.textMateScopeNamesByTreeSitterLanguageId = new Map();
    this.treeSitterLanguageIdsByTextMateScopeName = new Map();

    const grammarAddedOrUpdated = this.grammarAddedOrUpdated.bind(this);
    this.textmateRegistry.onDidAddGrammar(grammarAddedOrUpdated);
    this.textmateRegistry.onDidUpdateGrammar(grammarAddedOrUpdated);

    this.subscriptions.add(
      this.config.onDidChange('core.useTreeSitterParsers', () => {
        this.grammarScoresByBuffer.forEach((score, buffer) => {
          if (!this.languageOverridesByBufferId.has(buffer.id)) {
            this.autoAssignLanguageMode(buffer);
          }
        });
      })
    );
  }

  serialize() {
    const languageOverridesByBufferId = {};
    this.languageOverridesByBufferId.forEach((languageId, bufferId) => {
      languageOverridesByBufferId[bufferId] = languageId;
    });
    return { languageOverridesByBufferId };
  }

  deserialize(params) {
    for (const bufferId in params.languageOverridesByBufferId || {}) {
      this.languageOverridesByBufferId.set(
        bufferId,
        params.languageOverridesByBufferId[bufferId]
      );
    }
  }

  createToken(value, scopes) {
    return new Token({ value, scopes });
  }

  // Extended: set a {TextBuffer}'s language mode based on its path and content,
  // and continue to update its language mode as grammars are added or updated, or
  // the buffer's file path changes.
  //
  // * `buffer` The {TextBuffer} whose language mode will be maintained.
  //
  // Returns a {Disposable} that can be used to stop updating the buffer's
  // language mode.
  maintainLanguageMode(buffer) {
    this.grammarScoresByBuffer.set(buffer, null);

    const languageOverride = this.languageOverridesByBufferId.get(buffer.id);
    if (languageOverride) {
      this.assignLanguageMode(buffer, languageOverride);
    } else {
      this.autoAssignLanguageMode(buffer);
    }

    const pathChangeSubscription = buffer.onDidChangePath(() => {
      this.grammarScoresByBuffer.delete(buffer);
      if (!this.languageOverridesByBufferId.has(buffer.id)) {
        this.autoAssignLanguageMode(buffer);
      }
    });

    const destroySubscription = buffer.onDidDestroy(() => {
      this.grammarScoresByBuffer.delete(buffer);
      this.languageOverridesByBufferId.delete(buffer.id);
      this.subscriptions.remove(destroySubscription);
      this.subscriptions.remove(pathChangeSubscription);
    });

    this.subscriptions.add(pathChangeSubscription, destroySubscription);

    return new Disposable(() => {
      destroySubscription.dispose();
      pathChangeSubscription.dispose();
      this.subscriptions.remove(pathChangeSubscription);
      this.subscriptions.remove(destroySubscription);
      this.grammarScoresByBuffer.delete(buffer);
      this.languageOverridesByBufferId.delete(buffer.id);
    });
  }

  // Extended: Force a {TextBuffer} to use a different grammar than the
  // one that would otherwise be selected for it.
  //
  // * `buffer` The {TextBuffer} whose grammar will be set.
  // * `languageId` The {String} id of the desired language.
  //
  // Returns a {Boolean} that indicates whether the language was successfully
  // found.
  assignLanguageMode(buffer, languageId) {
    if (buffer.getBuffer) buffer = buffer.getBuffer();

    let grammar = null;
    if (languageId != null) {
      grammar = this.grammarForId(languageId);
      if (!grammar) return false;
      this.languageOverridesByBufferId.set(buffer.id, languageId);
    } else {
      this.languageOverridesByBufferId.set(buffer.id, null);
      grammar = this.textmateRegistry.nullGrammar;
    }

    this.grammarScoresByBuffer.set(buffer, null);
    if (grammar !== buffer.getLanguageMode().grammar) {
      buffer.setLanguageMode(
        this.languageModeForGrammarAndBuffer(grammar, buffer)
      );
    }

    return true;
  }

  // Extended: Force a {TextBuffer} to use a different grammar than the
  // one that would otherwise be selected for it.
  //
  // * `buffer` The {TextBuffer} whose grammar will be set.
  // * `grammar` The desired {Grammar}.
  //
  // Returns a {Boolean} that indicates whether the assignment was successful
  assignGrammar(buffer, grammar) {
    if (!grammar) return false;
    if (buffer.getBuffer) buffer = buffer.getBuffer();
    this.languageOverridesByBufferId.set(buffer.id, grammar.scopeName || null);
    this.grammarScoresByBuffer.set(buffer, null);
    if (grammar !== buffer.getLanguageMode().grammar) {
      buffer.setLanguageMode(
        this.languageModeForGrammarAndBuffer(grammar, buffer)
      );
    }
    return true;
  }

  // Extended: Get the `languageId` that has been explicitly assigned to
  // the given buffer, if any.
  //
  // Returns a {String} id of the language
  getAssignedLanguageId(buffer) {
    return this.languageOverridesByBufferId.get(buffer.id);
  }

  // Extended: Remove any language mode override that has been set for the
  // given {TextBuffer}. This will assign to the buffer the best language
  // mode available.
  //
  // * `buffer` The {TextBuffer}.
  autoAssignLanguageMode(buffer) {
    const result = this.selectGrammarWithScore(
      buffer.getPath(),
      getGrammarSelectionContent(buffer)
    );
    this.languageOverridesByBufferId.delete(buffer.id);
    this.grammarScoresByBuffer.set(buffer, result.score);
    if (result.grammar !== buffer.getLanguageMode().grammar) {
      buffer.setLanguageMode(
        this.languageModeForGrammarAndBuffer(result.grammar, buffer)
      );
    }
  }

  languageModeForGrammarAndBuffer(grammar, buffer) {
    if (grammar instanceof TreeSitterGrammar) {
      return new TreeSitterLanguageMode({
        grammar,
        buffer,
        config: this.config,
        grammars: this
      });
    } else {
      return new TextMateLanguageMode({ grammar, buffer, config: this.config });
    }
  }

  // Extended: Select a grammar for the given file path and file contents.
  //
  // This picks the best match by checking the file path and contents against
  // each grammar.
  //
  // * `filePath` A {String} file path.
  // * `fileContents` A {String} of text for the file path.
  //
  // Returns a {Grammar}, never null.
  selectGrammar(filePath, fileContents) {
    return this.selectGrammarWithScore(filePath, fileContents).grammar;
  }

  selectGrammarWithScore(filePath, fileContents) {
    let bestMatch = null;
    let highestScore = -Infinity;
    this.forEachGrammar(grammar => {
      const score = this.getGrammarScore(grammar, filePath, fileContents);
      if (score > highestScore || bestMatch == null) {
        bestMatch = grammar;
        highestScore = score;
      }
    });
    return { grammar: bestMatch, score: highestScore };
  }

  // Extended: Returns a {Number} representing how well the grammar matches the
  // `filePath` and `contents`.
  getGrammarScore(grammar, filePath, contents) {
    if (contents == null && fs.isFileSync(filePath)) {
      contents = fs.readFileSync(filePath, 'utf8');
    }

    // Initially identify matching grammars based on the filename and the first
    // line of the file.
    let score = this.getGrammarPathScore(grammar, filePath);
    if (this.grammarMatchesPrefix(grammar, contents)) score += 0.5;

    // If multiple grammars match by one of the above criteria, break ties.
    if (score > 0) {
      const isTreeSitter = grammar instanceof TreeSitterGrammar;

      // Prefer either TextMate or Tree-sitter grammars based on the user's settings.
      if (isTreeSitter) {
        if (this.shouldUseTreeSitterParser(grammar.scopeName)) {
          score += 0.1;
        } else {
          return -Infinity;
        }
      }

      // Prefer grammars with matching content regexes. Prefer a grammar with no content regex
      // over one with a non-matching content regex.
      if (grammar.contentRegex) {
        const contentMatch = isTreeSitter
          ? grammar.contentRegex.test(contents)
          : grammar.contentRegex.testSync(contents);
        if (contentMatch) {
          score += 0.05;
        } else {
          score -= 0.05;
        }
      }

      // Prefer grammars that the user has manually installed over bundled grammars.
      if (!grammar.bundledPackage) score += 0.01;
    }

    return score;
  }

  getGrammarPathScore(grammar, filePath) {
    if (!filePath) return -1;
    if (process.platform === 'win32') {
      filePath = filePath.replace(/\\/g, '/');
    }

    const pathComponents = filePath.toLowerCase().split(PATH_SPLIT_REGEX);
    let pathScore = 0;

    let customFileTypes;
    if (this.config.get('core.customFileTypes')) {
      customFileTypes = this.config.get('core.customFileTypes')[
        grammar.scopeName
      ];
    }

    let { fileTypes } = grammar;
    if (customFileTypes) {
      fileTypes = fileTypes.concat(customFileTypes);
    }

    for (let i = 0; i < fileTypes.length; i++) {
      const fileType = fileTypes[i];
      const fileTypeComponents = fileType.toLowerCase().split(PATH_SPLIT_REGEX);
      const pathSuffix = pathComponents.slice(-fileTypeComponents.length);
      if (_.isEqual(pathSuffix, fileTypeComponents)) {
        pathScore = Math.max(pathScore, fileType.length);
        if (i >= grammar.fileTypes.length) {
          pathScore += 0.5;
        }
      }
    }

    return pathScore;
  }

  grammarMatchesPrefix(grammar, contents) {
    if (contents && grammar.firstLineRegex) {
      let escaped = false;
      let numberOfNewlinesInRegex = 0;
      for (let character of grammar.firstLineRegex.source) {
        switch (character) {
          case '\\':
            escaped = !escaped;
            break;
          case 'n':
            if (escaped) {
              numberOfNewlinesInRegex++;
            }
            escaped = false;
            break;
          default:
            escaped = false;
        }
      }

      const prefix = contents
        .split('\n')
        .slice(0, numberOfNewlinesInRegex + 1)
        .join('\n');
      if (grammar.firstLineRegex.testSync) {
        return grammar.firstLineRegex.testSync(prefix);
      } else {
        return grammar.firstLineRegex.test(prefix);
      }
    } else {
      return false;
    }
  }

  forEachGrammar(callback) {
    this.getGrammars({ includeTreeSitter: true }).forEach(callback);
  }

  grammarForId(languageId) {
    if (!languageId) return null;
    if (this.shouldUseTreeSitterParser(languageId)) {
      return (
        this.treeSitterGrammarsById[languageId] ||
        this.textmateRegistry.grammarForScopeName(languageId)
      );
    } else {
      return (
        this.textmateRegistry.grammarForScopeName(languageId) ||
        this.treeSitterGrammarsById[languageId]
      );
    }
  }

  // Deprecated: Get the grammar override for the given file path.
  //
  // * `filePath` A {String} file path.
  //
  // Returns a {String} such as `"source.js"`.
  grammarOverrideForPath(filePath) {
    Grim.deprecate('Use buffer.getLanguageMode().getLanguageId() instead');
    const buffer = atom.project.findBufferForPath(filePath);
    if (buffer) return this.getAssignedLanguageId(buffer);
  }

  // Deprecated: Set the grammar override for the given file path.
  //
  // * `filePath` A non-empty {String} file path.
  // * `languageId` A {String} such as `"source.js"`.
  //
  // Returns undefined.
  setGrammarOverrideForPath(filePath, languageId) {
    Grim.deprecate(
      'Use atom.grammars.assignLanguageMode(buffer, languageId) instead'
    );
    const buffer = atom.project.findBufferForPath(filePath);
    if (buffer) {
      const grammar = this.grammarForScopeName(languageId);
      if (grammar)
        this.languageOverridesByBufferId.set(buffer.id, grammar.name);
    }
  }

  // Remove the grammar override for the given file path.
  //
  // * `filePath` A {String} file path.
  //
  // Returns undefined.
  clearGrammarOverrideForPath(filePath) {
    Grim.deprecate('Use atom.grammars.autoAssignLanguageMode(buffer) instead');
    const buffer = atom.project.findBufferForPath(filePath);
    if (buffer) this.languageOverridesByBufferId.delete(buffer.id);
  }

  grammarAddedOrUpdated(grammar) {
    if (grammar.scopeName && !grammar.id) grammar.id = grammar.scopeName;

    this.grammarScoresByBuffer.forEach((score, buffer) => {
      const languageMode = buffer.getLanguageMode();
      const languageOverride = this.languageOverridesByBufferId.get(buffer.id);

      if (
        grammar === buffer.getLanguageMode().grammar ||
        grammar === this.grammarForId(languageOverride)
      ) {
        buffer.setLanguageMode(
          this.languageModeForGrammarAndBuffer(grammar, buffer)
        );
        return;
      } else if (!languageOverride) {
        const score = this.getGrammarScore(
          grammar,
          buffer.getPath(),
          getGrammarSelectionContent(buffer)
        );
        const currentScore = this.grammarScoresByBuffer.get(buffer);
        if (currentScore == null || score > currentScore) {
          buffer.setLanguageMode(
            this.languageModeForGrammarAndBuffer(grammar, buffer)
          );
          this.grammarScoresByBuffer.set(buffer, score);
          return;
        }
      }

      languageMode.updateForInjection(grammar);
    });
  }

  // Extended: Invoke the given callback when a grammar is added to the registry.
  //
  // * `callback` {Function} to call when a grammar is added.
  //   * `grammar` {Grammar} that was added.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onDidAddGrammar(callback) {
    return this.textmateRegistry.onDidAddGrammar(callback);
  }

  // Extended: Invoke the given callback when a grammar is updated due to a grammar
  // it depends on being added or removed from the registry.
  //
  // * `callback` {Function} to call when a grammar is updated.
  //   * `grammar` {Grammar} that was updated.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onDidUpdateGrammar(callback) {
    return this.textmateRegistry.onDidUpdateGrammar(callback);
  }

  // Experimental: Specify a type of syntax node that may embed other languages.
  //
  // * `grammarId` The {String} id of the parent language
  // * `injectionPoint` An {Object} with the following keys:
  //   * `type` The {String} type of syntax node that may embed other languages
  //   * `language` A {Function} that is called with syntax nodes of the specified `type` and
  //     returns a {String} that will be tested against other grammars' `injectionRegex` in
  //     order to determine what language should be embedded.
  //   * `content` A {Function} that is called with syntax nodes of the specified `type` and
  //     returns another syntax node or array of syntax nodes that contain the embedded source code.
  addInjectionPoint(grammarId, injectionPoint) {
    const grammar = this.treeSitterGrammarsById[grammarId];
    if (grammar) {
      if (grammar.addInjectionPoint) {
        grammar.addInjectionPoint(injectionPoint);
      } else {
        grammar.injectionPoints.push(injectionPoint);
      }
    } else {
      this.treeSitterGrammarsById[grammarId] = {
        injectionPoints: [injectionPoint]
      };
    }
    return new Disposable(() => {
      const grammar = this.treeSitterGrammarsById[grammarId];
      grammar.removeInjectionPoint(injectionPoint);
    });
  }

  get nullGrammar() {
    return this.textmateRegistry.nullGrammar;
  }

  get grammars() {
    return this.getGrammars();
  }

  decodeTokens() {
    return this.textmateRegistry.decodeTokens.apply(
      this.textmateRegistry,
      arguments
    );
  }

  grammarForScopeName(scopeName) {
    return this.grammarForId(scopeName);
  }

  addGrammar(grammar) {
    if (grammar instanceof TreeSitterGrammar) {
      const existingParams =
        this.treeSitterGrammarsById[grammar.scopeName] || {};
      if (grammar.scopeName)
        this.treeSitterGrammarsById[grammar.scopeName] = grammar;
      if (existingParams.injectionPoints) {
        for (const injectionPoint of existingParams.injectionPoints) {
          grammar.addInjectionPoint(injectionPoint);
        }
      }
      this.grammarAddedOrUpdated(grammar);
      return new Disposable(() => this.removeGrammar(grammar));
    } else {
      return this.textmateRegistry.addGrammar(grammar);
    }
  }

  removeGrammar(grammar) {
    if (grammar instanceof TreeSitterGrammar) {
      delete this.treeSitterGrammarsById[grammar.scopeName];
    } else {
      return this.textmateRegistry.removeGrammar(grammar);
    }
  }

  removeGrammarForScopeName(scopeName) {
    return this.textmateRegistry.removeGrammarForScopeName(scopeName);
  }

  // Extended: Read a grammar asynchronously and add it to the registry.
  //
  // * `grammarPath` A {String} absolute file path to a grammar file.
  // * `callback` A {Function} to call when loaded with the following arguments:
  //   * `error` An {Error}, may be null.
  //   * `grammar` A {Grammar} or null if an error occurred.
  loadGrammar(grammarPath, callback) {
    this.readGrammar(grammarPath, (error, grammar) => {
      if (error) return callback(error);
      this.addGrammar(grammar);
      callback(null, grammar);
    });
  }

  // Extended: Read a grammar synchronously and add it to this registry.
  //
  // * `grammarPath` A {String} absolute file path to a grammar file.
  //
  // Returns a {Grammar}.
  loadGrammarSync(grammarPath) {
    const grammar = this.readGrammarSync(grammarPath);
    this.addGrammar(grammar);
    return grammar;
  }

  // Extended: Read a grammar asynchronously but don't add it to the registry.
  //
  // * `grammarPath` A {String} absolute file path to a grammar file.
  // * `callback` A {Function} to call when read with the following arguments:
  //   * `error` An {Error}, may be null.
  //   * `grammar` A {Grammar} or null if an error occurred.
  //
  // Returns undefined.
  readGrammar(grammarPath, callback) {
    if (!callback) callback = () => {};
    CSON.readFile(grammarPath, (error, params = {}) => {
      if (error) return callback(error);
      try {
        callback(null, this.createGrammar(grammarPath, params));
      } catch (error) {
        callback(error);
      }
    });
  }

  // Extended: Read a grammar synchronously but don't add it to the registry.
  //
  // * `grammarPath` A {String} absolute file path to a grammar file.
  //
  // Returns a {Grammar}.
  readGrammarSync(grammarPath) {
    return this.createGrammar(
      grammarPath,
      CSON.readFileSync(grammarPath) || {}
    );
  }

  createGrammar(grammarPath, params) {
    if (params.type === 'tree-sitter') {
      return new TreeSitterGrammar(this, grammarPath, params);
    } else {
      if (
        typeof params.scopeName !== 'string' ||
        params.scopeName.length === 0
      ) {
        throw new Error(
          `Grammar missing required scopeName property: ${grammarPath}`
        );
      }
      return this.textmateRegistry.createGrammar(grammarPath, params);
    }
  }

  // Extended: Get all the grammars in this registry.
  //
  // * `options` (optional) {Object}
  //   * `includeTreeSitter` (optional) {Boolean} Set to include
  //     [Tree-sitter](https://github.blog/2018-10-31-atoms-new-parsing-system/) grammars
  //
  // Returns a non-empty {Array} of {Grammar} instances.
  getGrammars(params) {
    let tmGrammars = this.textmateRegistry.getGrammars();
    if (!(params && params.includeTreeSitter)) return tmGrammars;

    const tsGrammars = Object.values(this.treeSitterGrammarsById).filter(
      g => g.scopeName
    );
    return tmGrammars.concat(tsGrammars); // NullGrammar is expected to be first
  }

  scopeForId(id) {
    return this.textmateRegistry.scopeForId(id);
  }

  treeSitterGrammarForLanguageString(languageString) {
    let longestMatchLength = 0;
    let grammarWithLongestMatch = null;
    for (const id in this.treeSitterGrammarsById) {
      const grammar = this.treeSitterGrammarsById[id];
      if (grammar.injectionRegex) {
        const match = languageString.match(grammar.injectionRegex);
        if (match) {
          const { length } = match[0];
          if (length > longestMatchLength) {
            grammarWithLongestMatch = grammar;
            longestMatchLength = length;
          }
        }
      }
    }
    return grammarWithLongestMatch;
  }

  shouldUseTreeSitterParser(languageId) {
    return this.config.get('core.useTreeSitterParsers', {
      scope: new ScopeDescriptor({ scopes: [languageId] })
    });
  }
};

function getGrammarSelectionContent(buffer) {
  return buffer.getTextInRange(
    Range(Point(0, 0), buffer.positionForCharacterIndex(1024))
  );
}