How to add Autocomplete, Syntax Highlighting using Antlr4 grammar file functionality to Monaco Editor in my React application?


I am using the Monaco Editor in a react app in the following way:

import MonacoEditor from 'react-monaco-editor';

  return (
    <Flex>
      <Flex.Box>
        <MonacoEditor
          width="80"
          height="60"
          theme="vs"
          value="select * from something"
        />
      </Flex.Box>
    </Flex>
  );
});

I am using the following packages to render this.
https://www.npmjs.com/package/monaco-editor-webpack-plugin
https://www.npmjs.com/package/react-monaco-editor
https://www.npmjs.com/package/monaco-editor

I would like to add AutoComplete/Syntax highlighting functionality using an existing grammar file Expression.g4 in my application.

In the following way, I have expression parsing code in my application,

enter image description here

This is how my index.js under this expression-parser folder look like,

import { ExpressionLexer } from './ExpressionLexer';
import { ExpressionParser } from './ExpressionParser';
import { MyExpressionVisitor } from './MyExpressionVisitor';

const ExpressionVisitor = MyExpressionVisitor;
export {
  ExpressionLexer,
  ExpressionParser,
  ExpressionVisitor,
};

And, MyExpressionVisitor looks like,

import { ExpressionParser } from "./ExpressionParser";
import { ExpressionVisitor } from './ExpressionVisitor';
import Functions from './Functions';
import moment from 'moment';
import 'moment/locale/en-gb';

export class MyExpressionVisitor extends ExpressionVisitor {

    constructor(formData, formEntryDisplayTypeMap, locale = 'en-us') {
        super();
        this.formData = formData;
        this.formEntryDisplayTypeMap = formEntryDisplayTypeMap;
        this.locale = locale;
    }

    visitInteger(ctx) {
        return parseInt(ctx.getText(), 10);
    }

    visitNumeric(ctx) {
        return parseFloat(ctx.getText());
    }

    visitUnaryMinus(ctx) {
        return -this.visit(ctx.children[1]);
    }

    visitTrue(ctx) {
        return true;
    }

    visitFalse(ctx) {
        return false;
    }

    visitStringLiteral(ctx) {
        return ctx.getText().slice(1, -1);
    }

    visitArray(ctx) {
        const array = ctx.children
        .filter((_, i) => (i % 2 == 1))  // pick every other child
        .map(c => this.visit(c));
        return array;
    }

    visitComparison(ctx) {
        var left = this.visit(ctx.children[0]);
        var right = this.visit(ctx.children[2]);

        if (left == null || right == null)
            return false;

        if (typeof left == 'number')
            return this.compareNumbers(ctx, left, right);

        else if (typeof left == 'string')
            return this.compareStrings(ctx, left, right);

        if (typeof left == 'object' && Array.isArray(left))
            return this.compareInclude(left, right);

        else if (moment.isMoment(left)) {
            return this.compareDates(ctx, left, right);
        }
        else
            return this.compareBooleans(ctx, left, right);
    }

    visitIn(ctx) {
        var left = this.visit(ctx.children[0]);
        var right = this.visit(ctx.children[2]);

        if (typeof right == 'object' && Array.isArray(right)) {
            right.some((item) => left == item);
        } else {
            throw new Error(`Error evaluating expression. Left side of in must be an array. ${left}`);
        }
    }

    compareBooleans(ctx, left, right) {

        if (!(right === 1 || right === 0 || typeof right === 'boolean')) {
            throw new Error(`Error when evaluating expression. Cannot compare Boolean with ${right}`);
        }

        switch (ctx.op.type) {

            case ExpressionParser.EQ:
                return left == right;

            case ExpressionParser.NE:
                return left != right;
        }
    }

    compareNumbers(ctx, left, right) {
        if (typeof right !== 'number') {
            throw new Error(`Error when evaluating expression. Cannot compare number with ${right}`);
        }

        switch (ctx.op.type) {
            case ExpressionParser.EQ:
                return left == right;
            
            case ExpressionParser.NE:
                return left != right;

            case ExpressionParser.GT:
                return left > right;

            case ExpressionParser.GTE:
                return left >= right;
            
            case ExpressionParser.LT:
                return left < right;
            
            case ExpressionParser.LTE:
                return left <= right;

            default:
                throw new Error(`Operator ${ctx.op.text} cannot be used with numbers at line ${ctx.op.line} col ${ctx.op.column}`);
        }
    }

    compareInclude(left, right) {
        return left.some((item) => right == item);
    }

