OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / lib / parsers / js.js

539 lines · javascript ref:
import { parse as acornParse } from '../../vendor/acorn/dist/acorn.mjs'

const PARSE_OPTS = {
  ecmaVersion: 'latest',
  sourceType: 'module',
  locations: true,
  ranges: true,
  allowHashBang: true
}

const CJS_PARSE_OPTS = {
  ...PARSE_OPTS,
  sourceType: 'script'
}

export function parseFile (source, filePath) {
  const comments = []
  const isESM = !filePath || !filePath.endsWith('.cjs')
  const opts = {
    ...(isESM ? PARSE_OPTS : CJS_PARSE_OPTS),
    onComment: comments
  }

  let ast
  try {
    ast = acornParse(source, opts)
  } catch (err) {
    // Fall back to script mode if module parse fails (CJS files)
    try {
      ast = acornParse(source, { ...CJS_PARSE_OPTS, onComment: comments })
    } catch {
      return { error: err.message, filePath }
    }
  }

  const result = {
    filePath,
    imports: [],
    exports: [],
    classes: [],
    functions: [],
    constants: [],
    state: [],
    errorContracts: [],
    delegates: []
  }

  const commentMap = buildCommentMap(comments)

  for (const node of ast.body) {
    switch (node.type) {
      case 'ImportDeclaration':
        result.imports.push(extractImport(node))
        break

      case 'ExportNamedDeclaration':
        extractNamedExport(node, result, commentMap)
        break

      case 'ExportDefaultDeclaration':
        extractDefaultExport(node, result, commentMap)
        break

      case 'ExportAllDeclaration':
        result.exports.push({
          name: '*',
          from: node.source ? node.source.value : null
        })
        break

      case 'ClassDeclaration':
        if (node.id) {
          result.classes.push(extractClass(node, commentMap))
        }
        break

      case 'FunctionDeclaration':
        if (node.id) {
          result.functions.push(extractFunction(node, commentMap))
        }
        break

      case 'VariableDeclaration':
        extractConstants(node, result, commentMap)
        break

      // CJS: module.exports = ...
      case 'ExpressionStatement':
        extractCJSExport(node, result, commentMap)
        break
    }
  }

  // Walk full AST for state, errors, delegation
  walkDeep(ast, result)

  return result
}

// --- Comment mapping ---

function buildCommentMap (comments) {
  // Map: endLine -> comment text (for block comments / JSDoc)
  const map = []
  for (const c of comments) {
    if (c.type === 'Block' && c.value.startsWith('*')) {
      map.push({
        text: '/*' + c.value + '*/',
        endLine: c.loc.end.line,
        start: c.start,
        end: c.end
      })
    }
  }
  return map
}

function findJSDoc (commentMap, node) {
  if (!node.loc) return null
  const nodeLine = node.loc.start.line
  for (const c of commentMap) {
    // JSDoc comment ends on the line before or same line as the node
    if (c.endLine >= nodeLine - 2 && c.endLine < nodeLine) {
      return c.text
    }
  }
  return null
}

// --- Import extraction ---

function extractImport (node) {
  const result = {
    specifier: node.source.value,
    names: []
  }
  for (const spec of node.specifiers) {
    switch (spec.type) {
      case 'ImportSpecifier':
        result.names.push(spec.imported.name)
        break
      case 'ImportDefaultSpecifier':
        result.names.push(spec.local.name)
        break
      case 'ImportNamespaceSpecifier':
        result.names.push('* as ' + spec.local.name)
        break
    }
  }
  return result
}

// --- Export extraction ---

