Source: lib/parse.js

/*eslint-disable camelcase*/

'use strict';

/**
 * The `kss/lib/parse` module is normally accessed via the
 * [`parse()`]{@link module:kss.parse} method of the `kss` module:
 * ```
 * var kss = require('kss');
 * kss.parse(input, options, callback);
 * ```
 * @private
 * @module kss/lib/parse
 */

var KssStyleguide = require('./kss_styleguide.js'),
  KssSection = require('./kss_section.js'),
  KssModifier = require('./kss_modifier.js'),
  KssParameter = require('./kss_parameter.js'),
  marked = require('marked'),
  natural = require('natural');

var inlineRenderer,
  parse, parseChunk, createModifiers, createParameters, checkReference, findBlocks, processProperty,
  isDeprecated, isExperimental, hasPrefix;

// Create a MarkDown renderer that does not output a wrapping paragraph.
inlineRenderer = new marked.Renderer();
inlineRenderer.paragraph = function(text) {
  return text;
};

/**
 * Parse an array/string of documented CSS, or an object of files
 * and their content.
 *
 * File object formatted as `{ "absolute filename": content, ... }`.
 *
 * This is called automatically as part of `traverse` but is publicly
 * accessible as well.
 *
 * @alias module:kss.parse
 * @param  {Mixed}    input    The input to parse
 * @param  {Object}   options  Options to alter the output content. Same as the options in [`traverse()`]{@link module:kss.traverse}.
 * @param  {Function} callback Called when parsing is complete
 */
parse = function(input, options, callback) {
  var data = {}, fileName, files,
    i, l;

  // If supplied a string, just make it an Array.
  if (typeof input === 'string') {
    input = [input];
  }

  // Otherwise assume the input supplied is a JSON object, as specified above.
  if (!Array.isArray(input)) {
    files = input;
    input = [];
    data.files = [];
    for (fileName in files) {
      if (files.hasOwnProperty(fileName)) {
        input.push(files[fileName]);
        data.files.push(fileName);
      }
    }
    data.files.sort();
  }

  // Default parsing options
  if (typeof options.markdown === 'undefined') {
    options.markdown = true;
  }
  if (typeof options.multiline === 'undefined') {
    options.multiline = true;
  }
  options.typos = options.typos || false;
  options.custom = options.custom || [];

  // Actually parse the input (parseChunk is the key function here.)
  l = input.length;
  data.sections = [];
  data.section_refs = {};

  for (i = 0; i < l; i += 1) {
    data = parseChunk(data, input[i], options) || data;
  }

  // Determine the reference delimiter.
  l = data.sections.length;
  data.referenceDelimiter = '.';
  for (i = 0; i < l; i += 1) {
    if (data.sections[i].reference().match(/\ \-\ /)) {
      data.referenceDelimiter = ' - ';
      break;
    }
  }

  // Set the depth of each section's reference.
  for (i = 0; i < l; i += 1) {
    data.sections[i].data.depth = data.sections[i].data.reference.split(data.referenceDelimiter).length;
  }

  callback(false, new KssStyleguide(data));
};

/**
 * Take a chunk of text and parse the comments. This is the primary parsing
 * function, and eventually returns a `data` variable to use to create a new
 * instance of `KssStyleguide`.
 *
 * @private
 * @param  {Object} data    JSON object containing all of the style guide data.
 * @param  {String} input   Text to be parsed, i.e. a single CSS/LESS/etc. file's content.
 * @param  {Object} options The options passed on from `traverse` or `parse`
 * @return {Object} The raw style guide data from the newly parsed text.
 */
