mkungla/atom-aframe

View on GitHub
src/autocomplete/provider.js

Summary

Maintainability
D
2 days
Test Coverage
'use babel'

import APRIMITIVES from '../../data/primitives.json'
import AATTRIBUTES from '../../data/attributes.json'
import ACOMPONENTS from '../../data/components.json'

const tagPattern = /<([a-zA-Z][-a-zA-Z]*)(?:\s|$)/
const attributePattern = /\s+([a-zA-Z][-a-zA-Z]*)\s*=\s*$/

// Check are first char equal
const fce = (str1, str2) => str1[0].toLowerCase() === str2[0].toLowerCase()

/**
 * inspired by https://github.com/atom/autocomplete-html
 */
export class AutocompleteProvider {
  constructor () {
    // selector (required): Defines the scope selector(s) (can be comma-separated)
    // for which your provider should receive suggestion requests
    this.selector = '.text.html, .source.js'
    // disableForSelector (optional): Defines the scope selector(s)
    // (can be comma-separated) for which your provider should not be used
    this.disableForSelector = '.text.html .comment'
    // filterSuggestions (optional): If set to true, autocomplete+ will perform
    // fuzzy filtering and sorting on the list of matches returned by getSuggestions.
    this.filterSuggestions = true
    this.docVersion = null
    this.docsBaseURL = atom.config.get('atom-aframe.devel.docsBaseURL')
    // perhaps observe following 2
    this.attributeDefaults = atom.config.get(
      'atom-aframe.project.attributeDefaults'
    )
    this.componentDefaults = atom.config.get(
      'atom-aframe.project.componentDefaults'
    )

    this.currentVerDocs = `${this.docsBaseURL}/master`
    this.completions = {
      tags: APRIMITIVES,
      attributes: AATTRIBUTES,
      components: ACOMPONENTS
    }
  }

  /**
   * Set A-Frame version to use for docs
   *
   * @param {String} ver valid semver version string
   */
  setDocVersion (ver) {
    this.currentVerDocs = `${this.docsBaseURL}/${ver}`
    this.docVersion = ver
  }

  /**
   * getSuggestions (required): Is called when a suggestion request has been
   * dispatched by autocomplete+ to your provider. Return an array of suggestions
   * (if any) in the order you would like them displayed to the user.
   * Returning a Promise of an array of suggestions is also supported.
   *
   * An request object will be passed to your getSuggestions function,
   * with the following properties:
   *
   * editor: The current TextEditor
   * bufferPosition: The position of the cursor
   * scopeDescriptor: The scope descriptor for the current cursor position
   * prefix: The prefix for the word immediately preceding the current cursor position
   * activatedManually: Whether the autocomplete request was initiated by the user (e.g. with ctrl+space)
   */
  getSuggestions (request) {
    if (this.isComponentOrAttributeValueStart(request)) {
      return this.getComponentOrAttributeValueCompletions(request)
    } else if (this.isComponentOrAttributeStart(request)) {
      return this.getComponentOrAttributeNameCompletions(request)
    } else if (this.isTagOrPrimitive(request)) {
      return this.getTagCompletions(request)
    } else {
      return []
    }
  }

  /**
   * Check is current request for HTML tag
   *
   * @param  The Suggestion Request's Options Object
   * @return {Boolean}
   */
  isTagOrPrimitive ({ prefix, scopeDescriptor, bufferPosition, editor }) {
    if (prefix.trim() && prefix.indexOf('<') === -1) {
      return this.hasTagScope(scopeDescriptor.getScopesArray())
    }
    prefix = editor.getTextInRange([
      [bufferPosition.row, bufferPosition.column - 1],
      bufferPosition
    ])
    const scopes = scopeDescriptor.getScopesArray()
    return (
      prefix === '<' && scopes[0] === 'text.html.basic' && scopes.length === 1
    )
  }