function extractNamedExport (node, result, commentMap) {
  // Re-export: export { X } from './file.js'
  //
  // This is sugar for import-then-export, so it is BOTH. Recording only the
  // export leaves the source file with nothing pointing at it: a pure re-export
  // barrel (index.js doing nothing but `export { x } from './lib/x.js'`) then
  // makes every file it fronts look unreachable.
  if (node.source) {
    const names = []
    for (const spec of node.specifiers) {
      const name = spec.exported.name || spec.exported.value
      names.push(spec.local ? (spec.local.name || name) : name)
      result.exports.push({ name, from: node.source.value })
    }
    if (names.length > 0) {
      result.imports.push({ specifier: node.source.value, names, reExport: true })
    }
    return
  }

  // export class X { ... }
  if (node.declaration) {
    const decl = node.declaration
    switch (decl.type) {
      case 'ClassDeclaration': {
        const cls = extractClass(decl, commentMap)
        result.classes.push(cls)
        result.exports.push({ name: cls.name, type: 'class' })
        break
      }
      case 'FunctionDeclaration': {
        const fn = extractFunction(decl, commentMap)
        result.functions.push(fn)
        result.exports.push({ name: fn.name, type: 'function' })
        break
      }
      case 'VariableDeclaration': {
        extractConstants(decl, result, commentMap)
        for (const d of decl.declarations) {
          if (d.id && d.id.name) {
            result.exports.push({ name: d.id.name, type: 'const' })
          }
        }
        break
      }
    }
    return
  }

  // export { X, Y }
  for (const spec of node.specifiers) {
    result.exports.push({
      name: spec.exported.name || spec.exported.value
    })
  }
}

function extractDefaultExport (node, result, commentMap) {
  const decl = node.declaration
  if (decl.type === 'ClassDeclaration' && decl.id) {
    const cls = extractClass(decl, commentMap)
    result.classes.push(cls)
    result.exports.push({ name: 'default', type: 'class', localName: cls.name })
  } else if (decl.type === 'FunctionDeclaration' && decl.id) {
    const fn = extractFunction(decl, commentMap)
    result.functions.push(fn)
    result.exports.push({ name: 'default', type: 'function', localName: fn.name })
  } else {
    result.exports.push({ name: 'default', type: decl.type })
  }
}

function extractCJSExport (node, result, commentMap) {
  const expr = node.expression
  if (!expr || expr.type !== 'AssignmentExpression') return

  const left = expr.left
  if (!left || left.type !== 'MemberExpression') return

  // module.exports = class X { ... }
  const isModuleExports = left.object &&
    left.object.name === 'module' &&
    left.property && left.property.name === 'exports'

  // exports.X = ...
  const isExportsProp = left.object &&
    left.object.name === 'exports' &&
    left.property

  if (isModuleExports) {
    const right = expr.right
    if (right.type === 'ClassExpression' || right.type === 'ClassDeclaration') {
      const cls = extractClass(right, commentMap)
      result.classes.push(cls)
      result.exports.push({ name: 'default', type: 'class', localName: cls.name, cjs: true })
    } else if (right.type === 'FunctionExpression' || right.type === 'FunctionDeclaration') {
      const fn = extractFunction(right, commentMap)
      result.functions.push(fn)
      result.exports.push({ name: 'default', type: 'function', localName: fn.name, cjs: true })
    } else {
      result.exports.push({ name: 'default', type: right.type, cjs: true })
    }
  } else if (isExportsProp) {
    const name = left.property.name || left.property.value
    result.exports.push({ name, cjs: true })
  }
}

// --- Class extraction ---