parseChunk = function(data, input, options) {
  /* jshint loopfunc: true */

  var currSection, i, l, blocks = [], paragraphs, j, m, hasModifiers, lastModifier;

  // Append the raw text to the body string.
  data.body = data.body || '';
  data.body += '\n\n';
  data.body += input;

  // Retrieve an array of "comment block" strings, and then evaluate each one.
  blocks = findBlocks(input, options);
  l = blocks.length;

  for (i = 0; i < l; i += 1) {
    // Create a new, temporary section object with some default values.
    // "raw" is a comment block from the array above.
    currSection = {
      raw: blocks[i],
      header: '',
      description: '',
      modifiers: [],
      parameters: [],
      markup: false
    };

    // Split the comment block into paragraphs.
    paragraphs = currSection.raw
      .replace(/\r\n/g, '\n')      // Convert Windows CRLF linebreaks.
      .replace(/\r/g, '\n')        // Convert Classic Mac CR linebreaks too.
      .replace(/\n\s+\n/g, '\n\n') // Trim whitespace-only lines.
      .replace(/^\s+|\s+$/g, '')   // Trim the string of white space.
      .split('\n\n');

    // Before anything else, process the properties that are clearly labeled and
    // can be found right away and then removed.
    currSection = processProperty('Markup', paragraphs, options, currSection);
    /*eslint-disable no-loop-func*/
    currSection = processProperty('Weight', paragraphs, options, currSection, function(value) {
      return isNaN(value) ? 0 : parseFloat(value);
    });
    // Process custom properties.
    options.custom.forEach(function(name) {
      currSection = processProperty(name, paragraphs, options, currSection);
    });
    /*eslint-enable no-loop-func*/

    // Ignore this block if a styleguide reference number is not listed.
    currSection.reference = checkReference(paragraphs, options) || '';
    if (!currSection.reference) {
      continue;
    }

    // If the block is 2 paragraphs long, it is just a header and a reference;
    // no need to search for modifiers.
    if (paragraphs.length === 2) {
      currSection.header = currSection.description = paragraphs[0];
    // If it's 3+ paragraphs long, search for modifiers.
    } else if (paragraphs.length > 2) {

      // Extract the approximate header, description and modifiers paragraphs.
      // The modifiers will be split into an array of lines.
      currSection.header = paragraphs[0];
      currSection.description = paragraphs.slice(0, paragraphs.length - 2).join('\n\n');
      currSection.modifiers = paragraphs[paragraphs.length - 2]
        .split('\n');

      // Check the modifiers paragraph. Does it look like it's a list of
      // modifiers, or just another paragraph of the description?
      m = currSection.modifiers.length;
      hasModifiers = true;
      for (j = 0; j < m; j += 1) {
        if (currSection.modifiers[j].match(/^\s*.+?\s+\-\s/g)) {
          lastModifier = j;
        } else if (j === 0) {
          // The paragraph doesn't start with a modifier, so bail out.
          hasModifiers = false;
          j = m;
        } else {
          // If the current line doesn't match a modifier, it must be a
          // multi-line modifier description.
          currSection.modifiers[lastModifier] += ' ' + currSection.modifiers[j].replace(/^\s+|\s+$/g, '');
          // We will strip this blank line later.
          currSection.modifiers[j] = '';
        }
      }
      // Remove any blank lines added.
      /*eslint-disable no-loop-func*/
      currSection.modifiers = currSection.modifiers.filter(function(line) { return line !== ''; });
      /*eslint-enable no-loop-func*/

      // If it's a modifiers paragraph, turn each one into a modifiers object.
      // Otherwise, add it back to the description.
      if (hasModifiers) {
        // If the current section has markup, create proper KssModifier objects.
        if (currSection.markup) {
          currSection.modifiers = createModifiers(currSection.modifiers, options);
        } else {
          // If the current section has no markup, create KssParameter objects.
          currSection.parameters = createParameters(currSection.modifiers, options);
          currSection.modifiers = [];
        }
      } else {
        currSection.description += '\n\n' + paragraphs[paragraphs.length - 2];
        currSection.modifiers = [];
      }
    }

    // Squash the header into a single line.
    currSection.header = currSection.header.replace(/\n/g, ' ');

    // Check the section's status.
    currSection.deprecated = isDeprecated(currSection.description, options);
    currSection.experimental = isExperimental(currSection.description, options);

    // If multi-line descriptions are allowed, remove the first paragraph (the
    // header) from the description. Otherwise, remove the description.
    if (options.multiline) {
      if (currSection.description.match(/\n{2,}/)) {
        currSection.description = currSection.description.replace(/^.*?\n{2,}/, '');
      } else {
        currSection.description = '';
      }
    }

    // Markdown Parsing.
    if (options.markdown) {
      currSection.description = marked(currSection.description);
    }

    // Add the new section instance to the sections array.
    currSection = new KssSection(currSection);
    data.sections.push(currSection);

    // Store the reference for quick searching later, if it's supplied.
    if (currSection.reference()) {
      data.section_refs[currSection.reference()] = currSection;
    }
  }

  return data;
};

/**
 * Takes an array of modifier lines, and turns it into instances of KssModifier.
 *
 * @private
 * @param  {Array}  lines   Modifier lines, which should all be strings.
 * @param  {Object} options Any options passed on by the functions above.
 * @return {Array} The modifier instances created.
 */
