Convert JsDoc to JSON used in Sheets' Custom Functions written in Google Apps Script

Use RegEx to quickly identify relevant patterns of JsDoc supported by Sheets Custom Function in Apps Script and convert that into JSON.

Convert JsDoc to JSON
Convert JsDoc to JSON

Problem statement

I was recently working on yet another workspace add-on (Custom Functions) which required me to parse JsDoc data that was provided as part of an open-source repository of custom functions (being consumed by Google Sheets), written in Apps Script and that's what got me to write my own set of RegEx to match all the relevant, applicable and supported params.

Here's an example of a custom function —

/**
 * Multiplies the input value by 2.
 *
 * @param {number} input The value or range of cells to multiply.
 * @return {number} The input multiplied by 2.
 * @customfunction
 */
function DOUBLE(input) {
  return Array.isArray(input) ?
    input.map(row => row.map(cell => cell * 2)) :
    input * 2;
}

Now, I wanted to display the description of the function that shows-up like so on Google Sheets —

Example of the description/about being shown on a Google Sheets Custom Function.
Example of the description/about being shown on a Google Sheets Custom Function.

...inside my add-on —

Custom functions description inside the add-on.
Custom functions description inside the add-on.

I also eventually wanted to display the input param and the data that would show-up via return and so, figured it could come in handy to transform the entire JsDoc blob into JSON.

Prior reading

Supported formats

For the first time (ever!), I thought about creating the right set of test cases to begin with, BEFORE I launched myself head-first into creating the actual RegEx itself. These are the ones I landed on —

@param

@param somebody
@param {string} somebody
@param {string} somebody Somebody's name.
@param {string} somebody - Somebody's name.
@param {string} employee.name - The name of the employee.
@param {Object[]} employees - The employees who are responsible for the project.
@param {string} employees[].department - The employee's department.
@param {string=} somebody - Somebody's name.
@param {*} somebody - Whatever you want.
@param {string} [somebody=John Doe] - Somebody's name.
@param {(string|string[])} [somebody=John Doe] - Somebody's name, or an array of names.
@param {string} [somebody] - Somebody's name.

@return

@returns {number}
@returns {number} Sum of a and b
@returns {(number|Array)} Sum of a and b or an array that contains a, b and the sum of a and b.
@returns {Promise} Promise object represents the sum of a and b

While the goal was to come-up with a single expression, I wasn't fully able to do that but had some fun with it anyway —

Solution

I ended-up with 3 expressions. 2 to handle all kinds of input @param and 1 for the values that would show-up as per @return

Case 1

When the @param didn't specify any data type 😬

@param somebody

and the RegEx statement to handle this —