function extractClass (node, commentMap) {
  const cls = {
    name: node.id ? node.id.name : '(anonymous)',
    extends: node.superClass ? nodeToString(node.superClass) : null,
    line: node.loc ? node.loc.start.line : null,
    endLine: node.loc ? node.loc.end.line : null,
    constructor: null,
    methods: [],
    getters: [],
    staticMethods: [],
    jsdoc: findJSDoc(commentMap, node)
  }

  if (!node.body || !node.body.body) return cls

  for (const member of node.body.body) {
    if (member.type === 'MethodDefinition') {
      const name = member.key.name || member.key.value || '(computed)'
      const jsdoc = findJSDoc(commentMap, member)

      if (member.kind === 'constructor') {
        cls.constructor = {
          params: extractParams(member.value.params),
          line: member.loc ? member.loc.start.line : null,
          endLine: member.loc ? member.loc.end.line : null,
          jsdoc
        }
      } else if (member.kind === 'get') {
        cls.getters.push({ name, static: member.static || false, line: member.loc ? member.loc.start.line : null, endLine: member.loc ? member.loc.end.line : null })
      } else if (member.kind === 'set') {
        cls.getters.push({ name, kind: 'set', static: member.static || false })
      } else if (member.static) {
        cls.staticMethods.push({
          name,
          params: extractParams(member.value.params),
          async: member.value.async || false,
          line: member.loc ? member.loc.start.line : null,
          endLine: member.loc ? member.loc.end.line : null,
          jsdoc
        })
      } else {
        cls.methods.push({
          name,
          params: extractParams(member.value.params),
          async: member.value.async || false,
          generator: member.value.generator || false,
          line: member.loc ? member.loc.start.line : null,
          endLine: member.loc ? member.loc.end.line : null,
          jsdoc
        })
      }
    } else if (member.type === 'PropertyDefinition') {
      // Class fields (public or private)
      const name = member.key.name || member.key.value || '(computed)'
      cls.methods.push({
        name,
        kind: 'field',
        static: member.static || false
      })
    }
  }

  return cls
}

// --- Function extraction ---

function extractFunction (node, commentMap) {
  return {
    name: node.id ? node.id.name : '(anonymous)',
    params: extractParams(node.params),
    async: node.async || false,
    generator: node.generator || false,
    line: node.loc ? node.loc.start.line : null,
    endLine: node.loc ? node.loc.end.line : null,
    jsdoc: findJSDoc(commentMap, node)
  }
}

// --- Constants ---

function extractConstants (node, result, commentMap) {
  for (const decl of node.declarations) {
    if (!decl.id) continue

    if (decl.id.type === 'Identifier') {
      const entry = {
        name: decl.id.name,
        kind: node.kind,
        jsdoc: findJSDoc(commentMap, node)
      }

      // If the init is a class or function, extract it
      if (decl.init) {
        if (decl.init.type === 'ClassExpression') {
          const cls = extractClass(decl.init, commentMap)
          cls.name = cls.name === '(anonymous)' ? decl.id.name : cls.name
          result.classes.push(cls)
          entry.type = 'class'
        } else if (decl.init.type === 'ArrowFunctionExpression' || decl.init.type === 'FunctionExpression') {
          result.functions.push({
            name: decl.id.name,
            params: extractParams(decl.init.params),
            async: decl.init.async || false,
            generator: decl.init.generator || false,
            arrow: decl.init.type === 'ArrowFunctionExpression',
            jsdoc: findJSDoc(commentMap, node)
          })
          entry.type = 'function'
        }
      }

      result.constants.push(entry)
    }
  }
}

// --- Deep walk for state, errors, delegation ---