    compareStrings(ctx, left, right) {
        const leftLC = left.toLowerCase().trim();
        const rightLC = right.toString().toLowerCase().trim();

        switch (ctx.op.type) {
            case ExpressionParser.EQ:
                return leftLC === rightLC;

            case ExpressionParser.NE:
                return leftLC !== rightLC;

            default:
                throw new Error(`Operator ${ctx.op.text} cannot be used with strings at line ${ctx.op.line} col ${ctx.op.column}`);
        }
    }

    compareDates(ctx, left, right) {

        if (left === null || right === null)
            return false;

        if( !moment.isMoment(right)) {
            throw new Error(`Expression eval error, Trying to compare date to: ${right}`);
        }

        switch (ctx.op.type) {
            case ExpressionParser.EQ:
                return moment(left).isSame(right);
            
            case ExpressionParser.NE:
                return !moment(left).isSame(right);

            case ExpressionParser.GT:
                return moment(left).isAfter(right);

            case ExpressionParser.GTE:
                return moment(left).isSameOrAfter(right);
            
            case ExpressionParser.LT:
                return moment(left).isBefore(right);
            
            case ExpressionParser.LTE:
                return moment(left).isSameOrBefore(right);
        }
    }

    visitNot(ctx) {
        return !this.visit(ctx.children[1]);
    }

    visitAndOr(ctx) {
        var left = this.visit(ctx.children[0]);
        // Avoid visiting right child here for short circuit evaluation
        switch (ctx.op.type) {
            case ExpressionParser.AND:
                return left && this.visit(ctx.children[2]);

            case ExpressionParser.OR:
                return left || this.visit(ctx.children[2]);
        }
    }

    visitIdentifier(ctx) {
        let identifier = ctx.getText();

        if (!this.formData.hasOwnProperty(identifier)) {
            // Case insensitive comparation & gets actual case sensitive identifier
            const matchPredicate = (key) => key.localeCompare(identifier, 'en', { sensitivity: 'base' }) == 0;
            identifier = Object.keys(this.formData).find(matchPredicate);
        }

        if (this.formData.hasOwnProperty(identifier)) {

            let value = this.formData[identifier];
            const displayType = this.formEntryDisplayTypeMap[identifier];
            switch (displayType) {
                case 'Text':
                case 'TextArea':
                    return value;

                case 'Date':
                case 'DateTime':
                    return (value === null || value == '') ? null : moment(value);

                case 'CheckBox':
                    return (value === 1);

                case 'MultiCheckBoxList':
                case 'CheckBoxList': {
                    return value?.toString().split('|') || [];
                }

                case 'MultiLookup':
                case 'MultiDropdown': {
                    return value?.toString().split('|') || [];
                }

                default:
                    return (value === '') ? null : value;
            }
        }
        else
            return null;
    }

    visitAddSub(ctx) {
        var left = this.visit(ctx.children[0]);
        var right = this.visit(ctx.children[2]);
        switch (ctx.op.type) {
            case ExpressionParser.PLUS:
                return left + right;

            case ExpressionParser.MINUS:
                return left - right;
        }
    }

    visitMultDivide(ctx) {
        var left = this.visit(ctx.children[0]);
        var right = this.visit(ctx.children[2]);
        switch (ctx.op.type) {
            case ExpressionParser.MULT:
                return left * right;

            case ExpressionParser.DIVIDE:
                if (right == 0)
                    if (left == 0)
                        return undefined;
                    else
                        return Infinity;
                return left / right;
        }
    }

    visitIn(ctx) {  
        // num in [10,20,30] translates to
        // children: ['num', 'in', '[', '10', ',', '20', ',', '30', ']']
        var left = this.visit(ctx.children[0]);
        const options = ctx.children
                           .filter((c, i) => (i > 2) && (i % 2 == 1))  // starting at 3rd, pick every other child
                           .map(c => this.visit(c));
        if (typeof left === "string") {
            const optionsLC = options.map(s => s.toString().toLowerCase().trim());
            return optionsLC.indexOf(left.toLocaleLowerCase().trim()) > -1;
        }
        else
            return options.indexOf(left) > -1;
        
    }

    visitContains(ctx) {
        var left = this.visit(ctx.children[0]);
        if (left === null) return false;
        var right = this.visit(ctx.children[2]);
        if (right === null) return false;

        if (typeof left !== 'string')
            throw new Error(`Left hand side of ~ operator must be of type string. Found ${left}`);
        if (typeof right !== 'string')
            throw new Error(`Right hand side of ~ operator must be of type string. Found ${right}`);

        return Functions.contains(left, right);
    }