createModifiers = function(lines, options) {
  return lines.map(function(entry) {
    var modifier, description, className;

    // Split modifier name and the description.
    modifier = entry.split(/\s+\-\s+/, 1)[0];
    description = entry.replace(modifier, '', 1).replace(/^\s+\-\s+/, '');

    className = modifier.replace(/\:/g, '.pseudo-class-');

    // Markdown parsing.
    if (options.markdown) {
      description = marked(description, {renderer: inlineRenderer});
    }

    return new KssModifier({
      name: modifier,
      description: description,
      className: className
    });
  });
};

/**
 * Takes an array of parameter lines, and turns it into instances of KssParameter.
 *
 * @private
 * @param  {Array}  lines   Parameter lines, which should all be strings.
 * @param  {Object} options Any options passed on by the functions above.
 * @return {Array} The parameter instances created.
 */
createParameters = function(lines, options) {
  return lines.map(function(entry) {
    var parameter, description;

    // Split parameter name and the description.
    parameter = entry.split(/\s+\-\s+/, 1)[0];
    description = entry.replace(parameter, '', 1).replace(/^\s+\-\s+/, '');

    // Markdown parsing.
    if (options.markdown) {
      description = marked(description, {renderer: inlineRenderer});
    }

    return new KssParameter({
      name: parameter,
      description: description
    });
  });
};

/**
 * Returns an array of comment blocks found within a string.
 *
 * @private
 * @param  {String} input   The string to search.
 * @param  {Object} options Optional parameters to pass. Inherited from `parse`.
 * @return {Array} The blocks found.
 */
findBlocks = function(input, options) {
  /*eslint-disable key-spacing*/
  var currentBlock = '',
    insideSingleBlock = false, insideMultiBlock = false, insideDocblock = false,
    indentAmount = false,
    blocks = [],
    lines, line, i, l,
    commentExpressions = {
      single:         /^\s*\/\/.*$/,
      docblockStart:  /^\s*\/\*\*\s*$/,
      multiStart:     /^\s*\/\*+\s*$/,
      multiFinish:    /^\s*\*\/\s*$/
    };
  /*eslint-enable key-spacing*/

  options = options || {};

  input = input.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
  lines = input.split(/\n|$/g);

  l = lines.length;
  for (i = 0; i < l; i += 1) {
    line = lines[i];

    // Remove trailing space.
    line = line.replace(/\s*$/, '');

    // Single-line parsing.
    if (!insideMultiBlock && !insideDocblock && line.match(commentExpressions.single)) {
      if (insideSingleBlock && currentBlock !== '') {
        currentBlock += '\n';
      }
      currentBlock += line.replace(/^\s*\/\/\s?/, '');
      insideSingleBlock = true;
      continue;
    }

    // Since the current line is not a single line comment, save the current
    // block and continue parsing the current line.
    if (insideSingleBlock) {
      blocks.push(currentBlock.replace(/^\n+/, '').replace(/\n+$/, ''));
      insideSingleBlock = false;
      currentBlock = '';
    }

    // Save the current multi-/docblock if we have reached the end of the block.
    if ((insideMultiBlock || insideDocblock) && line.match(commentExpressions.multiFinish)) {
      blocks.push(currentBlock.replace(/^\n+/, '').replace(/\n+$/, ''));
      insideMultiBlock = insideDocblock = false;
      currentBlock = '';
      indentAmount = false;
      continue;
    }

    // Docblock parsing.
    if (line.match(commentExpressions.docblockStart)) {
      insideDocblock = true;
      currentBlock = '';
      continue;
    }
    if (insideDocblock) {
      currentBlock += '\n';
      currentBlock += line.replace(/^\s*\*\s?/, '');
      continue;
    }

    // Multi-line parsing.
    if (line.match(commentExpressions.multiStart)) {
      insideMultiBlock = true;
      currentBlock = '';
      continue;
    }
    if (insideMultiBlock) {
      // If this is the first interior line, determine the indentation amount.
      if (indentAmount === false) {
        // Skip initial blank lines.
        if (line === '') {
          continue;
        }
        indentAmount = line.match(/^\s*/)[0];
      }
      currentBlock += '\n';
      // Always strip same indentation amount from each line.
      currentBlock += line.replace(new RegExp('^' + indentAmount), '', 1);
      continue;
    }
  }

  // Add the last comment block to our list of blocks.
  if (currentBlock) {
    blocks.push(currentBlock.replace(/^\n+/, '').replace(/\n+$/, ''));
  }

  return blocks;
};

