diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
index f96994ea8d..7449f90183 100644
--- a/CONTRIBUTING.rst
+++ b/CONTRIBUTING.rst
@@ -85,7 +85,7 @@ template. Your help and contribution make ScanCode docs better, we love hearing
The ScanCode documentation is hosted at `scancode-toolkit.readthedocs.io `_.
-If you want to contribute to Scancode Documentation, you'll find `this guide here https://scancode-toolkit.readthedocs.io/en/latest/getting-started/contribute/contributing-docs.html`_ helpful.
+If you want to contribute to Scancode Documentation, you'll find `this guide here `_ helpful.
Development
===========
@@ -123,7 +123,7 @@ To set up ScanCode for local development:
git checkout -b name-of-your-bugfix-or-feature
-4. Check out the Contributing to Code Development `documentation `_, as it contains more in-depth guide for contributing code and documentation.
+4. Check out the Contributing to Code Development `documentation `_, as it contains more in-depth guide for contributing code and documentation.
5. To configure your local environment for development, locate to the main
directory of the local repository, and run the configure script.
diff --git a/requirements.txt b/requirements.txt
index 3b59481345..018df9679f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,4 @@
+antlr4-python3-runtime == 4.13.2
attrs==25.4.0
babel==2.17.0
banal==1.0.6
@@ -42,8 +43,10 @@ jsonstreams==0.6.0
keyring==23.7.0
license-expression==30.4.4
lxml==6.0.2
+luaparser==4.0.0
MarkupSafe==3.0.3
more-itertools==10.8.0
+multimethod==2.0.2
multiregex==2.0.3
normality==2.6.1
openpyxl==3.0.10
diff --git a/setup.cfg b/setup.cfg
index 7c45f388fd..eac590e2d6 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -91,8 +91,9 @@ install_requires =
jsonstreams >= 0.5.0
license_expression >= 30.4.4
lxml >= 5.4.0
+ luaparser >= 4.0.0
MarkupSafe >= 2.1.2
- multiregex >= 2.0.3
+ multiregex >= 2.0.3
normality <= 2.6.1
packageurl_python >= 0.9.0
packvers >= 21.0.0
diff --git a/src/packagedcode/__init__.py b/src/packagedcode/__init__.py
index d3c48b6e25..67320a3057 100644
--- a/src/packagedcode/__init__.py
+++ b/src/packagedcode/__init__.py
@@ -35,6 +35,7 @@
from packagedcode import pubspec
from packagedcode import pypi
from packagedcode import readme
+from packagedcode import rockspec
from packagedcode import rpm
from packagedcode import rubygems
from packagedcode import swift
@@ -202,6 +203,8 @@
rubygems.GemspecInExtractedGemHandler,
rubygems.GemspecHandler,
+ rockspec.RockspecHandler,
+
swift.SwiftManifestJsonHandler,
swift.SwiftPackageResolvedHandler,
swift.SwiftShowDependenciesDepLockHandler,
diff --git a/src/packagedcode/rockspec.py b/src/packagedcode/rockspec.py
new file mode 100644
index 0000000000..54703141a9
--- /dev/null
+++ b/src/packagedcode/rockspec.py
@@ -0,0 +1,560 @@
+import os
+import sys
+import logging
+import re
+import traceback
+from packageurl import PackageURL
+
+from luaparser import ast
+from packagedcode import models
+
+# Debug configuration - set via environment variables
+SCANCODE_DEBUG_PACKAGE = os.environ.get('SCANCODE_DEBUG_PACKAGE', False)
+TRACE = SCANCODE_DEBUG_PACKAGE
+
+
+def logger_debug(*args):
+ """Dummy function that does nothing by default."""
+ pass
+
+
+logger = logging.getLogger(__name__)
+
+# Configure logging when debug is enabled
+if TRACE:
+ logging.basicConfig(stream=sys.stdout)
+ logger.setLevel(logging.DEBUG)
+
+ def logger_debug(*args):
+ """Redefine to actually log debug messages."""
+ return logger.debug(
+ ' '.join(isinstance(a, str) and a or repr(a) for a in args)
+ )
+
+
+class RockspecHandler(models.DatafileHandler):
+ datasource_id = 'luarocks_rockspec'
+ path_patterns = ('*.rockspec',)
+ default_package_type = 'luarocks'
+ default_primary_language = 'Lua'
+ description = 'LuaRocks rockspec file'
+ documentation_url = 'https://github.com/luarocks/luarocks/blob/main/docs/rockspec_format.md'
+
+ @classmethod
+ def parse(cls, location, package_only=False):
+ """Parse rockspec file and yield PackageData object."""
+ parser = RockspecParser(location)
+ parsed_data = parser.parse()
+
+ # mandatory fields in rockspec files
+ name = parsed_data.get('package')
+ version = parsed_data.get('version')
+ vcs_url = parsed_data.get('vcs_url')
+
+ # Extract optional fields
+ description = parsed_data.get('description')
+ homepage_url = parsed_data.get('homepage_url')
+ extracted_license_statement = parsed_data.get('license')
+
+ parsed_dependencies = parsed_data.get('dependencies') or []
+
+ if parsed_dependencies:
+ dependencies = cls._build_dependent_packages(parsed_dependencies)
+ else:
+ dependencies = []
+
+ extra_data = cls._build_extra_data(parsed_data)
+
+ package_data = dict(
+ datasource_id=cls.datasource_id,
+ type=cls.default_package_type,
+ name=name,
+ version=version,
+ primary_language=cls.default_primary_language,
+ description=description,
+ homepage_url=homepage_url,
+ vcs_url=vcs_url,
+ extracted_license_statement=extracted_license_statement,
+ dependencies=dependencies,
+ extra_data=extra_data,
+ )
+
+ yield models.PackageData.from_data(package_data, package_only)
+
+ @classmethod
+ def _build_dependent_packages(cls, parsed_dependencies):
+ """Convert parsed dependency dicts to DependentPackage objects."""
+ dependencies = []
+
+ for dep_dict in parsed_dependencies:
+ dep_obj = cls._create_dependent_package(dep_dict)
+ dependencies.append(dep_obj)
+
+ return dependencies
+
+ @classmethod
+ def _create_dependent_package(cls, dep_components):
+ """Create DependentPackage from parsed dependency components dict."""
+ name = dep_components.get('name')
+ version_number = dep_components.get('version_number')
+ version_spec = dep_components.get('version_spec')
+
+ purl_str = cls._create_purl_string(name, version_number)
+ # Determine if pinned (exact version with == operator)
+ is_pinned = bool(version_spec and '==' in str(version_spec))
+
+ return models.DependentPackage(
+ purl=purl_str,
+ extracted_requirement=version_spec,
+ scope='dependencies',
+ is_runtime=True,
+ is_optional=False,
+ is_pinned=is_pinned,
+ is_direct=True,
+ )
+
+ @classmethod
+ def _build_extra_data(cls, parsed_data):
+ """Extract optional rockspec metadata into extra_data dict."""
+ extra_data = {}
+
+ rockspec_format = parsed_data.get('rockspec_format')
+ if rockspec_format:
+ extra_data['rockspec_format'] = rockspec_format
+
+ platforms = parsed_data.get('supported_platforms')
+ if platforms:
+ extra_data['supported_platforms'] = platforms
+
+ # TODO: Extract build table fields and add to extra_data
+ # - build.type: the build system type (e.g., "builtin", "cmake", "make")
+ # - build.copy_directories: directories to include in installation
+ # - build.platforms: platform-specific build configurations
+
+ return extra_data
+
+ @classmethod
+ def _create_purl_string(cls, package_name, package_version):
+ """Return PURL string for luarocks package. Raises ValueError if package_name is empty."""
+ if not package_name:
+ raise ValueError('Package name is required for PURL creation')
+
+ purl = PackageURL(
+ type=cls.default_package_type,
+ name=package_name,
+ version=package_version
+ )
+ return purl.to_string()
+
+
+
+class ParseError:
+ """Structured error representation."""
+
+ ERROR_MANDATORY_FIELD_MISSING = 'mandatory_field_missing'
+ ERROR_PARSE_FAILED = 'parse_failed'
+ ERROR_TABLE_EXTRACTION = 'table_extraction_failed'
+
+ def __init__(self, error_type, field, message):
+ self.error_type = error_type
+ self.field = field
+ self.message = message
+
+ def __str__(self):
+ return self.message
+
+ def __repr__(self):
+ return f"ParseError({self.error_type}, {self.field}: {self.message})"
+
+
+class RockspecParser:
+ """Parse LuaRocks rockspec files using Lua AST."""
+
+ def __init__(self, rockspec_path):
+ self.rockspec_path = rockspec_path
+ self.ast_tree = None
+ self.errors = []
+
+ def parse(self):
+ """Read file, parse AST, extract all rockspec fields and return data dict."""
+ try:
+ code = self._read_file()
+ self.ast_tree = self._parse_lua(code)
+
+ data = {
+ 'package': self._extract_package(),
+ 'version': self._extract_version(),
+ 'rockspec_format': self._extract_rockspec_format(),
+ 'supported_platforms': self._extract_supported_platforms(),
+ 'vcs_url': self._extract_source_url(),
+ 'description': self._extract_description(),
+ 'license': self._extract_license(),
+ 'homepage_url': self._extract_homepage(),
+ 'dependencies': self._extract_dependencies(),
+ }
+ return data
+ except Exception as e:
+ self.errors.append(ParseError(ParseError.ERROR_PARSE_FAILED, 'parse', str(e)))
+ traceback.print_exc()
+ return {}
+
+ def _read_file(self):
+ """Read and return rockspec file content as string."""
+ try:
+ with open(self.rockspec_path, 'r') as f:
+ return f.read()
+ except FileNotFoundError:
+ raise FileNotFoundError(f"File not found: {self.rockspec_path}")
+ except IOError as e:
+ raise IOError(f"Error reading file: {e}")
+
+ def _parse_lua(self, code):
+ """Parse Lua code string into AST tree."""
+ try:
+ return ast.parse(code)
+ except Exception as e:
+ raise RuntimeError(f"Lua parse error: {e}")
+
+ def _find_assignment(self, var_name):
+ """Return (target_node, value_node) tuple for variable assignment or None."""
+ if not self.ast_tree:
+ return None
+
+ for node in ast.walk(self.ast_tree):
+ # Skip nodes that aren't assignments
+ if not hasattr(node, 'targets') or not hasattr(node, 'values'): # type: ignore
+ continue
+
+ # Skip if no targets in this assignment
+ if not node.targets: # type: ignore
+ continue
+
+ # Check each target to find our variable
+ for idx, target in enumerate(node.targets): # type: ignore
+ # Skip if target doesn't have an id attribute
+ if not hasattr(target, 'id'):
+ continue
+
+ # Found the variable we're looking for
+ if target.id == var_name: # type: ignore
+ # Get the corresponding value (or first value if not enough values)
+ value = node.values[idx] if idx < len(node.values) else (node.values[0] if node.values else None) # type: ignore
+ return (target, value)
+
+ return None
+
+
+ def _extract_string_value(self, node):
+ """Extract and return string value from String AST node."""
+ if not node or type(node).__name__ != 'String':
+ return None
+
+ s_val = node.s if hasattr(node, 's') else None
+ if isinstance(s_val, bytes):
+ return s_val.decode('utf-8')
+ return str(s_val) if s_val else None
+
+ def _extract_table_values(self, table_node):
+ """Extract and return dict of key-value pairs from Table AST node."""
+ result = {}
+
+ if not table_node or type(table_node).__name__ != 'Table':
+ return result
+
+ if not hasattr(table_node, 'fields'):
+ return result
+
+ try:
+ for field in table_node.fields:
+ field_type = type(field).__name__
+
+ # Only process Field type nodes because they represent key-value pairs or array entries
+ if field_type != 'Field':
+ continue
+
+ key_node = field.key if hasattr(field, 'key') else None
+ value_node = field.value if hasattr(field, 'value') else None
+
+ # Skip fields without values
+ if not value_node:
+ continue
+
+ # Extract value (works for both hash-style and array-style)
+ extracted_value = self._extract_value(value_node)
+ if extracted_value is None:
+ continue
+
+ # Hash-style field: {key = value}
+ if key_node:
+ key = self._extract_key(key_node)
+ if key is not None:
+ result[key] = extracted_value
+ # Array-style field: {value}
+ else:
+ result[len(result)] = extracted_value
+
+ except Exception as e:
+ error_msg = f'Error extracting table values: {e}'
+ self.errors.append(ParseError(ParseError.ERROR_TABLE_EXTRACTION, 'table', error_msg))
+
+ return result
+
+ def _extract_key(self, key_node):
+ """Extract and return key from field key node."""
+ if not key_node:
+ return None
+
+ node_type = type(key_node).__name__
+
+ if node_type == 'String':
+ return self._extract_string_value(key_node)
+ elif node_type == 'Name' or node_type == 'Id':
+ return key_node.id if hasattr(key_node, 'id') else None
+ elif node_type == 'Number':
+ n_val = key_node.n if hasattr(key_node, 'n') else None
+ return str(n_val) if n_val is not None else None
+
+ return None
+
+ def _extract_value(self, node):
+ """Extract and return value from any AST node type."""
+ if node is None:
+ return None
+
+ node_type = type(node).__name__
+
+ # Handle each node type
+ if node_type == 'String':
+ return self._extract_string_value(node)
+
+ elif node_type == 'Number':
+ number_value = node.n if hasattr(node, 'n') else None
+ return number_value
+
+ elif node_type == 'Boolean':
+ bool_value = node.value if hasattr(node, 'value') else None
+ return bool_value
+
+ elif node_type == 'Table':
+ return self._extract_table_values(node)
+ # special concat case found in the some rockspec files in the wild
+ elif node_type == 'Concat':
+ return self._extract_concat(node)
+
+ elif node_type == 'Name':
+ var_name = node.id if hasattr(node, 'id') else None
+ if var_name:
+ return self._get_variable_value(var_name)
+
+ # Unknown node type
+ return None
+
+ def _get_variable_value(self, var_name):
+ """Look up variable by name and return its extracted value."""
+ assignment = self._find_assignment(var_name)
+ if not assignment:
+ return None
+
+ _, value = assignment
+ return self._extract_value(value)
+
+ def _extract_concat(self, concat_node):
+ """Extract and return concatenated string from Concat AST node."""
+ if not concat_node or type(concat_node).__name__ != 'Concat':
+ return None
+
+ left_node = concat_node.left if hasattr(concat_node, 'left') else None
+ right_node = concat_node.right if hasattr(concat_node, 'right') else None
+
+ # Recursively extract values from both sides
+ left_value = self._extract_value(left_node)
+ right_value = self._extract_value(right_node)
+
+ # Build result from available values
+ has_left = left_value is not None
+ has_right = right_value is not None
+
+ if has_left and has_right:
+ return str(left_value) + str(right_value)
+ elif has_left:
+ return str(left_value)
+ elif has_right:
+ return str(right_value)
+ else:
+ return None
+
+ def _extract_package(self):
+ """Extract and return mandatory package name field."""
+ assignment = self._find_assignment('package')
+ if not assignment:
+ self.errors.append(ParseError(ParseError.ERROR_MANDATORY_FIELD_MISSING, 'package', 'Missing mandatory field: package'))
+ return None
+
+ _, value = assignment
+ result = self._extract_value(value)
+ return str(result) if result else None
+
+ def _extract_version(self):
+ """Extract and return mandatory version field."""
+ assignment = self._find_assignment('version')
+ if not assignment:
+ self.errors.append(ParseError(ParseError.ERROR_MANDATORY_FIELD_MISSING, 'version', 'Missing mandatory field: version'))
+ return None
+
+ _, value = assignment
+ result = self._extract_value(value)
+ return str(result) if result else None
+
+ def _extract_rockspec_format(self):
+ """Extract and return optional rockspec_format field."""
+ assignment = self._find_assignment('rockspec_format')
+ if not assignment:
+ return None
+
+ _, value = assignment
+ result = self._extract_value(value)
+ return str(result) if result else None
+
+ def _extract_supported_platforms(self):
+ """Extract and return supported_platforms as sorted string list. (optional table)"""
+ assignment = self._find_assignment('supported_platforms')
+ if not assignment:
+ return []
+
+ _, platform_table_node = assignment
+ platform_dict = self._extract_table_values(platform_table_node)
+
+ # Sort platforms by numeric index order
+ return self._sort_by_numeric_index(platform_dict)
+
+ def _extract_source_url(self):
+ """Extract and return mandatory source.url field."""
+ assignment = self._find_assignment('source')
+ if not assignment:
+ self.errors.append(ParseError(ParseError.ERROR_MANDATORY_FIELD_MISSING, 'source', 'Missing mandatory field: source'))
+ return None
+
+ _, value = assignment
+ source_table = self._extract_table_values(value)
+
+ source_url = source_table.get('url')
+ if not source_url:
+ self.errors.append(ParseError(ParseError.ERROR_MANDATORY_FIELD_MISSING, 'source.url', 'Missing mandatory field: source.url'))
+ return None
+
+ return str(source_url)
+
+ def _extract_description(self):
+ """Extract and return optional description.summary field."""
+ assignment = self._find_assignment('description')
+ if not assignment:
+ return None
+
+ _, value = assignment
+ desc_table = self._extract_table_values(value)
+
+ summary = desc_table.get('summary')
+ return str(summary) if summary else None
+
+ def _extract_license(self):
+ """Extract and return optional license field from description table."""
+ assignment = self._find_assignment('description')
+ if not assignment:
+ return None
+
+ _, value = assignment
+ desc_table = self._extract_table_values(value)
+
+ license_val = desc_table.get('license')
+ return str(license_val) if license_val else None
+
+ def _extract_homepage(self):
+ """Extract and return optional homepage URL from description table."""
+ assignment = self._find_assignment('description')
+ if not assignment:
+ return None
+
+ _, value = assignment
+ desc_table = self._extract_table_values(value)
+
+ homepage = desc_table.get('homepage')
+ return str(homepage) if homepage else None
+
+ def _extract_dependencies(self):
+ """Extract dependencies and return list of parsed dependency dicts."""
+ assignment = self._find_assignment('dependencies')
+ if not assignment:
+ return []
+
+ _, dependency_table_node = assignment
+ dependency_strings = self._extract_table_values(dependency_table_node)
+
+ if not dependency_strings:
+ return []
+
+ sorted_strings = self._sort_by_numeric_index(dependency_strings)
+
+ return [
+ parsed
+ for parsed in (self.parse_dependency(dep_string) for dep_string in sorted_strings)
+ if parsed is not None
+ ]
+
+ def _sort_by_numeric_index(self, table_dict):
+ """Return values from table dict sorted by numeric keys as string list."""
+ try:
+ # Sort by numeric key index
+ sorted_items = sorted(
+ table_dict.items(),
+ key=lambda x: self._numeric_key_value(x[0])
+ )
+ return [str(value) for _, value in sorted_items]
+ except Exception:
+ # Fallback: return values in dict order
+ return [str(v) for v in table_dict.values()]
+
+ def _numeric_key_value(self, key):
+ """Return numeric sort key for dict key; non-numeric keys sort last."""
+ if isinstance(key, int):
+ return key
+ if isinstance(key, str) and key.isdigit():
+ return int(key)
+ return float('inf') # Non-numeric keys sort to the end
+
+ def parse_dependency(self, dep_string):
+ """Parse dependency string and return dict with name, version_number, and version_spec. Returns None if parsing fails."""
+ if not dep_string:
+ return None
+
+ dep_string = str(dep_string).strip()
+ pattern = r'([a-zA-Z0-9_-]+)\s*(?:([>=<~=]+)\s*)?(.+)?'
+ match = re.match(pattern, dep_string)
+
+ if not match:
+ return None
+
+ name = match.group(1)
+ operator = match.group(2)
+ version_raw = match.group(3)
+
+ version_number = None
+ version_spec = None
+
+ if version_raw:
+ version_raw = version_raw.strip()
+ version_match = re.search(r'([0-9][0-9.]*)', version_raw)
+ if version_match:
+ version_number = version_match.group(1)
+ if operator:
+ version_spec = operator + ' ' + version_number
+ else:
+ version_spec = version_number
+
+ return {
+ 'name': name,
+ 'version_number': version_number,
+ 'version_spec': version_spec,
+ }
+
+
+
diff --git a/tests/packagedcode/data/plugin/plugins_list_linux.txt b/tests/packagedcode/data/plugin/plugins_list_linux.txt
index eb4763d6c7..a8e9ad0de7 100755
--- a/tests/packagedcode/data/plugin/plugins_list_linux.txt
+++ b/tests/packagedcode/data/plugin/plugins_list_linux.txt
@@ -545,6 +545,13 @@ Package type: linux-distro
description: Linux OS release metadata file
path_patterns: '*etc/os-release', '*usr/lib/os-release'
--------------------------------------------
+Package type: luarocks
+ datasource_id: luarocks_rockspec
+ documentation URL: https://github.com/luarocks/luarocks/blob/main/docs/rockspec_format.md
+ primary language: Lua
+ description: LuaRocks rockspec file
+ path_patterns: '*.rockspec'
+--------------------------------------------
Package type: maven
datasource_id: build_gradle
documentation URL: None
diff --git a/tests/packagedcode/data/rockspec/test.rockspec b/tests/packagedcode/data/rockspec/test.rockspec
new file mode 100644
index 0000000000..6e1f4d0d17
--- /dev/null
+++ b/tests/packagedcode/data/rockspec/test.rockspec
@@ -0,0 +1,30 @@
+package = "lua-cjson"
+version = "2.1.0.16-1"
+
+source = {
+ url = "git+https://github.com/openresty/lua-cjson",
+ tag = "2.1.0.16",
+}
+
+description = {
+ summary = "A fast JSON encoding/parsing module",
+ detailed = [[
+ The Lua CJSON module provides JSON support for Lua. It features:
+ - Fast, standards compliant encoding/parsing routines
+ - Full support for JSON with UTF-8, including decoding surrogate pairs
+ - Optional run-time support for common exceptions to the JSON specification
+ (infinity, NaN,..)
+ - No dependencies on other libraries
+ ]],
+ homepage = "http://www.kyne.com.au/~mark/software/lua-cjson.php",
+ license = "MIT"
+}
+
+dependencies = {
+ "lua >= 5.1"
+}
+
+build = {
+ type = "builtin",
+ copy_directories = { "tests" }
+}
\ No newline at end of file
diff --git a/tests/packagedcode/data/rockspec/test.rockspec-expected.json b/tests/packagedcode/data/rockspec/test.rockspec-expected.json
new file mode 100644
index 0000000000..c3e476b1df
--- /dev/null
+++ b/tests/packagedcode/data/rockspec/test.rockspec-expected.json
@@ -0,0 +1,81 @@
+[
+ {
+ "type": "luarocks",
+ "namespace": null,
+ "name": "lua-cjson",
+ "version": "2.1.0.16-1",
+ "qualifiers": {},
+ "subpath": null,
+ "primary_language": "Lua",
+ "description": "A fast JSON encoding/parsing module",
+ "release_date": null,
+ "parties": [],
+ "keywords": [],
+ "homepage_url": "http://www.kyne.com.au/~mark/software/lua-cjson.php",
+ "download_url": null,
+ "size": null,
+ "sha1": null,
+ "md5": null,
+ "sha256": null,
+ "sha512": null,
+ "bug_tracking_url": null,
+ "code_view_url": null,
+ "vcs_url": "git+https://github.com/openresty/lua-cjson",
+ "copyright": null,
+ "holder": null,
+ "declared_license_expression": "mit",
+ "declared_license_expression_spdx": "MIT",
+ "license_detections": [
+ {
+ "license_expression": "mit",
+ "license_expression_spdx": "MIT",
+ "matches": [
+ {
+ "license_expression": "mit",
+ "license_expression_spdx": "MIT",
+ "from_file": null,
+ "start_line": 1,
+ "end_line": 1,
+ "matcher": "1-spdx-id",
+ "score": 100.0,
+ "matched_length": 1,
+ "match_coverage": 100.0,
+ "rule_relevance": 100,
+ "rule_identifier": "spdx-license-identifier-mit-5da48780aba670b0860c46d899ed42a0f243ff06",
+ "rule_url": null,
+ "matched_text": "MIT"
+ }
+ ],
+ "identifier": "mit-a822f434-d61f-f2b1-c792-8b8cb9e7b9bf"
+ }
+ ],
+ "other_license_expression": null,
+ "other_license_expression_spdx": null,
+ "other_license_detections": [],
+ "extracted_license_statement": "MIT",
+ "notice_text": null,
+ "source_packages": [],
+ "file_references": [],
+ "is_private": false,
+ "is_virtual": false,
+ "extra_data": {},
+ "dependencies": [
+ {
+ "purl": "pkg:luarocks/lua@5.1",
+ "extracted_requirement": ">= 5.1",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": false,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ }
+ ],
+ "repository_homepage_url": null,
+ "repository_download_url": null,
+ "api_data_url": null,
+ "datasource_id": "luarocks_rockspec",
+ "purl": "pkg:luarocks/lua-cjson@2.1.0.16-1"
+ }
+]
\ No newline at end of file
diff --git a/tests/packagedcode/data/rockspec/test1.rockspec b/tests/packagedcode/data/rockspec/test1.rockspec
new file mode 100644
index 0000000000..ed376c86b2
--- /dev/null
+++ b/tests/packagedcode/data/rockspec/test1.rockspec
@@ -0,0 +1,48 @@
+package = "kong"
+version = "3.3.0-0"
+rockspec_format = "3.0"
+supported_platforms = {"linux", "macosx"}
+source = {
+ url = "git+https://github.com/Kong/kong.git",
+ tag = "3.3.0"
+}
+description = {
+ summary = "Kong is a scalable and customizable API Management Layer built on top of Nginx.",
+ homepage = "https://konghq.com",
+ license = "Apache 2.0"
+}
+dependencies = {
+ "inspect == 3.1.3",
+ "luasec == 1.3.1",
+ "luasocket == 3.0-rc1",
+ "penlight == 1.13.1",
+ "lua-resty-http == 0.17.1",
+ "lua-resty-jit-uuid == 0.0.7",
+ "lua-ffi-zlib == 0.5",
+ "multipart == 0.5.9",
+ "version == 1.0.1",
+ "kong-lapis == 1.8.3.1",
+ "lua-cassandra == 1.5.2",
+ "pgmoon == 1.16.0",
+ "luatz == 0.4",
+ "lua_system_constants == 0.1.4",
+ "lyaml == 6.2.8",
+ "luasyslog == 2.0.1",
+ "lua_pack == 2.0.0",
+ "binaryheap >= 0.4",
+ "luaxxhash >= 1.0",
+ "lua-protobuf == 0.5.0",
+ "lua-resty-healthcheck == 1.6.2",
+ "lua-resty-mlcache == 2.6.0",
+ "lua-messagepack == 0.5.2",
+ "lua-resty-openssl == 0.8.20",
+ "lua-resty-counter == 0.2.1",
+ "lua-resty-ipmatcher == 0.6.1",
+ "lua-resty-acme == 0.11.0",
+ "lua-resty-session == 4.0.3",
+ "lua-resty-timer-ng == 0.2.5",
+ "lpeg == 1.0.2",
+}
+build = {
+ type = "builtin"
+}
diff --git a/tests/packagedcode/data/rockspec/test1.rockspec-expected.json b/tests/packagedcode/data/rockspec/test1.rockspec-expected.json
new file mode 100644
index 0000000000..6e9f5d5540
--- /dev/null
+++ b/tests/packagedcode/data/rockspec/test1.rockspec-expected.json
@@ -0,0 +1,406 @@
+[
+ {
+ "type": "luarocks",
+ "namespace": null,
+ "name": "kong",
+ "version": "3.3.0-0",
+ "qualifiers": {},
+ "subpath": null,
+ "primary_language": "Lua",
+ "description": "Kong is a scalable and customizable API Management Layer built on top of Nginx.",
+ "release_date": null,
+ "parties": [],
+ "keywords": [],
+ "homepage_url": "https://konghq.com",
+ "download_url": null,
+ "size": null,
+ "sha1": null,
+ "md5": null,
+ "sha256": null,
+ "sha512": null,
+ "bug_tracking_url": null,
+ "code_view_url": null,
+ "vcs_url": "git+https://github.com/Kong/kong.git",
+ "copyright": null,
+ "holder": null,
+ "declared_license_expression": "apache-2.0",
+ "declared_license_expression_spdx": "Apache-2.0",
+ "license_detections": [
+ {
+ "license_expression": "apache-2.0",
+ "license_expression_spdx": "Apache-2.0",
+ "matches": [
+ {
+ "license_expression": "apache-2.0",
+ "license_expression_spdx": "Apache-2.0",
+ "from_file": null,
+ "start_line": 1,
+ "end_line": 1,
+ "matcher": "1-hash",
+ "score": 100.0,
+ "matched_length": 3,
+ "match_coverage": 100.0,
+ "rule_relevance": 100,
+ "rule_identifier": "spdx_license_id_apache-2.0_for_apache-2.0.RULE",
+ "rule_url": "https://github.com/nexB/scancode-toolkit/tree/develop/src/licensedcode/data/rules/spdx_license_id_apache-2.0_for_apache-2.0.RULE",
+ "matched_text": "Apache 2.0"
+ }
+ ],
+ "identifier": "apache_2_0-d66ab77d-a5cc-7104-e702-dc7df61fe9e8"
+ }
+ ],
+ "other_license_expression": null,
+ "other_license_expression_spdx": null,
+ "other_license_detections": [],
+ "extracted_license_statement": "Apache 2.0",
+ "notice_text": null,
+ "source_packages": [],
+ "file_references": [],
+ "is_private": false,
+ "is_virtual": false,
+ "extra_data": {
+ "rockspec_format": "3.0",
+ "supported_platforms": [
+ "linux",
+ "macosx"
+ ]
+ },
+ "dependencies": [
+ {
+ "purl": "pkg:luarocks/inspect@3.1.3",
+ "extracted_requirement": "== 3.1.3",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/luasec@1.3.1",
+ "extracted_requirement": "== 1.3.1",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/luasocket@3.0",
+ "extracted_requirement": "== 3.0",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/penlight@1.13.1",
+ "extracted_requirement": "== 1.13.1",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/lua-resty-http@0.17.1",
+ "extracted_requirement": "== 0.17.1",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/lua-resty-jit-uuid@0.0.7",
+ "extracted_requirement": "== 0.0.7",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/lua-ffi-zlib@0.5",
+ "extracted_requirement": "== 0.5",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/multipart@0.5.9",
+ "extracted_requirement": "== 0.5.9",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/version@1.0.1",
+ "extracted_requirement": "== 1.0.1",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/kong-lapis@1.8.3.1",
+ "extracted_requirement": "== 1.8.3.1",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/lua-cassandra@1.5.2",
+ "extracted_requirement": "== 1.5.2",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/pgmoon@1.16.0",
+ "extracted_requirement": "== 1.16.0",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/luatz@0.4",
+ "extracted_requirement": "== 0.4",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/lua_system_constants@0.1.4",
+ "extracted_requirement": "== 0.1.4",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/lyaml@6.2.8",
+ "extracted_requirement": "== 6.2.8",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/luasyslog@2.0.1",
+ "extracted_requirement": "== 2.0.1",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/lua_pack@2.0.0",
+ "extracted_requirement": "== 2.0.0",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/binaryheap@0.4",
+ "extracted_requirement": ">= 0.4",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": false,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/luaxxhash@1.0",
+ "extracted_requirement": ">= 1.0",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": false,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/lua-protobuf@0.5.0",
+ "extracted_requirement": "== 0.5.0",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/lua-resty-healthcheck@1.6.2",
+ "extracted_requirement": "== 1.6.2",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/lua-resty-mlcache@2.6.0",
+ "extracted_requirement": "== 2.6.0",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/lua-messagepack@0.5.2",
+ "extracted_requirement": "== 0.5.2",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/lua-resty-openssl@0.8.20",
+ "extracted_requirement": "== 0.8.20",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/lua-resty-counter@0.2.1",
+ "extracted_requirement": "== 0.2.1",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/lua-resty-ipmatcher@0.6.1",
+ "extracted_requirement": "== 0.6.1",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/lua-resty-acme@0.11.0",
+ "extracted_requirement": "== 0.11.0",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/lua-resty-session@4.0.3",
+ "extracted_requirement": "== 4.0.3",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/lua-resty-timer-ng@0.2.5",
+ "extracted_requirement": "== 0.2.5",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ },
+ {
+ "purl": "pkg:luarocks/lpeg@1.0.2",
+ "extracted_requirement": "== 1.0.2",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": true,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ }
+ ],
+ "repository_homepage_url": null,
+ "repository_download_url": null,
+ "api_data_url": null,
+ "datasource_id": "luarocks_rockspec",
+ "purl": "pkg:luarocks/kong@3.3.0-0"
+ }
+]
\ No newline at end of file
diff --git a/tests/packagedcode/data/rockspec/test2.rockspec b/tests/packagedcode/data/rockspec/test2.rockspec
new file mode 100644
index 0000000000..8bf134e401
--- /dev/null
+++ b/tests/packagedcode/data/rockspec/test2.rockspec
@@ -0,0 +1,29 @@
+package = "LuaSocket"
+version = "scm-3"
+source = {
+ url = "git+https://github.com/lunarmodules/luasocket.git",
+ branch = "master"
+}
+description = {
+ summary = "Network support for the Lua language",
+ detailed = [[
+ LuaSocket is a Lua extension library composed of two parts: a set of C
+ modules that provide support for the TCP and UDP transport layers, and a
+ set of Lua modules that provide functions commonly needed by applications
+ that deal with the Internet.
+ ]],
+ homepage = "https://github.com/lunarmodules/luasocket",
+ license = "MIT"
+}
+dependencies = {
+ "lua >= 5.1"
+}
+
+build = {
+ type = "builtin",
+ copy_directories = {
+ "docs",
+ "samples",
+ "test"
+ }
+}
\ No newline at end of file
diff --git a/tests/packagedcode/data/rockspec/test2.rockspec-expected.json b/tests/packagedcode/data/rockspec/test2.rockspec-expected.json
new file mode 100644
index 0000000000..6c12f8dde8
--- /dev/null
+++ b/tests/packagedcode/data/rockspec/test2.rockspec-expected.json
@@ -0,0 +1,81 @@
+[
+ {
+ "type": "luarocks",
+ "namespace": null,
+ "name": "LuaSocket",
+ "version": "scm-3",
+ "qualifiers": {},
+ "subpath": null,
+ "primary_language": "Lua",
+ "description": "Network support for the Lua language",
+ "release_date": null,
+ "parties": [],
+ "keywords": [],
+ "homepage_url": "https://github.com/lunarmodules/luasocket",
+ "download_url": null,
+ "size": null,
+ "sha1": null,
+ "md5": null,
+ "sha256": null,
+ "sha512": null,
+ "bug_tracking_url": null,
+ "code_view_url": null,
+ "vcs_url": "git+https://github.com/lunarmodules/luasocket.git",
+ "copyright": null,
+ "holder": null,
+ "declared_license_expression": "mit",
+ "declared_license_expression_spdx": "MIT",
+ "license_detections": [
+ {
+ "license_expression": "mit",
+ "license_expression_spdx": "MIT",
+ "matches": [
+ {
+ "license_expression": "mit",
+ "license_expression_spdx": "MIT",
+ "from_file": null,
+ "start_line": 1,
+ "end_line": 1,
+ "matcher": "1-spdx-id",
+ "score": 100.0,
+ "matched_length": 1,
+ "match_coverage": 100.0,
+ "rule_relevance": 100,
+ "rule_identifier": "spdx-license-identifier-mit-5da48780aba670b0860c46d899ed42a0f243ff06",
+ "rule_url": null,
+ "matched_text": "MIT"
+ }
+ ],
+ "identifier": "mit-a822f434-d61f-f2b1-c792-8b8cb9e7b9bf"
+ }
+ ],
+ "other_license_expression": null,
+ "other_license_expression_spdx": null,
+ "other_license_detections": [],
+ "extracted_license_statement": "MIT",
+ "notice_text": null,
+ "source_packages": [],
+ "file_references": [],
+ "is_private": false,
+ "is_virtual": false,
+ "extra_data": {},
+ "dependencies": [
+ {
+ "purl": "pkg:luarocks/lua@5.1",
+ "extracted_requirement": ">= 5.1",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": false,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ }
+ ],
+ "repository_homepage_url": null,
+ "repository_download_url": null,
+ "api_data_url": null,
+ "datasource_id": "luarocks_rockspec",
+ "purl": "pkg:luarocks/luasocket@scm-3"
+ }
+]
\ No newline at end of file
diff --git a/tests/packagedcode/data/rockspec/test3.rockspec b/tests/packagedcode/data/rockspec/test3.rockspec
new file mode 100644
index 0000000000..b7e4a9984f
--- /dev/null
+++ b/tests/packagedcode/data/rockspec/test3.rockspec
@@ -0,0 +1,29 @@
+rockspec_format = "3.0"
+package = "vdsl"
+version = "0.1.0-1"
+
+source = {
+ url = "git+https://github.com/ynishi/vdsl.git",
+ tag = "v0.1.0",
+}
+
+description = {
+ summary = "Visual DSL for ComfyUI",
+ detailed = [[
+ vdsl transforms semantic scene composition into ComfyUI node graphs.
+ Pure Lua. Zero dependencies.
+ Images become portable project files through PNG-embedded recipes.
+ ]],
+ homepage = "https://github.com/ynishi/vdsl",
+ license = "MIT",
+ labels = { "comfyui", "dsl", "image-generation", "stable-diffusion" },
+}
+
+dependencies = {
+ "lua >= 5.1",
+}
+
+build = {
+ type = "builtin",
+ copy_directories = { "examples", "tests" },
+}
\ No newline at end of file
diff --git a/tests/packagedcode/data/rockspec/test3.rockspec-expected.json b/tests/packagedcode/data/rockspec/test3.rockspec-expected.json
new file mode 100644
index 0000000000..8b67e87eea
--- /dev/null
+++ b/tests/packagedcode/data/rockspec/test3.rockspec-expected.json
@@ -0,0 +1,83 @@
+[
+ {
+ "type": "luarocks",
+ "namespace": null,
+ "name": "vdsl",
+ "version": "0.1.0-1",
+ "qualifiers": {},
+ "subpath": null,
+ "primary_language": "Lua",
+ "description": "Visual DSL for ComfyUI",
+ "release_date": null,
+ "parties": [],
+ "keywords": [],
+ "homepage_url": "https://github.com/ynishi/vdsl",
+ "download_url": null,
+ "size": null,
+ "sha1": null,
+ "md5": null,
+ "sha256": null,
+ "sha512": null,
+ "bug_tracking_url": null,
+ "code_view_url": null,
+ "vcs_url": "git+https://github.com/ynishi/vdsl.git",
+ "copyright": null,
+ "holder": null,
+ "declared_license_expression": "mit",
+ "declared_license_expression_spdx": "MIT",
+ "license_detections": [
+ {
+ "license_expression": "mit",
+ "license_expression_spdx": "MIT",
+ "matches": [
+ {
+ "license_expression": "mit",
+ "license_expression_spdx": "MIT",
+ "from_file": null,
+ "start_line": 1,
+ "end_line": 1,
+ "matcher": "1-spdx-id",
+ "score": 100.0,
+ "matched_length": 1,
+ "match_coverage": 100.0,
+ "rule_relevance": 100,
+ "rule_identifier": "spdx-license-identifier-mit-5da48780aba670b0860c46d899ed42a0f243ff06",
+ "rule_url": null,
+ "matched_text": "MIT"
+ }
+ ],
+ "identifier": "mit-a822f434-d61f-f2b1-c792-8b8cb9e7b9bf"
+ }
+ ],
+ "other_license_expression": null,
+ "other_license_expression_spdx": null,
+ "other_license_detections": [],
+ "extracted_license_statement": "MIT",
+ "notice_text": null,
+ "source_packages": [],
+ "file_references": [],
+ "is_private": false,
+ "is_virtual": false,
+ "extra_data": {
+ "rockspec_format": "3.0"
+ },
+ "dependencies": [
+ {
+ "purl": "pkg:luarocks/lua@5.1",
+ "extracted_requirement": ">= 5.1",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": false,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ }
+ ],
+ "repository_homepage_url": null,
+ "repository_download_url": null,
+ "api_data_url": null,
+ "datasource_id": "luarocks_rockspec",
+ "purl": "pkg:luarocks/vdsl@0.1.0-1"
+ }
+]
\ No newline at end of file
diff --git a/tests/packagedcode/data/rockspec/test4.rockspec b/tests/packagedcode/data/rockspec/test4.rockspec
new file mode 100644
index 0000000000..f49f6f9095
--- /dev/null
+++ b/tests/packagedcode/data/rockspec/test4.rockspec
@@ -0,0 +1,32 @@
+---@diagnostic disable: lowercase-global
+
+local _MODREV, _SPECREV = "scm", "-1"
+rockspec_format = "3.0"
+version = _MODREV .. _SPECREV
+
+local user = "S1M0N38"
+package = "claude.nvim"
+
+description = {
+ summary = "A simple plugin to integrate Claude Code in Neovim",
+ detailed = [[
+claude.nvim is a simple plugin to integrate Claude Code in Neovim.
+ ]],
+ labels = { "neovim", "plugin", "lua", "claude", "ai" },
+ homepage = "https://github.com/" .. user .. "/" .. package,
+ license = "MIT",
+}
+
+dependencies = {
+ "lua >= 5.1",
+}
+
+
+source = {
+ url = "git://github.com/" .. user .. "/" .. package,
+}
+
+build = {
+ type = "builtin",
+ copy_directories = { "plugin", "doc", "scripts" },
+}
\ No newline at end of file
diff --git a/tests/packagedcode/data/rockspec/test4.rockspec-expected.json b/tests/packagedcode/data/rockspec/test4.rockspec-expected.json
new file mode 100644
index 0000000000..aa0b6af26d
--- /dev/null
+++ b/tests/packagedcode/data/rockspec/test4.rockspec-expected.json
@@ -0,0 +1,83 @@
+[
+ {
+ "type": "luarocks",
+ "namespace": null,
+ "name": "claude.nvim",
+ "version": "scm-1",
+ "qualifiers": {},
+ "subpath": null,
+ "primary_language": "Lua",
+ "description": "A simple plugin to integrate Claude Code in Neovim",
+ "release_date": null,
+ "parties": [],
+ "keywords": [],
+ "homepage_url": "https://github.com/S1M0N38/claude.nvim",
+ "download_url": null,
+ "size": null,
+ "sha1": null,
+ "md5": null,
+ "sha256": null,
+ "sha512": null,
+ "bug_tracking_url": null,
+ "code_view_url": null,
+ "vcs_url": "git://github.com/S1M0N38/claude.nvim",
+ "copyright": null,
+ "holder": null,
+ "declared_license_expression": "mit",
+ "declared_license_expression_spdx": "MIT",
+ "license_detections": [
+ {
+ "license_expression": "mit",
+ "license_expression_spdx": "MIT",
+ "matches": [
+ {
+ "license_expression": "mit",
+ "license_expression_spdx": "MIT",
+ "from_file": null,
+ "start_line": 1,
+ "end_line": 1,
+ "matcher": "1-spdx-id",
+ "score": 100.0,
+ "matched_length": 1,
+ "match_coverage": 100.0,
+ "rule_relevance": 100,
+ "rule_identifier": "spdx-license-identifier-mit-5da48780aba670b0860c46d899ed42a0f243ff06",
+ "rule_url": null,
+ "matched_text": "MIT"
+ }
+ ],
+ "identifier": "mit-a822f434-d61f-f2b1-c792-8b8cb9e7b9bf"
+ }
+ ],
+ "other_license_expression": null,
+ "other_license_expression_spdx": null,
+ "other_license_detections": [],
+ "extracted_license_statement": "MIT",
+ "notice_text": null,
+ "source_packages": [],
+ "file_references": [],
+ "is_private": false,
+ "is_virtual": false,
+ "extra_data": {
+ "rockspec_format": "3.0"
+ },
+ "dependencies": [
+ {
+ "purl": "pkg:luarocks/lua@5.1",
+ "extracted_requirement": ">= 5.1",
+ "scope": "dependencies",
+ "is_runtime": true,
+ "is_optional": false,
+ "is_pinned": false,
+ "is_direct": true,
+ "resolved_package": {},
+ "extra_data": {}
+ }
+ ],
+ "repository_homepage_url": null,
+ "repository_download_url": null,
+ "api_data_url": null,
+ "datasource_id": "luarocks_rockspec",
+ "purl": "pkg:luarocks/claude.nvim@scm-1"
+ }
+]
\ No newline at end of file
diff --git a/tests/packagedcode/data/rockspec/test_file_source.txt b/tests/packagedcode/data/rockspec/test_file_source.txt
new file mode 100644
index 0000000000..1e44909ebe
--- /dev/null
+++ b/tests/packagedcode/data/rockspec/test_file_source.txt
@@ -0,0 +1,11 @@
+Test File : Source
+
+1. test.rockspec : https://github.com/openresty/lua-cjson/blob/master/lua-cjson-2.1.0.16-1.rockspec
+
+2. test1.rockspec : https://github.com/Kong/kong/blob/master/kong-latest.rockspec
+
+3. test2.rockspec : https://github.com/lunarmodules/luasocket/blob/master/luasocket-scm-3.rockspec
+
+4. test3.rockspec : https://github.com/ynishi/vdsl/blob/main/vdsl-0.1.0-1.rockspec
+
+5. test4.rockspec : https://github.com/S1M0N38/claude.nvim/blob/main/claude.nvim-scm-1.rockspec
diff --git a/tests/packagedcode/test_rockspec.py b/tests/packagedcode/test_rockspec.py
new file mode 100644
index 0000000000..5b74bc49e8
--- /dev/null
+++ b/tests/packagedcode/test_rockspec.py
@@ -0,0 +1,105 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# ScanCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: Apache-2.0
+# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
+# See https://github.com/nexB/scancode-toolkit for support or download.
+# See https://aboutcode.org for more information about nexB OSS projects.
+
+
+import os
+import tempfile
+
+from packagedcode import rockspec
+from packages_test_utils import PackageTester
+from scancode_config import REGEN_TEST_FIXTURES
+
+
+class TestRockspecHandler(PackageTester):
+ """
+ Tests for RockspecHandler following ScanCode's testing patterns.
+
+ Tests use the comprehensive JSON snapshot approach with check_packages_data()
+ to compare entire handler output against expected JSON files. This provides
+ better visibility into the complete output and makes it easier to detect
+ any changes.
+ """
+
+ test_data_dir = os.path.join(os.path.dirname(__file__), 'data')
+
+ def test_parse_test_rockspec(self):
+ """Test parsing test.rockspec."""
+ test_file = self.get_test_loc('rockspec/test.rockspec')
+ packages = rockspec.RockspecHandler.parse(test_file)
+ expected_loc = self.get_test_loc('rockspec/test.rockspec-expected.json')
+ self.check_packages_data(packages, expected_loc, regen=REGEN_TEST_FIXTURES)
+
+ def test_parse_test1_rockspec(self):
+ """Test parsing test1.rockspec with mandatory and optional fields."""
+ test_file = self.get_test_loc('rockspec/test1.rockspec')
+ packages = rockspec.RockspecHandler.parse(test_file)
+ expected_loc = self.get_test_loc('rockspec/test1.rockspec-expected.json')
+ self.check_packages_data(packages, expected_loc, regen=REGEN_TEST_FIXTURES)
+
+ def test_parse_test2_rockspec(self):
+ """Test parsing test2.rockspec."""
+ test_file = self.get_test_loc('rockspec/test2.rockspec')
+ packages = rockspec.RockspecHandler.parse(test_file)
+ expected_loc = self.get_test_loc('rockspec/test2.rockspec-expected.json')
+ self.check_packages_data(packages, expected_loc, regen=REGEN_TEST_FIXTURES)
+
+ def test_parse_test3_rockspec(self):
+ """Test parsing test3.rockspec."""
+ test_file = self.get_test_loc('rockspec/test3.rockspec')
+ packages = rockspec.RockspecHandler.parse(test_file)
+ expected_loc = self.get_test_loc('rockspec/test3.rockspec-expected.json')
+ self.check_packages_data(packages, expected_loc, regen=REGEN_TEST_FIXTURES)
+
+ def test_parse_test4_rockspec(self):
+ """Test parsing test4.rockspec with variable concatenation."""
+ test_file = self.get_test_loc('rockspec/test4.rockspec')
+ packages = rockspec.RockspecHandler.parse(test_file)
+ expected_loc = self.get_test_loc('rockspec/test4.rockspec-expected.json')
+ self.check_packages_data(packages, expected_loc, regen=REGEN_TEST_FIXTURES)
+
+ def test_handler_is_registered(self):
+ """Test that RockspecHandler is registered in the application handlers."""
+ from packagedcode import APPLICATION_PACKAGE_DATAFILE_HANDLERS
+ handlers = [h for h in APPLICATION_PACKAGE_DATAFILE_HANDLERS
+ if h.datasource_id == 'luarocks_rockspec']
+ assert len(handlers) == 1, f"Expected 1 RockspecHandler, found {len(handlers)}"
+ assert handlers[0] == rockspec.RockspecHandler
+
+ def test_handler_in_datasource_registry(self):
+ """Test that handler is registered in HANDLER_BY_DATASOURCE_ID registry."""
+ from packagedcode import HANDLER_BY_DATASOURCE_ID
+ handler = HANDLER_BY_DATASOURCE_ID.get('luarocks_rockspec')
+ assert handler is not None, "RockspecHandler not found in registry"
+ assert handler == rockspec.RockspecHandler
+
+ def test_handler_attributes(self):
+ """Test that handler has required attributes."""
+ assert rockspec.RockspecHandler.datasource_id == 'luarocks_rockspec'
+ assert rockspec.RockspecHandler.path_patterns == ('*.rockspec',)
+ assert rockspec.RockspecHandler.default_package_type == 'luarocks'
+ assert rockspec.RockspecHandler.default_primary_language == 'Lua'
+ assert rockspec.RockspecHandler.description is not None
+
+ def test_is_datafile_rockspec(self):
+ """Test that is_datafile recognizes .rockspec files."""
+ test_file = self.get_test_loc('rockspec/test1.rockspec')
+ assert rockspec.RockspecHandler.is_datafile(test_file)
+
+ def test_is_datafile_non_rockspec(self):
+ """Test that is_datafile rejects non-.rockspec files."""
+ with tempfile.NamedTemporaryFile(suffix='.txt', delete=False) as f:
+ temp_file = f.name
+ try:
+ assert not rockspec.RockspecHandler.is_datafile(temp_file)
+ finally:
+ os.unlink(temp_file)
+
+
+if __name__ == '__main__':
+ import pytest
+ pytest.main([__file__, '-v'])