^@(param) (?:(?=[)(?:[(.*)]$)|(?![)(?:([^\s]+)$))

Case 2

Literally, all the other, supported cases of @param within Apps Script (for the purposes of Custom Functions)

@param {string} somebody
@param {string} somebody Somebody's name.
@param {string} somebody - Somebody's name.
@param {string} employee.name - The name of the employee.
@param {Object[]} employees - The employees who are responsible for the project.
@param {string} employees[].department - The employee's department.
@param {string=} somebody - Somebody's name.
@param {*} somebody - Whatever you want.
@param {string} [somebody=John Doe] - Somebody's name.
@param {(string|string[])} [somebody=John Doe] - Somebody's name, or an array of names.
@param {string} [somebody] - Somebody's name.

and the RegEx statement to handle these —

^\@(param)(?: )\{(.*)\}(?: )(?:(?=\[)(?:\[(.*?)\])|(?!\[)(?:(.*?)))(?:(?= )(?: )(?:\- )?(.*)|(?! )$)

This one came with a VERY useful aha! moment too —

Case 3

To handle the humble @return

Possible scenarios —

@returns {number}
@returns {number} Sum of a and b
@returns {(number|Array)} Sum of a and b or an array that contains a, b and the sum of a and b.
@returns {Promise} Promise object represents the sum of a and b

and the RegEx statement to handle 'em all —

^\@(returns)(?: )\{(.*)\}(?:(?= )(?: )(?:\- )?(.*)|(?! )$)

Codebase

You can access the entire script via my GitHub repository here or make a copy of this Apps Script file here.

For reference, here's the code —

function jsDoc2JSON(input) {
  input = UrlFetchApp.fetch("https://raw.githubusercontent.com/custom-functions/google-sheets/main/functions/DOUBLE.gs").getBlob().getDataAsString()
  const jsDocJSON = {};
  const jsDocComment = input.match(/\/\*\*.*\*\//s);
  const jsDocDescription = jsDocComment ? jsDocComment[0].match(/^[^@]*/s) : false;
  const description = jsDocDescription ? jsDocDescription[0].split("*").map(el => el.trim()).filter(el => el !== '' && el !== '/').join(" ") : false;
  const jsDocTags = jsDocComment ? jsDocComment[0].match(/@.*(?=\@)/s) : false;
  const rawTags = jsDocTags ? jsDocTags[0].split("*").map(el => el.trim()).filter(el => el !== '') : false;
  const tags = [];
  let components;
  rawTags.forEach(el => {
    if (el.startsWith("@param ")) { // https://jsdoc.app/tags-param.html
      components = el.match(/^\@(param)(?: )\{(.*)\}(?: )(?:(?=\[)(?:\[(.*?)\])|(?!\[)(?:(.*?)))(?:(?= )(?: )(?:\- )?(.*)|(?! )$)/i);
      if (components) {
        components = components.filter(el => el !== undefined);
        tags.push({
          "tag": "param",
          "type": components[2] ? components[2] : null,
          "name": components[3] ? components[3] : null,
          "description": components[4] ? components[4] : null,
        });
      } else {
        components = el.match(/^\@(param) (?:(?=\[)(?:\[(.*)\]$)|(?!\[)(?:([^\s]+)$))/i);
        if (components) {
          components = components.filter(el => el !== undefined);
          tags.push({
            "tag": "param",
            "type": components[2] ? components[2] : null,
            "name": components[3] ? components[3] : null,
            "description": components[4] ? components[4] : null,
          });
        } else {
          console.log(`invalid @param tag: ${el}`);
        }
      }
    } else if (el.startsWith("@return ") || el.startsWith("@returns ")) { // https://jsdoc.app/tags-returns.html
      components = el.match(/^\@(returns?)(?: )\{(.*)\}(?:(?= )(?: )(?:\- )?(.*)|(?! )$)/i);
      if (components) {
        components = components.filter(el => el !== undefined);
        tags.push({
          "tag": "return",
          "type": components[2] ? components[2] : null,
          "description": components[3] ? components[3] : null,
        });
      } else {
        console.log(`invalid @return tag: ${el}`);
      }
    } else {
      console.log(`unknown tag: ${el}`);
    }
  });

  jsDocJSON.description = description;
  jsDocJSON.tags = tags;

  console.log(JSON.stringify(jsDocJSON, null, 2));
}


// https://jsdoc.app/tags-param.html

// ^\@(param) (?:(?=\[)(?:\[(.*)\]$)|(?!\[)(?:([^\s]+)$))
//   @param somebody

// ^\@(param)(?: )\{(.*)\}(?: )(?:(?=\[)(?:\[(.*?)\])|(?!\[)(?:(.*?)))(?:(?= )(?: )(?:\- )?(.*)|(?! )$)
//   @param {string} somebody
//   @param {string} somebody Somebody's name.
//   @param {string} somebody - Somebody's name.
//   @param {string} employee.name - The name of the employee.
//   @param {Object[]} employees - The employees who are responsible for the project.
//   @param {string} employees[].department - The employee's department.
//   @param {string=} somebody - Somebody's name.
//   @param {*} somebody - Whatever you want.
//   @param {string} [somebody=John Doe] - Somebody's name.
//   @param {(string|string[])} [somebody=John Doe] - Somebody's name, or an array of names.
//   @param {string} [somebody] - Somebody's name.

// https://jsdoc.app/tags-returns.html

// ^\@(returns)(?: )\{(.*)\}(?:(?= )(?: )(?:\- )?(.*)|(?! )$)
//   @returns {number}
//   @returns {number} Sum of a and b
//   @returns {(number|Array)} Sum of a and b or an array that contains a, b and the sum of a and b.
//   @returns {Promise} Promise object represents the sum of a and b

Conclusion

In case you find any of the applicable patterns NOT being handled by the RegEx/script, please do reach out to me either via email (code@script.gs), Twitter, LinkedIn or any of the means listed here.

You'd be surprised how many folks reach out via Telegram too 😄