  /**
   * Check for head or meta tag
   *
   * @param  {Object} scopes http://flight-manual.atom.io/behind-atom/sections/scoped-settings-scopes-and-scope-descriptors/#scope-descriptors
   * @return {Boolean}
   */
  hasTagScope (scopes) {
    for (const scope of Array.from(scopes)) {
      if (scope.startsWith('meta.tag.') && scope.endsWith('.html')) {
        return true
      }
    }
    return false
  }

  /**
   * Get Tag Completions for tags or primitives
   *
   * @param  {Object} The Suggestion Request's Options Object
   * @return {Object} completions
   */
  getTagCompletions ({ prefix, editor, bufferPosition }) {
    // autocomplete-plus's default prefix setting does not capture <. Manually check for it.
    const ignorePrefix =
      editor.getTextInRange([
        [bufferPosition.row, bufferPosition.column - 1],
        bufferPosition
      ]) === '<'

    const completions = []
    for (const tag in this.completions.tags) {
      const conf = this.completions.tags[tag]
      if (ignorePrefix || fce(tag, prefix)) {
        completions.push(this.buildTagCompletion(tag, conf))
      }
    }
    return completions
  }

  /**
   * Build Tag Completion entry
   *
   * @param  {String} tag
   * @param  {String} description
   * @return {Object}
   */
  buildTagCompletion (tag, conf) {
    return {
      text: tag,
      type: 'tag',
      leftLabel: 'A-Frame',
      description:
        conf.description != null ? conf.description : `primitive <${tag}>`,
      descriptionMoreURL: conf.url != null ? conf.url : this.getTagDocsURL(tag)
    }
  }

  /**
   * Greate docs url for tag/primitives
   *
   * @param  {String} tag/primitive
   * @return {String}     documentation url
   */
  getTagDocsURL (tag) {
    let url
    switch (tag) {
      case 'a-scene':
        url = `${this.currentVerDocs}/core/scene.html`
        break
      case 'a-entity':
        url = `${this.currentVerDocs}/core/entity.html`
        break
      case 'a-mixin':
        url = `${this.currentVerDocs}/core/mixins.html`
        break
      case 'a-assets' || 'a-asset-item':
        url = `${this.currentVerDocs}/core/asset-management-system.html`
        break
      case 'a-animation':
        url = `${this.currentVerDocs}/core/animations.html`
        break
      default:
        url = `${this.currentVerDocs}/primitives/${tag}.html`
    }
    return url
  }

  /**
   * Get component docs url
   *
   * @param  {String} component name
   * @return {String} url
   */
  getComponentDocsURL (component) {
    return `${this.currentVerDocs}/components/${component}.html`
  }

  /**
   * onDidInsertSuggestion
   *
   * @param  {Object} editor
   * @param  {Object} suggestion
   */
  onDidInsertSuggestion ({ editor, suggestion }) {
    if (suggestion.type === 'attribute' || suggestion.type === 'component') {
      return setTimeout(this.triggerAutocomplete.bind(this, editor), 1)
    }
  }

  /**
   * triggerAutocomplete
   *
   * @param  {Object} editor
   */
  triggerAutocomplete (editor) {
    return atom.commands.dispatch(
      atom.views.getView(editor),
      'autocomplete-plus:activate',
      { activatedManually: false }
    )
  }

  /**
   * Get Previous tag name
   *
   * @param  {Object} editor
   * @param  {Object} bufferPosition The position of the cursor
   */
  getPreviousTag (editor, bufferPosition) {
    let { row } = bufferPosition
    while (row >= 0) {
      const tag = __guard__(
        tagPattern.exec(editor.lineTextForBufferRow(row)),
        x => x[1]
      )
      if (tag) {
        return tag
      }
      row--
    }
  }

