How would you construct an ESLint rule that rewrote complex method chaining into a sequence of calls?

Viewed 182

So I figured out how to print this:

const tmp2 = getF()
const tmp4 = getM()
const tmp3 = tmp4.getZ(tmp2.g)
const tmp1 = tmp3.a.b.c.getX()
const tmp5 = getN()
const tmp6 = getY(tmp5)
const tmp8 = getP()
const tmp7 = tmp8.x.y.getQ(tmp6)
const tmp0 = tmp1[tmp7].r.getS()

Given this:

getM()
  .getZ(getF().g)
  .a.b.c.getX(1, 2, 3)
  [getP(1, 2).x.y.getQ(getY(getN(1)))]
  .r.getS()

You just do that.

const acorn = require('acorn')
const fs = require('fs')

const input = fs.readFileSync('./tmp/parse.in.js', 'utf-8')

const jst = acorn.parse(input, {
  ecmaVersion: 2021,
  sourceType: 'module'
})

fs.writeFileSync('tmp/parse.out.js.json', JSON.stringify(jst, null, 2))

const flattenedText = flattenJST(jst.body[0].expression)

fs.writeFileSync('tmp/parse.flat.json', flattenedText)

function flattenJST(jst) {
  const state = {
    text: [],
    tmps: 0
  }
  if (jst.type === 'CallExpression') {
    compileCallExpression(jst, state)
  } else if (jst.type === 'MemberExpression') {
    state.text.push(`const tmp${state.tmps++} = ${compileMemberExpression(jst, state)}`)
  }
  return state.text.join('\n')
  // return state.out.map(serialize).join('\n')
}

function serialize(node) {
  switch (node.type) {
    case 'VariableDeclaration':

      break
  }
}

function compileCallExpression(jst, state) {
  const args = []
  jst.arguments.forEach(arg => {
    switch (arg.type) {
      case 'CallExpression':
        args.push(compileCallExpression(arg, state))
        break
      case 'MemberExpression':
        args.push(compileMemberExpression(arg, state))
        break
    }
  })

  const variable = `tmp${state.tmps++}`
  let path
  if (jst.callee.type === 'MemberExpression') {
    path = compileMemberExpression(jst.callee, state)
  } else if (jst.callee.type === 'Identifier') {
    path = state.prefix ? `${state.prefix}.${jst.callee.name}` : jst.callee.name
  } else {
    throw JSON.stringify(jst)
  }

  state.text.push(`const ${variable} = ${path}(${args.join(', ')})`)

  return variable
}

function compileMemberExpression(jst, state) {
  if (jst.object.type === 'MemberExpression') {
    const back = compileMemberExpression(jst.object, state)
    switch (jst.property.type) {
      case 'Identifier': return joinProperty(`${back}`, `${jst.property.name}`, jst.computed)
      case 'MemberExpression':
        return joinProperty(back, compileMemberExpression(jst.property, state), jst.computed)
        break
      case 'CallExpression':
        return joinProperty(back, compileCallExpression(jst.property, state), jst.computed)
        break
    }

  } else if (jst.object.type === 'CallExpression') {
    const back = compileCallExpression(jst.object, state)

    switch (jst.property.type) {
      case 'Identifier': return joinProperty(`${back}`, `${jst.property.name}`, jst.computed)
      case 'MemberExpression':
        return joinProperty(`${back}`, compileMemberExpression(jst.property, state), jst.computed)
        break
      case 'CallExpression':
        return joinProperty(`${back}`, compileCallExpression(jst.property, state), jst.computed)
        break
    }
  } else if (jst.object.type === 'Identifier') {
    const back = jst.object.name

    switch (jst.property.type) {
      case 'Identifier': return joinProperty(`${back}`, `${jst.property.name}`, jst.computed)
      case 'MemberExpression':
        return compileMemberExpression(jst.property, state)
        break
      case 'CallExpression':
        return compileCallExpression(jst.property, state)
        break
    }
  } else {
    throw JSON.stringify(jst)
  }
}

function joinProperty(a, b, computed) {
  if (computed) {
    return `${a}[${b}]`
  } else {
    return `${a}.${b}`
  }
}

The question is though, how could I write an ESLint rule (taking as inspiration something like this) that accomplishes roughly the same thing?

I am trying to gauge whether using the "visitor" pattern like they do in ESLint is the right general approach for manipulating/modifying an AST, using this question as an example scenario. In the end I want to "normalize" JavaScript in a certain custom way, so it is easier to transpile it into my custom language. That requires essentially taking the input JavaScript AST, and rewriting parts of it, and then transforming the rewritten AST into the target language AST, then writing the target language AST into text. For this question, I only care about rewriting the JavaScript AST into a new form, and am not sure the "best" way to generically do that. ESLint's approach seems like a standard / somewhat solid / potentially "good" way to transform ASTs (like in that example), but I haven't worked with ESLint directly, writing my own custom plugins which transform ASTs, I've only been looking at examples in repos for the moment.

Hence my question, I would like to see what it would look like in ESLint's terms to rewrite a complex MemberExpression/CallExpression tree into a "flat", sequential form (eventually I want to do any expression tree in a flat/sequential way, like LogicalExpressions and whatnot, but for now we an leave it here).

Roughly speaking, how would an ESLint plugin for transforming the MemberExpression/CallExpression tree like I've done here look like? By "rough" I mean you don't necessarily need to write a working plugin that does what this function above does (well, the function above should really be transforming the AST, not writing directly to a string haha), just having a rough boilerplate of where things would go with some ESLint plugin pseudocode would probably be helpful enough to mark as an answer. I need to see (a) generally/roughly how much work is involved using their manual AST transformation APIs/best-practices, and (b) the general approach to performing what seems like a really complicated task :)

Most of the "ESLint plugin tutorials" or similar show the "visitor" pattern/system. The ESLint nodes seem to be more complex than the basic JS AST nodes you typically find, as they have the parent property and node.replaceWith, etc. But the visitor examples I've seen so far are all relatively basic, though you can find more complex examples digging into the awesome ESLint list linked repo source codes. But blogs typically have if node.type === X and node.name == "y", make node.name = "z" instead, which doesn't really teach you that much.

I am just not sure a visitor pattern is suitable for my complex task described here, which is the reason for the question. I am going to spend many more hours trying to write an ESLint plugin to do this myself, but any guidance or tips or implementation snippets would be of great value in getting there faster.

To summarize, for this question, the key question is what the ESLint create/visitor/transformation system would look like to transform this:

getM()
  .getZ(getF().g)
  .a.b.c.getX(1, 2, 3)
  [getP(1, 2).x.y.getQ(getY(getN(1)))]
  .r.getS()

into this:

const tmp2 = getF()
const tmp4 = getM()
const tmp3 = tmp4.getZ(tmp2.g)
const tmp1 = tmp3.a.b.c.getX()
const tmp5 = getN()
const tmp6 = getY(tmp5)
const tmp8 = getP()
const tmp7 = tmp8.x.y.getQ(tmp6)
const tmp0 = tmp1[tmp7].r.getS()

Doesn't need to be a perfect solution, anything even pseudocode would work.

0 Answers
Related