/**
 * Check a section for the reference number it may or may not have.
 *
 * @private
 * @param  {Array}  paragraphs An array of the paragraphs in a single block.
 * @param  {Object} options    The options object passed on from the initial functions
 * @return {Boolean|String} False if not found, otherwise returns the reference number as a string.
 */
checkReference = function(paragraphs, options) {
  var lastParagraph = paragraphs[paragraphs.length - 1].trim(),
    words = lastParagraph.split(/\s+/),
    keyword = false,
    reference = false;

  options = options || {};

  // If is only one word in the last paragraph, it can't be a styleguide ref.
  if (words.length < 2) {
    return false;
  }

  // Search for the "styleguide" (or "style guide") keyword at the start of the paragraph.
  [words[0], words[0] + words[1]].forEach(function(value, index) {
    if (!keyword) {
      value = value.replace(/[-\:]?$/, '');
      if (value.toLowerCase() === 'styleguide' || options.typos && natural.Metaphone.compare('Styleguide', value.replace('-', ''))) {
        keyword = words.shift();
        if (index === 1) {
          keyword += ' ' + words.shift();
        }
      }
    }
  });

  if (keyword) {
    reference = words.join(' ');

    // Normalize any " - " delimeters.
    reference = reference.replace(/\s+\-\s+/g, ' - ');

    // Remove trailing dot-zeros and periods.
    reference = reference.replace(/\.$|(\.0){1,}$/g, '');
  }

  return reference;
};

/**
 * Checks if there is a specific property in the comment block and removes it from the original array.
 *
 * @private
 * @param  {String}   propertyName The name of the property to search for
 * @param  {Array}    paragraphs   An array of the paragraphs in a single block
 * @param  {Object}   options      The options object passed on from the initial functions
 * @param  {Object}   sectionData  The original data object of a section.
 * @param  {Function} processValue A function to massage the value before it is inserted into the sectionData.
 * @return {Object} A new data object for the section.
 */
processProperty = function(propertyName, paragraphs, options, sectionData, processValue) {
  var indexToRemove = 'not found';

  propertyName = propertyName.toLowerCase();

  paragraphs.map(function(paragraph, index) {
    if (hasPrefix(paragraph, options, propertyName)) {
      sectionData[propertyName] = paragraph.replace(new RegExp('^\\s*' + propertyName + '\\:\\s+?', 'gmi'), '');
      if (typeof processValue === 'function') {
        sectionData[propertyName] = processValue(sectionData[propertyName]);
      }
      paragraph = '';
      indexToRemove = index;
    }
    return paragraph;
  });

  if (indexToRemove !== 'not found') {
    paragraphs.splice(indexToRemove, 1);
  }

  return sectionData;
};

/**
 * Check if the description indicates that a section is deprecated.
 *
 * @private
 * @param  {String}  description The description of that section
 * @param  {Object}  options     The options passed on from previous functions
 * @return {Boolean} Whether the description indicates the section is deprecated.
 */
isDeprecated = function(description, options) {
  return hasPrefix(description, options, 'Deprecated');
};

/**
 * Check if the description indicates that a section is experimental.
 *
 * @private
 * @param  {String}  description The description of that section
 * @param  {Object}  options     The options passed on from previous functions
 * @return {Boolean} Whether the description indicates the section is experimental.
 */
isExperimental = function(description, options) {
  return hasPrefix(description, options, 'Experimental');
};

/**
 * Essentially this function checks if a string is prefixed by a particular attribute,
 * e.g. 'Deprecated:' and 'Markup:'
 *
 * If `options.typos` is enabled it'll try check if the first word at least sounds like
 * the word we're checking for.
 *
 * @private
 * @param  {String}  description The string to check
 * @param  {Object}  options     The options passed on from previous functions
 * @param  {String}  prefix      The prefix to search for
 * @return {Boolean} Whether the description contains the specified prefix.
 */
hasPrefix = function(description, options, prefix) {
  var words;
  if (!options.typos) {
    return !!description.match(new RegExp('^\\s*' + prefix + '\\:', 'gmi'));
  }

  words = description.replace(/^\s*/, '').match(/^\s*([a-z ]*)\:/gmi);
  if (!words) {
    return false;
  }

  return natural.Metaphone.compare(words[0].replace(':', ''), prefix);
};

module.exports = parse;