NpmLockParser.kt

package com.depanalyzer.parser.npm

import com.depanalyzer.core.graph.DependencyNode
import com.depanalyzer.parser.Ecosystem
import tools.jackson.databind.JsonNode
import tools.jackson.databind.json.JsonMapper
import java.io.File

class NpmLockParser {
    private val jsonMapper = JsonMapper.builder().build()

    fun parse(
        packageLockFile: File,
        directPackageNames: Set<String>
    ): List<DependencyNode> {
        if (!packageLockFile.exists() || !packageLockFile.isFile) return emptyList()
        if (packageLockFile.name != "package-lock.json") return emptyList()

        val root = runCatching { jsonMapper.readTree(packageLockFile) }.getOrNull() ?: return emptyList()

        val packagesNode = root.path("packages")
        return if (packagesNode.isObject) {
            parsePackagesFormat(packagesNode, directPackageNames)
        } else {
            parseLegacyFormat(root.path("dependencies"), directPackageNames)
        }
    }

    private fun parsePackagesFormat(
        packagesNode: JsonNode,
        directPackageNames: Set<String>
    ): List<DependencyNode> {
        data class LockEntry(
            val packageName: String,
            val version: String,
            val dependencies: Set<String>
        )

        val byPath = mutableMapOf<String, LockEntry>()
        val preferredByName = mutableMapOf<String, LockEntry>()

        packagesNode.properties().forEach { (path, value) ->
            if (!value.isObject || path.isBlank()) return@forEach

            val packageName = value.path("name").textOrEmpty().ifBlank {
                extractPackageNameFromPath(path)
            }
            val version = value.path("version").textOrEmpty()
            if (packageName.isBlank() || version.isBlank()) return@forEach

            val deps = mutableSetOf<String>()
            deps += value.path("dependencies").propertyNames().toSet()
            deps += value.path("optionalDependencies").propertyNames().toSet()
            deps += value.path("peerDependencies").propertyNames().toSet()

            val entry = LockEntry(packageName = packageName, version = version, dependencies = deps)
            byPath[path] = entry

            val topLevelPath = "node_modules/$packageName"
            if (path == topLevelPath || packageName !in preferredByName) {
                preferredByName[packageName] = entry
            }
        }

        val rootDeclaredDeps = mutableSetOf<String>()
        val rootPackage = packagesNode.path("")
        if (rootPackage.isObject) {
            rootDeclaredDeps += rootPackage.path("dependencies").propertyNames().toSet()
            rootDeclaredDeps += rootPackage.path("devDependencies").propertyNames().toSet()
            rootDeclaredDeps += rootPackage.path("optionalDependencies").propertyNames().toSet()
            rootDeclaredDeps += rootPackage.path("peerDependencies").propertyNames().toSet()
        }

        val roots = when {
            directPackageNames.isNotEmpty() -> directPackageNames
            rootDeclaredDeps.isNotEmpty() -> rootDeclaredDeps
            else -> preferredByName.keys
        }

        fun buildNode(name: String, visiting: MutableSet<String>): DependencyNode? {
            val entry = preferredByName[name] ?: return null
            val key = "$name@${entry.version}"
            if (!visiting.add(key)) return null

            val (groupId, artifactId) = toGroupArtifact(name)
            val node = DependencyNode(
                id = "$groupId:$artifactId:${entry.version}",
                groupId = groupId,
                artifactId = artifactId,
                version = entry.version,
                scope = "dependencies",
                ecosystem = Ecosystem.NPM
            )

            entry.dependencies.forEach { childName ->
                buildNode(childName, visiting)?.let(node::addChild)
            }

            visiting.remove(key)
            return node
        }

        return roots.mapNotNull { buildNode(it, mutableSetOf()) }
    }

    private fun parseLegacyFormat(
        dependenciesNode: JsonNode,
        directPackageNames: Set<String>
    ): List<DependencyNode> {
        if (!dependenciesNode.isObject) return emptyList()

        fun parseNode(name: String, node: JsonNode, visiting: MutableSet<String>): DependencyNode? {
            if (!node.isObject) return null
            val version = node.path("version").textOrEmpty()
            if (version.isBlank()) return null

            val key = "$name@$version"
            if (!visiting.add(key)) return null

            val (groupId, artifactId) = toGroupArtifact(name)
            val result = DependencyNode(
                id = "$groupId:$artifactId:$version",
                groupId = groupId,
                artifactId = artifactId,
                version = version,
                scope = "dependencies",
                ecosystem = Ecosystem.NPM
            )

            node.path("dependencies").properties().forEach { (childName, childNode) ->
                parseNode(childName, childNode, visiting)?.let(result::addChild)
            }

            visiting.remove(key)
            return result
        }

        val roots = directPackageNames.ifEmpty {
            dependenciesNode.propertyNames().toSet()
        }

        return roots.mapNotNull { name ->
            val node = dependenciesNode.path(name)
            if (node.isMissingNode) null else parseNode(name, node, mutableSetOf())
        }
    }

    private fun extractPackageNameFromPath(path: String): String {
        if (!path.contains("node_modules/")) return ""
        val lastSegment = path.substringAfterLast("node_modules/")
        return if (lastSegment.startsWith("@")) {
            val parts = lastSegment.split('/')
            if (parts.size >= 2) "${parts[0]}/${parts[1]}" else lastSegment
        } else {
            lastSegment.substringBefore('/')
        }
    }

    private fun JsonNode.textOrEmpty(): String = scalarText().trim()

    private fun JsonNode.scalarText(): String = when {
        isNull || isMissingNode -> ""
        else -> toString().removeSurrounding("\"")
    }

    private fun toGroupArtifact(packageName: String): Pair<String, String> {
        return if (packageName.startsWith("@") && packageName.contains('/')) {
            packageName.substringBefore('/') to packageName.substringAfter('/')
        } else {
            "npm" to packageName
        }
    }
}