DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’» is a community of 966,904 amazing developers

We're a place where coders share, stay up-to-date and grow their careers.

Create account Log in
Nicolas Delperdange for Sencrop

Posted on

Inception coding: write code to analyze your code (in Typescript)

If you want to get some statistics about code usage or generate some documentation about it, you are it the right place !

Let's investigate how to analyze your code in this article.

Better than using regex which is a sequence of characters comparison, we will explain how you can achieve this more efficiently thanks to an AST tree which uses syntactical analysis.

We will develop this around our own usecase: find all calls to an analytic service and create a markdown report (which will be used by our product team).

This work is inspired by the work made by nfroidure in jsarch

Step 0: Package installation

Here is all package we will use in this article:

npm install -D ts-node @babel/parser ast-types glob-promise json2md lodash
Enter fullscreen mode Exit fullscreen mode

Our script will be written in typescript and will be launched by a npm command, so we have to add it in the script section of our package.json:

"scripts": {
    "analytics": "ts-node -O '{\"module\":\"commonjs\"}' bin/parseAnalytics.ts"
}
Enter fullscreen mode Exit fullscreen mode

ts-node is needed to convert our typescript script, and a little option because we will use import statements.

Step 1: Grab files

The first step is pretty simple. To optimize and not reading all files in our project, we will generate a list of files to visit.
To do that, we will use node-glob.

In our case, we want to visit javascript or typescript files in our /src folder.

const filesPaths = await glob("src/**/*.{ts,js}", {
    cwd: process.cwd(),
    dot: true,
    nodir: true,
    absolute: true,
});
Enter fullscreen mode Exit fullscreen mode

process.cwd() returns the current directory, since we are launching with npm, it is our project folder.

Step 2: File content to AST

To be able to analyze our code, we will generate an AST tree from our code.

Quickly, here is the definition of an AST tree:

An abstract syntax tree (AST) is a tree representation of the abstract syntactic structure of source code written in a programming language.

So with our files list, we read their content with the fs library.

const content = fs.readFileSync(filePath, "utf-8");
Enter fullscreen mode Exit fullscreen mode

Then, we create our AST with the help of babel-parser (you can add plugins depending of your needs):

const ast = parse(content, {
    sourceType: "module",
    plugins: ["typescript", "jsx", "classProperties"],
});
Enter fullscreen mode Exit fullscreen mode

Step 3: Visit AST

In this step, we visit our AST tree to convert it into a easier structure (in our case it will be an array with all analytic calls, at the moment it just contains the name of the event).
There are a lot of visit functions available (more or less one per AST type), so we first need to find which visit function is the most appropriate.

To do that, copy-paste one example of file you want to visit in an AST tree explorer, for example ast-explorer.

In our case, our call that we want to analyze is a ExpressionStatement:

Analytics.track({
    event: 'This is a event',
    properties: {
        device: 1
    },
});
Enter fullscreen mode Exit fullscreen mode

So we will implement the visitExpressionStatement:

const analyticsCalls = [];
 visit(ast, {
      visitExpressionStatement: function (path) {
          // Implement your logic here to grab correct parameter
          // In our case, we retrieve the event sended
          if (path.value.expression.callee?.property?.name === 'track') {
            const analyticsParams = path.value.expression.arguments[0];
            const analyticEventName = analyticsParams.properties.find(
              (prop) => prop.key.name === 'event',
            );
            analyticsCalls.push({
              event: analyticEventName?.value?.value || 'N/D',
            });
          }
          this.traverse(path);
      },
 });
Enter fullscreen mode Exit fullscreen mode

In the visit function, we explore the properties from the AST node and create an object (with the analytic event name) to push it into an array for the next step.
This is the hardest step, but the AST explorer will help you find the correct visit function and the properties of each AST node.

The traverse function must be called to continue to explore the AST until the end of it.

Step 4: Generate your markdown

Finally, we use the array that we have generated before to create a markdown, by using json2md package:

json2md([
    { h1: 'Analytics' },
    {
      table: {
        headers: ['Event'],
        rows: analyticsCalls.map((analyticsCall) => ({
          Event: analyticsCall.event,
        })),
      },
    },
]);
Enter fullscreen mode Exit fullscreen mode

Final script

Here is a recap of the script:

import { parse } from '@babel/parser';
import { visit } from 'ast-types';
import fs from 'fs';
import glob from 'glob-promise';
import json2md from 'json2md';
import { flatten } from 'lodash';

type AnalyticsElement = {
  event: string;
};

async function parseAnalytics() {
  const cwd = process.cwd();

  const filesPaths: string[] = await glob('src/**/*.{ts,js}', {
    cwd,
    dot: true,
    nodir: true,
    absolute: true,
  });

  const analyticsCalls = await Promise.all(
    filesPaths.map((file) => extractAnalytics(file)),
  );

  const markdownContent = generateMarkdown(flatten(analyticsCalls));

  fs.truncateSync('ANALYTICS.md');
  fs.writeFileSync('ANALYTICS.md', markdownContent);
}

async function extractAnalytics(filePath: string): Promise<AnalyticsElement[]> {
  const content = fs.readFileSync(filePath, 'utf-8');

  const ast = parse(content, {
    sourceType: 'module',
    plugins: ['typescript', 'jsx', 'classProperties'],
  });

  const analyticsCalls: AnalyticsElement[] = [];

  visit(ast, {
    visitExpressionStatement: function (path) {
      if (path.value.expression.callee?.property?.name === 'track') {
        const analyticsParams = path.value.expression.arguments[0];
        const analyticEventName = analyticsParams.properties.find(
          (prop) => prop.key.name === 'event',
        );
        analyticsCalls.push({
          event: analyticEventName?.value?.value || 'N/D',
        });
      }
      this.traverse(path);
    },
  });

  return analyticsCalls;
}

function generateMarkdown(analyticsCalls: AnalyticsElement[]): string {
  return json2md([
    { h1: 'Analytics' },
    {
      table: {
        headers: ['Event'],
        rows: analyticsCalls.map((analyticsCall) => ({
          Event: analyticsCall.event,
        })),
      },
    },
  ]);
}

parseAnalytics();

Enter fullscreen mode Exit fullscreen mode

Conclusion

You should now be able to adapt this usecase to suit your needs, and we hope you learnt things with this article!
Thanks for reading!

Authors

Top comments (0)

Update Your DEV Experience Level:

Settings

Go to your customization settings to nudge your home feed to show content more relevant to your developer experience level. πŸ›