  /**
   * Component Or Attribute Value
   *
   * @param  {Object}  scopeDescriptor The scope descriptor for the current cursor position
   * @param  {Object}  bufferPosition  The position of the cursor
   * @param  {Object}  editor          The current TextEditor
   * @return {Boolean}                 If bufferPosition is at attr value
   */
  isComponentOrAttributeValueStart ({
    scopeDescriptor,
    bufferPosition,
    editor
  }) {
    const scopes = scopeDescriptor.getScopesArray()
    const previousBufferPosition = [
      bufferPosition.row,
      Math.max(0, bufferPosition.column - 1)
    ]
    const previousScopes = editor.scopeDescriptorForBufferPosition(
      previousBufferPosition
    )
    const previousScopesArray = previousScopes.getScopesArray()
    return (
      this.hasStringScope(scopes) &&
      this.hasStringScope(previousScopesArray) &&
      previousScopesArray.indexOf('punctuation.definition.string.end.html') ===
        -1 &&
      this.hasTagScope(scopes) &&
      this.getComponentOrAttribute(editor, bufferPosition) != null
    )
  }

  getComponentOrAttributeValueCompletions ({ prefix, editor, bufferPosition }) {
    const completions = []
    return completions
  }

  /**
   * Check for previous attribute, component
   *
   * @param  {Object} editor
   * @param  {Object} bufferPosition
   */
  getComponentOrAttribute (editor, bufferPosition) {
    // Remove everything until the opening quote (if we're in a string)
    let quoteIndex = bufferPosition.column - 1 // Don't start at the end of the line
    while (quoteIndex) {
      const scopes = editor.scopeDescriptorForBufferPosition([
        bufferPosition.row,
        quoteIndex
      ])
      const scopesArray = scopes.getScopesArray()
      if (
        !this.hasStringScope(scopesArray) ||
        scopesArray.indexOf('punctuation.definition.string.begin.html') !== -1
      ) {
        break
      }
      quoteIndex--
    }
    return __guard__(
      attributePattern.exec(
        editor.getTextInRange([
          [bufferPosition.row, 0],
          [bufferPosition.row, quoteIndex]
        ])
      ),
      x => x[1]
    )
  }

  /**
   * Check does scopes have a String Scope
   *
   * @param  {Object}  scopes
   * @return {Boolean}
   */
  hasStringScope (scopes) {
    return (
      scopes.indexOf('string.quoted.double.html') !== -1 ||
      scopes.indexOf('string.quoted.single.html') !== -1
    )
  }

  /**
   * [isComponentOrAttributeStart description]
   *
   * @param  {Object}  prefix          The prefix for the word immediately preceding the current cursor position
   * @param  {Object}  scopeDescriptor [description]
   * @param  {Object}  bufferPosition  [description]
   * @param  {Object}  editor          [description]
   * @return {Boolean}                 [description]
   */
  isComponentOrAttributeStart ({
    prefix,
    scopeDescriptor,
    bufferPosition,
    editor
  }) {
    const scopes = scopeDescriptor.getScopesArray()
    if (
      !this.getComponentOrAttribute(editor, bufferPosition) &&
      prefix &&
      !prefix.trim()
    ) {
      return this.hasTagScope(scopes)
    }

    const previousBufferPosition = [
      bufferPosition.row,
      Math.max(0, bufferPosition.column - 1)
    ]
    const previousScopes = editor.scopeDescriptorForBufferPosition(
      previousBufferPosition
    )
    const previousScopesArray = previousScopes.getScopesArray()

    if (
      previousScopesArray.indexOf('entity.other.attribute-name.html') !== -1
    ) {
      return true
    }
    if (!this.hasTagScope(scopes)) {
      return false
    }
    return (
      scopes.indexOf('punctuation.definition.tag.end.html') !== -1 &&
      previousScopesArray.indexOf('punctuation.definition.tag.end.html') === -1
    )
  }