    visitFunction(ctx) {
        const functionName = ctx.children[1].getText().toLowerCase();
        let args = []
        if (ctx.children.length > 4)
            args = ctx.children
                      .filter((c, i) => (i > 2) && (i % 2 == 1))
                      .map(c => this.visit(c));
        if (!(functionName in Functions))
            throw new Error(`Could not find function ${functionName}`);
        return Functions[functionName].apply({}, args);
    }

    visitTernary(ctx) {
        const condition = this.visit(ctx.children[0]);
        if (condition === true)
            return this.visit(ctx.children[2]);
        else
            return this.visit(ctx.children[4]);
    }

    visitErrorNode(ctx) {
        console.error('ctx');
    }

    visitParenthesis (ctx) {
        return this.visit(ctx.expr());
    };

    visitIfElse (ctx) {
        let i = 0;      // index into children array
        while (true) {  // terminates when we hit the else clause, which is required by the grammar

            // keyword could be IF, ELSEIF or ELSE tokens
            let keyword = ctx.children[i].symbol.type;

            if (keyword == ExpressionParser.IF || keyword == ExpressionParser.ELSEIF) {
                // Nodes that represent the condition and the value, for readability
                let condition = ctx.children[i + 1];
                let value = ctx.children[i + 3];

                if (this.visit(condition) == true)
                    return this.visit(value);       // short circuit
                else
                    i += 4;     // move to next set of tokens
            }
            else  {     
                // assume keyword == ExpressionParser.ELSE
                return this.visit(ctx.children[i + 1]);
            }
        }
    }
}

How should I add this grammar file to Monaco Editor along with AutoComplete and also have the worker thread working for Monaco Editor?

Currently, I have set up Monaco Editor using Neutroino.js which is like webpack and it looks like,

const airbnb = require("@neutrinojs/airbnb");
const react = require("@neutrinojs/react");
const jest = require("@neutrinojs/jest");
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const MomentLocalesPlugin = require('moment-locales-webpack-plugin');
const merge = require("deepmerge");
const AssetsPlugin = require('assets-webpack-plugin');
const { extname, join, basename } = require("path");
const { readdirSync } = require("fs");
const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin');

/* Configuration of entry points both for prod and dev.
Prod: Gets all files from src/output-interfaces
Dev: Uses index.jsx file and creates html in order to load it in the dev server
*/
let prodMains = {};
const components = join(__dirname, "src", "usercontrols-output");
readdirSync(components).forEach((component) => {
  // eslint-disable-next-line no-param-reassign
  prodMains[basename(component, extname(component))] = {
    entry: join(components, component),
  };
});

let devMains = {
  index: {
    entry: __dirname + "srcindex.jsx",
  },
};
/* END of Entry point conf */

/* Style loaders configuration. Configured to use Css Modules with Sass */
const styleConf = {
  // Override the default file extension of `.css` if needed
  test: /.(css|sass|scss)$/,
  modules: true,
  modulesTest: /.module.(css|sass|scss)$/,
  extract: {
    enabled: false,
  },
  loaders: [
    // Define loaders as objects. Note: loaders must be specified in reverse order.
    // ie: for the loaders below the actual execution order would be:
    // input file -> sass-loader -> postcss-loader -> css-loader -> style-loader/mini-css-extract-plugin
    {
      loader: "postcss-loader",
      options: {
        plugins: [require("autoprefixer")],
      },
    },
    {
      loader: "sass-loader",
      useId: "sass",
    },
  ],
};

