src/tree-sitter-grammar.js

Summary

Maintainability
B
5 hrs
Test Coverage
const path = require('path');
const SyntaxScopeMap = require('./syntax-scope-map');
const Module = require('module');

module.exports = class TreeSitterGrammar {
  constructor(registry, filePath, params) {
    this.registry = registry;
    this.name = params.name;
    this.scopeName = params.scopeName;

    // TODO - Remove the `RegExp` spelling and only support `Regex`, once all of the existing
    // Tree-sitter grammars are updated to spell it `Regex`.
    this.contentRegex = buildRegex(params.contentRegex || params.contentRegExp);
    this.injectionRegex = buildRegex(
      params.injectionRegex || params.injectionRegExp
    );
    this.firstLineRegex = buildRegex(params.firstLineRegex);

    this.folds = params.folds || [];
    this.folds.forEach(normalizeFoldSpecification);

    this.commentStrings = {
      commentStartString: params.comments && params.comments.start,
      commentEndString: params.comments && params.comments.end
    };

    const scopeSelectors = {};
    for (const key in params.scopes || {}) {
      const classes = preprocessScopes(params.scopes[key]);
      const selectors = key.split(/,\s+/);
      for (let selector of selectors) {
        selector = selector.trim();
        if (!selector) continue;
        if (scopeSelectors[selector]) {
          scopeSelectors[selector] = [].concat(
            scopeSelectors[selector],
            classes
          );
        } else {
          scopeSelectors[selector] = classes;
        }
      }
    }

    this.scopeMap = new SyntaxScopeMap(scopeSelectors);
    this.fileTypes = params.fileTypes || [];
    this.injectionPointsByType = {};

    for (const injectionPoint of params.injectionPoints || []) {
      this.addInjectionPoint(injectionPoint);
    }

    // TODO - When we upgrade to a new enough version of node, use `require.resolve`
    // with the new `paths` option instead of this private API.
    const languageModulePath = Module._resolveFilename(params.parser, {
      id: filePath,
      filename: filePath,
      paths: Module._nodeModulePaths(path.dirname(filePath))
    });

    this.languageModule = require(languageModulePath);
    this.classNamesById = new Map();
    this.scopeNamesById = new Map();
    this.idsByScope = Object.create(null);
    this.nextScopeId = 256 + 1;
    this.registration = null;
  }

  inspect() {
    return `TreeSitterGrammar {scopeName: ${this.scopeName}}`;
  }

  idForScope(scopeName) {
    if (!scopeName) {
      return undefined;
    }
    let id = this.idsByScope[scopeName];
    if (!id) {
      id = this.nextScopeId += 2;
      const className = scopeName
        .split('.')
        .map(s => `syntax--${s}`)
        .join(' ');
      this.idsByScope[scopeName] = id;
      this.classNamesById.set(id, className);
      this.scopeNamesById.set(id, scopeName);
    }
    return id;
  }

  classNameForScopeId(id) {
    return this.classNamesById.get(id);
  }

  scopeNameForScopeId(id) {
    return this.scopeNamesById.get(id);
  }

  activate() {
    this.registration = this.registry.addGrammar(this);
  }

  deactivate() {
    if (this.registration) this.registration.dispose();
  }

  addInjectionPoint(injectionPoint) {
    let injectionPoints = this.injectionPointsByType[injectionPoint.type];
    if (!injectionPoints) {
      injectionPoints = this.injectionPointsByType[injectionPoint.type] = [];
    }
    injectionPoints.push(injectionPoint);
  }

  removeInjectionPoint(injectionPoint) {
    const injectionPoints = this.injectionPointsByType[injectionPoint.type];
    if (injectionPoints) {
      const index = injectionPoints.indexOf(injectionPoint);
      if (index !== -1) injectionPoints.splice(index, 1);
      if (injectionPoints.length === 0) {
        delete this.injectionPointsByType[injectionPoint.type];
      }
    }
  }

  /*
  Section - Backward compatibility shims
  */

  onDidUpdate(callback) {
    // do nothing
  }

  tokenizeLines(text, compatibilityMode = true) {
    return text.split('\n').map(line => this.tokenizeLine(line, null, false));
  }

  tokenizeLine(line, ruleStack, firstLine) {
    return {
      value: line,
      scopes: [this.scopeName]
    };
  }
};

const preprocessScopes = value =>
  typeof value === 'string'
    ? value
    : Array.isArray(value)
    ? value.map(preprocessScopes)
    : value.match
    ? { match: new RegExp(value.match), scopes: preprocessScopes(value.scopes) }
    : Object.assign({}, value, { scopes: preprocessScopes(value.scopes) });

const NODE_NAME_REGEX = /[\w_]+/;

function matcherForSpec(spec) {
  if (typeof spec === 'string') {
    if (spec[0] === '"' && spec[spec.length - 1] === '"') {
      return {
        type: spec.substr(1, spec.length - 2),
        named: false
      };
    }

    if (!NODE_NAME_REGEX.test(spec)) {
      return { type: spec, named: false };
    }

    return { type: spec, named: true };
  }
  return spec;
}

function normalizeFoldSpecification(spec) {
  if (spec.type) {
    if (Array.isArray(spec.type)) {
      spec.matchers = spec.type.map(matcherForSpec);
    } else {
      spec.matchers = [matcherForSpec(spec.type)];
    }
  }

  if (spec.start) normalizeFoldSpecification(spec.start);
  if (spec.end) normalizeFoldSpecification(spec.end);
}

function buildRegex(value) {
  // Allow multiple alternatives to be specified via an array, for
  // readability of the grammar file
  if (Array.isArray(value)) value = value.map(_ => `(${_})`).join('|');
  if (typeof value === 'string') return new RegExp(value);
  return null;
}