  /**
   * Get Component Or Attribute Name Completions
   *
   * @param  {String} prefix
   * @param  {Object} editor
   * @param  {Object} bufferPosition
   * @return {Object}                completions
   */
  getComponentOrAttributeNameCompletions ({ prefix, editor, bufferPosition }) {
    const completions = []
    const tag = this.getPreviousTag(editor, bufferPosition)
    const tagAttributes = this.getTagAttributes(tag)

    for (const attribute of Object.keys(tagAttributes)) {
      if (!prefix.trim() || fce(attribute, prefix)) {
        completions.push(
          this.buildMappingAttributeCompletion(
            attribute,
            tag,
            this.completions.attributes[attribute],
            tagAttributes[attribute]
          )
        )
      }
    }
    for (const component of Object.keys(this.completions.components)) {
      if (!prefix.trim() || fce(component, prefix)) {
        if (
          (tag !== 'a-scene' &&
            this.completions.components[component].sceneOnly) ||
          (tag === 'a-scene' &&
            !this.completions.components[component].sceneOnly)
        ) {
          continue
        }
        completions.push(
          this.buildComponentCompletion(
            component,
            tag,
            this.completions.components[component]
          )
        )
      }
    }
    return completions
  }

  /**
   * Build Local Mapping Attribute Completion
   *
   * @param  {String} attribute
   * @param  {String} tag
   * @param  {Object} options
   * @return {Object}
   */
  buildMappingAttributeCompletion (attribute, tag, options, v) {
    let description, value, url, cp
    if (this.attributeDefaults) {
      value = v != null && typeof v !== 'undefined' ? v.value : null
      if (value == null && options != null && options.mapping != null) {
        if (v !== null && typeof v.mapping !== 'undefined') {
          cp = v.mapping.split('.')
          description = v.mapping
        } else {
          cp = options.mapping.split('.')
          description = options.mapping
        }
        url = this.getComponentDocsURL(cp[0])
        if (
          Object.prototype.hasOwnProperty.call(
            this.completions.components,
            cp[0]
          )
        ) {
          const x = this.completions.components[cp[0]].properties[cp[1]]
          value = x || ''
        } else {
          value = ''
        }
      } else {
        description = `<${tag}> ${attribute}`
        url =
          options != null && options.url != null
            ? options.url
            : this.getTagDocsURL(tag)
      }
    }
    return {
      snippet:
        (options != null ? options.type : undefined) === 'flag'
          ? attribute
          : `${attribute}="${value}$1"$0`,
      displayText: attribute,
      type: 'attribute',
      rightLabel: value,
      leftLabel: 'A-Frame',
      description: description,
      descriptionMoreURL: url
    }
  }

  /**
   * Build Component Completion
   *
   * @param  {String} component
   * @param  {String} tag
   * @param  {Object} options
   * @return {Object}
   */
  buildComponentCompletion (component, tag, options) {
    return {
      snippet:
        options.type === 'flag'
          ? component
          : `${component}="${this.getComponentValue(options)}$1"$0`,
      displayText: component,
      type: 'component',
      leftLabel: 'A-Frame',
      rightLabel: 'component',
      description: options.description != null ? options.description : '',
      descriptionMoreURL: this.getComponentDocsURL(component)
    }
  }

  /**
   * Get Tag Attributes
   *
   * @param  {String} tag
   * @return {Object} attributes
   */
  getTagAttributes (tag) {
    return (this.completions.tags[tag] != null
      ? this.completions.tags[tag].attributes
      : undefined) != null
      ? this.completions.tags[tag] != null
        ? this.completions.tags[tag].attributes
        : undefined
      : []
  }

  /**
   * Get Component Value
   *
   * @param  {String} component
   * @return {String} val
   */
  getComponentValue (component) {
    if (component.value != null) {
      return component.value
    }
    if (this.componentDefaults) {
      let val = ''
      for (const p in component.properties) {
        if (Object.prototype.hasOwnProperty.call(component.properties, p)) {
          val += p + ':' + component.properties[p] + ';'
        }
      }
      return val
    }
    return ''
  }
}

function __guard__ (value, transform) {
  return typeof value !== 'undefined' && value !== null
    ? transform(value)
    : undefined
}