module.exports = {
  use: [
    airbnb({
      exclude: [__dirname + "srcsharedexpression-parser"],
      eslint: {
        cache: false,
        baseConfig: {
          env: {
            es6: true,
            browser: true,
            node: true,
          },
          rules: {
            "linebreak-style": 0,
            "no-multiple-empty-lines": 0,
            "no-trailing-spaces": 0,
            "max-len": 0,
            "jsx-a11y/label-has-associated-control": "off",
            "no-param-reassign": "off",
            "object-curly-newline": 0,
          },
        },
      },
    }),
    (neutrino) => {
      console.log("Environment: ", process.env.NODE_ENV);

      neutrino.config.when(
        process.env.NODE_ENV === "production",
        () => { neutrino.options.mains = prodMains; },
        () => { neutrino.options.mains = devMains; }
      );

      const babelConfig = {
        presets: [
          [ 
            "@babel/preset-react", {
              useBuiltIns: false, // Will use the native built-in instead of trying to polyfill behavior for any plugins that require one.
              development: process.env.NODE_ENV === "development",
            },
          ],
        ],
        plugins: ["macros"],
      };

      neutrino.config.when(
        process.env.NODE_ENV === "production",
        () => {
          /*
            when using --dev parameter in production it will add sourcemaps to output
            Usage: npm run build -- --dev
          */
          neutrino.use(
            react({
              html: null,
              style: styleConf,
              targets: false,
              devtool: { production: process.argv.includes("--dev") ? "cheap-module-eval-source-map" : undefined },
              babel: babelConfig,
            })
          );
        },
        () => {
          const devServerConfig = {
            port: 9000,
            proxy: [],
          };
          if (process.argv.includes("--iis")) {
            devServerConfig.proxy.push({
              context: ["/components", "/images"],
              target: `http://localhost:3733/`, //IIS Server
              logLevel: "debug",
            });
          } else {
            devServerConfig.proxy.push({
              context: ["/components", "/images"],
              target: "http://localhost:8884", //Mock Server
            });
          }

          neutrino.use(
            react({
              hot: true,
              image: true,
              style: true,
              font: true,
              html: { title: "My App" },
              style: styleConf,
              targets: false,
              babel: babelConfig,
              devServer: devServerConfig,
            })
          );
        }
      );

      // Added to be able to change browserlistrc and not cache changes
      neutrino.config.module
        .rule("compile")
        .use("babel")
        .tap((options) =>
          merge(options, {
            sourceType: "unambiguous",
            cacheDirectory: false,
          })
        );

      // build moment with 'en' (default) and 'en-gb' locales.
      neutrino.config.plugin('moment-locales')
        .use(MomentLocalesPlugin, [{
          localesToKeep: ['en-gb'],
        }]);

      neutrino.config.when(process.env.NODE_ENV === "production", (config) => {

        config.plugin('assets')
        .use(AssetsPlugin, [{
          prettyPrint: true,
          path: join(__dirname, "../myapp"),
          useCompilerPath: false,
        }]);

        config.plugin('monaco-editor')
        .use(MonacoWebpackPlugin, [{
          languages: ['sql'],
        }]);

        config.output
          .path(
            join(__dirname, "../myapp/output")
          )
          .filename("[name]-[hash].js")
          .libraryTarget("umd");

        config.performance
          .hints(false)
          .maxEntrypointSize(1000000) //bytes ~ 1mb
          .maxAssetSize(1000000);

        config.optimization
          .minimize(true)
          .runtimeChunk(false)
          .splitChunks({
            cacheGroups: {
              commons: {
                test: /[/]node_modules[/]/,
                name: "commons-vendors",
                chunks: "all",
              },
            },
          });

        if (process.argv.includes("--analyze")) {
          //Bundle analyzer
          config.plugin('analyzer')
            .use(BundleAnalyzerPlugin, [{
              reportFilename: '_report.html',
              analyzerMode: 'static', // server | static | json
              openAnalyzer: true,
              generateStatsFile: false,
              logLevel: 'info'
            }]);
        }
        /*Makes the project use React library from the project that uses the components this solution provides
        in order to shrink the each bundle size significantly
        */
        config.externals({
          react: "React",
          "react-dom": "ReactDOM",
          "external": "external",
          "tempa-xlsx": "XLSX", // alias of "tempa-xlsx" used in "react-data-export".
          xlsx: "xlsx", //Is included in "tempa-xlsx" which is used from "react-data-export". Import from npm the size is much bigger -->
          'pubsub-js': "PubSub",
        });
      });
    },
    jest({
      setupFilesAfterEnv: ["<rootDir>src/setupTests.js"],
      testRegex: "src/.*(__tests__|_spec|.test|.spec).(mjs|jsx|js)$",
      moduleFileExtensions: ["tsx", "js"],
      verbose: false,
      timers: "fake",
      collectCoverage: false,
      collectCoverageFrom: [
        "./src/api/**/*.{js,jsx}",
        "./src/app/components/**/*.{js,jsx}",
        "./src/app/features/**/*.{js,jsx}",
        "./src/app/hooks/**/*.{js,jsx}",
        "./src/shared/**/*.{js,jsx}",
        "./src/usercontrols/**/*.{js,jsx}",
        "./src/utils/**/*.{js,jsx}",
      ],
      coveragePathIgnorePatterns: [
        "src/app/.*/provider.jsx",
      ],
      coverageThreshold: {
        global: {
          // global thresholds
          branches: 80,
          functions: 80,
          lines: 80,
          statements: 80,
        }
      },
      reporters: ["default", "jest-junit"],
    })
  ]
};

Source: JavaSript – Stack Overflow

November 13, 2021
Category : News
Tags: antlr4 | javascript | monaco-editor | neutrino | reactjs

Leave a Reply

Your email address will not be published. Required fields are marked *

Sitemap | Terms | Privacy | Cookies | Advertising

Senior Software Developer

Creator of @LzoMedia I am a backend software developer based in London who likes beautiful code and has an adherence to standards & love's open-source.