function walkDeep (ast, result) {
  const stateFields = new Set()
  const delegateCalls = new Map() // field -> Set of methods
  const errors = []

  walk(ast, (node) => {
    // Dynamic import. The top-level switch only sees static ImportDeclaration,
    // so a module reached solely via `await import('x')` looked like nothing
    // imported it — and every one of its exports read as a dead export.
    //
    //   const { startServer } = await import('./lib/server.js')
    //
    // Visited parent-first, so the destructured form is recorded (with its
    // names) before the bare ImportExpression fallback below sees it.
    if (
      node.type === 'VariableDeclarator' &&
      node.id && node.id.type === 'ObjectPattern' &&
      node.init && node.init.type === 'AwaitExpression' &&
      node.init.argument && node.init.argument.type === 'ImportExpression' &&
      node.init.argument.source && node.init.argument.source.type === 'Literal'
    ) {
      const spec = node.init.argument.source.value
      if (typeof spec === 'string' && !result.imports.some(i => i.specifier === spec)) {
        const names = []
        for (const p of node.id.properties) {
          if (p.type === 'Property' && p.key && p.key.name) names.push(p.key.name)
        }
        result.imports.push({ specifier: spec, names, dynamic: true })
      }
    }

    // Any other dynamic import — bare `import('x')`, or one whose result is not
    // destructured. The edge still matters even when no names are recoverable.
    if (node.type === 'ImportExpression' && node.source && node.source.type === 'Literal') {
      const spec = node.source.value
      if (typeof spec === 'string' && !result.imports.some(i => i.specifier === spec)) {
        result.imports.push({ specifier: spec, names: [], dynamic: true })
      }
    }

    // this._field = ... (in constructor or methods)
    if (
      node.type === 'AssignmentExpression' &&
      node.left.type === 'MemberExpression' &&
      node.left.object.type === 'ThisExpression' &&
      node.left.property.type === 'Identifier' &&
      node.left.property.name.startsWith('_')
    ) {
      stateFields.add(node.left.property.name)
    }

    // throw new Error('...')
    if (
      node.type === 'ThrowStatement' &&
      node.argument &&
      node.argument.type === 'NewExpression' &&
      node.argument.callee &&
      node.argument.callee.name === 'Error' &&
      node.argument.arguments.length > 0
    ) {
      const arg = node.argument.arguments[0]
      if (arg.type === 'Literal') {
        errors.push(arg.value)
      } else if (arg.type === 'TemplateLiteral') {
        errors.push(templateToPattern(arg))
      }
    }

    // this._field.method() — delegation
    if (
      node.type === 'CallExpression' &&
      node.callee.type === 'MemberExpression' &&
      node.callee.object.type === 'MemberExpression' &&
      node.callee.object.object.type === 'ThisExpression' &&
      node.callee.object.property.type === 'Identifier' &&
      node.callee.object.property.name.startsWith('_') &&
      node.callee.property.type === 'Identifier'
    ) {
      const field = node.callee.object.property.name
      const method = node.callee.property.name
      if (!delegateCalls.has(field)) delegateCalls.set(field, new Set())
      delegateCalls.get(field).add(method)
    }
  })

  result.state = Array.from(stateFields)
  result.errorContracts = errors

  for (const [field, methods] of delegateCalls) {
    result.delegates.push({
      field,
      methods: Array.from(methods)
    })
  }
}

// --- Helpers ---

function extractParams (params) {
  return params.map(paramToString)
}

function paramToString (param) {
  switch (param.type) {
    case 'Identifier':
      return param.name
    case 'AssignmentPattern':
      return paramToString(param.left)
    case 'RestElement':
      return '...' + paramToString(param.argument)
    case 'ObjectPattern':
      return '{ ' + param.properties.map(p => {
        if (p.type === 'RestElement') return '...' + paramToString(p.argument)
        return p.key.name || p.key.value || '(computed)'
      }).join(', ') + ' }'
    case 'ArrayPattern':
      return '[' + param.elements.map(e => e ? paramToString(e) : '').join(', ') + ']'
    default:
      return '(?)'
  }
}

function nodeToString (node) {
  if (node.type === 'Identifier') return node.name
  if (node.type === 'MemberExpression') {
    return nodeToString(node.object) + '.' + (node.property.name || node.property.value)
  }
  return '(?)'
}

function templateToPattern (node) {
  let out = ''
  for (let i = 0; i < node.quasis.length; i++) {
    out += node.quasis[i].value.raw
    if (i < node.expressions.length) {
      out += '${...}'
    }
  }
  return out
}

function walk (node, fn) {
  if (!node || typeof node !== 'object') return
  fn(node)
  for (const key of Object.keys(node)) {
    const val = node[key]
    if (Array.isArray(val)) {
      for (const child of val) {
        if (child && typeof child.type === 'string') walk(child, fn)
      }
    } else if (val && typeof val.type === 'string') {
      walk(val, fn)
    }
  }
}