| #!/usr/bin/env python3 | |
| # SPDX-License-Identifier: GPL-2.0 | |
| # Copyright(c) 2026: Mauro Carvalho Chehab <mchehab@kernel.org>. | |
| # | |
| # pylint: disable=C0200,C0413,W0102,R0914 | |
| """ | |
| Unit tests for kernel-doc parser. | |
| """ | |
| import logging | |
| import os | |
| import re | |
| import shlex | |
| import sys | |
| import unittest | |
| from textwrap import dedent | |
| from unittest.mock import patch, MagicMock, mock_open | |
| import yaml | |
| SRC_DIR = os.path.dirname(os.path.realpath(__file__)) | |
| sys.path.insert(0, os.path.join(SRC_DIR, "../lib/python")) | |
| from kdoc.kdoc_files import KdocConfig | |
| from kdoc.kdoc_item import KdocItem | |
| from kdoc.kdoc_parser import KernelDoc | |
| from kdoc.kdoc_output import RestFormat, ManFormat | |
| from kdoc.xforms_lists import CTransforms | |
| from unittest_helper import TestUnits | |
| # | |
| # Test file | |
| # | |
| TEST_FILE = os.path.join(SRC_DIR, "kdoc-test.yaml") | |
| env = { | |
| "yaml_file": TEST_FILE | |
| } | |
| # | |
| # Ancillary logic to clean whitespaces | |
| # | |
| #: Regex to help cleaning whitespaces | |
| RE_WHITESPC = re.compile(r"[ \t]++") | |
| RE_BEGINSPC = re.compile(r"^\s+", re.MULTILINE) | |
| RE_ENDSPC = re.compile(r"\s+$", re.MULTILINE) | |
| def clean_whitespc(val, relax_whitespace=False): | |
| """ | |
| Cleanup whitespaces to avoid false positives. | |
| By default, strip only bein/end whitespaces, but, when relax_whitespace | |
| is true, also replace multiple whitespaces in the middle. | |
| """ | |
| if isinstance(val, str): | |
| val = val.strip() | |
| if relax_whitespace: | |
| val = RE_WHITESPC.sub(" ", val) | |
| val = RE_BEGINSPC.sub("", val) | |
| val = RE_ENDSPC.sub("", val) | |
| elif isinstance(val, list): | |
| val = [clean_whitespc(item, relax_whitespace) for item in val] | |
| elif isinstance(val, dict): | |
| val = {k: clean_whitespc(v, relax_whitespace) for k, v in val.items()} | |
| return val | |
| # | |
| # Helper classes to help mocking with logger and config | |
| # | |
| class MockLogging(logging.Handler): | |
| """ | |
| Simple class to store everything on a list | |
| """ | |
| def __init__(self, level=logging.NOTSET): | |
| super().__init__(level) | |
| self.messages = [] | |
| self.formatter = logging.Formatter() | |
| def emit(self, record: logging.LogRecord) -> None: | |
| """ | |
| Append a formatted record to self.messages. | |
| """ | |
| try: | |
| # The `format` method uses the handler's formatter. | |
| message = self.format(record) | |
| self.messages.append(message) | |
| except Exception: | |
| self.handleError(record) | |
| class MockKdocConfig(KdocConfig): | |
| def __init__(self, *args, **kwargs): | |
| super().__init__(*args, **kwargs) | |
| self.log = logging.getLogger(__file__) | |
| self.handler = MockLogging() | |
| self.log.addHandler(self.handler) | |
| def warning(self, msg): | |
| """Ancillary routine to output a warning and increment error count.""" | |
| self.log.warning(msg) | |
| # | |
| # Helper class to generate KdocItem and validate its contents | |
| # | |
| # TODO: check self.config.handler.messages content | |
| # | |
| class GenerateKdocItem(unittest.TestCase): | |
| """ | |
| Base class to run KernelDoc parser class | |
| """ | |
| DEFAULT = vars(KdocItem("", "", "", 0)) | |
| config = MockKdocConfig() | |
| xforms = CTransforms() | |
| def setUp(self): | |
| self.maxDiff = None | |
| def run_test(self, source, __expected_list, exports={}, fname="test.c", | |
| relax_whitespace=False): | |
| """ | |
| Stores expected values and patch the test to use source as | |
| a "file" input. | |
| """ | |
| debug_level = int(os.getenv("VERBOSE", "0")) | |
| source = dedent(source) | |
| # Ensure that default values will be there | |
| expected_list = [] | |
| for e in __expected_list: | |
| if not isinstance(e, dict): | |
| e = vars(e) | |
| new_e = self.DEFAULT.copy() | |
| new_e["fname"] = fname | |
| for key, value in e.items(): | |
| new_e[key] = value | |
| expected_list.append(new_e) | |
| patcher = patch('builtins.open', | |
| new_callable=mock_open, read_data=source) | |
| kernel_doc = KernelDoc(self.config, fname, self.xforms) | |
| with patcher: | |
| export_table, entries = kernel_doc.parse_kdoc() | |
| self.assertEqual(export_table, exports) | |
| self.assertEqual(len(entries), len(expected_list)) | |
| for i in range(0, len(entries)): | |
| entry = entries[i] | |
| expected = expected_list[i] | |
| self.assertNotEqual(expected, None) | |
| self.assertNotEqual(expected, {}) | |
| self.assertIsInstance(entry, KdocItem) | |
| d = vars(entry) | |
| other_stuff = d.get("other_stuff", {}) | |
| if "source" in other_stuff: | |
| del other_stuff["source"] | |
| for key, value in expected.items(): | |
| if key == "other_stuff": | |
| if "source" in value: | |
| del value["source"] | |
| result = clean_whitespc(d[key], relax_whitespace) | |
| value = clean_whitespc(value, relax_whitespace) | |
| if debug_level > 1: | |
| sys.stderr.write(f"{key}: assert('{result}' == '{value}')\n") | |
| self.assertEqual(result, value, msg=f"at {key}") | |
| # | |
| # Ancillary function that replicates kdoc_files way to generate output | |
| # | |
| def cleanup_timestamp(text): | |
| lines = text.split("\n") | |
| for i, line in enumerate(lines): | |
| if not line.startswith('.TH'): | |
| continue | |
| parts = shlex.split(line) | |
| if len(parts) > 3: | |
| parts[3] = "" | |
| lines[i] = " ".join(parts) | |
| return "\n".join(lines) | |
| def gen_output(fname, out_style, symbols, expected, | |
| config=None, relax_whitespace=False): | |
| """ | |
| Use the output class to return an output content from KdocItem symbols. | |
| """ | |
| if not config: | |
| config = MockKdocConfig() | |
| out_style.set_config(config) | |
| msg = out_style.output_symbols(fname, symbols) | |
| result = clean_whitespc(msg, relax_whitespace) | |
| result = cleanup_timestamp(result) | |
| expected = clean_whitespc(expected, relax_whitespace) | |
| expected = cleanup_timestamp(expected) | |
| return result, expected | |
| # | |
| # Classes to be used by dynamic test generation from YAML | |
| # | |
| class CToKdocItem(GenerateKdocItem): | |
| def setUp(self): | |
| self.maxDiff = None | |
| def run_parser_test(self, source, symbols, exports, fname): | |
| if isinstance(symbols, dict): | |
| symbols = [symbols] | |
| if isinstance(exports, str): | |
| exports=set([exports]) | |
| elif isinstance(exports, list): | |
| exports=set(exports) | |
| self.run_test(source, symbols, exports=exports, | |
| fname=fname, relax_whitespace=True) | |
| class KdocItemToMan(unittest.TestCase): | |
| out_style = ManFormat() | |
| def setUp(self): | |
| self.maxDiff = None | |
| def run_out_test(self, fname, symbols, expected): | |
| """ | |
| Generate output using out_style, | |
| """ | |
| result, expected = gen_output(fname, self.out_style, | |
| symbols, expected) | |
| self.assertEqual(result, expected) | |
| class KdocItemToRest(unittest.TestCase): | |
| out_style = RestFormat() | |
| def setUp(self): | |
| self.maxDiff = None | |
| def run_out_test(self, fname, symbols, expected): | |
| """ | |
| Generate output using out_style, | |
| """ | |
| result, expected = gen_output(fname, self.out_style, symbols, | |
| expected, relax_whitespace=True) | |
| self.assertEqual(result, expected) | |
| class CToMan(unittest.TestCase): | |
| out_style = ManFormat() | |
| config = MockKdocConfig() | |
| xforms = CTransforms() | |
| def setUp(self): | |
| self.maxDiff = None | |
| def run_out_test(self, fname, source, expected): | |
| """ | |
| Generate output using out_style, | |
| """ | |
| patcher = patch('builtins.open', | |
| new_callable=mock_open, read_data=source) | |
| kernel_doc = KernelDoc(self.config, fname, self.xforms) | |
| with patcher: | |
| export_table, entries = kernel_doc.parse_kdoc() | |
| result, expected = gen_output(fname, self.out_style, | |
| entries, expected, config=self.config) | |
| self.assertEqual(result, expected) | |
| class CToRest(unittest.TestCase): | |
| out_style = RestFormat() | |
| config = MockKdocConfig() | |
| xforms = CTransforms() | |
| def setUp(self): | |
| self.maxDiff = None | |
| def run_out_test(self, fname, source, expected): | |
| """ | |
| Generate output using out_style, | |
| """ | |
| patcher = patch('builtins.open', | |
| new_callable=mock_open, read_data=source) | |
| kernel_doc = KernelDoc(self.config, fname, self.xforms) | |
| with patcher: | |
| export_table, entries = kernel_doc.parse_kdoc() | |
| result, expected = gen_output(fname, self.out_style, entries, | |
| expected, relax_whitespace=True, | |
| config=self.config) | |
| self.assertEqual(result, expected) | |
| # | |
| # Selftest class | |
| # | |
| class TestSelfValidate(GenerateKdocItem): | |
| """ | |
| Tests to check if logic inside GenerateKdocItem.run_test() is working. | |
| """ | |
| SOURCE = """ | |
| /** | |
| * function3: Exported function | |
| * @arg1: @arg1 does nothing | |
| * | |
| * Does nothing | |
| * | |
| * return: | |
| * always return 0. | |
| */ | |
| int function3(char *arg1) { return 0; }; | |
| EXPORT_SYMBOL(function3); | |
| """ | |
| EXPECTED = [{ | |
| 'name': 'function3', | |
| 'type': 'function', | |
| 'declaration_start_line': 2, | |
| 'sections_start_lines': { | |
| 'Description': 4, | |
| 'Return': 7, | |
| }, | |
| 'sections': { | |
| 'Description': 'Does nothing\n\n', | |
| 'Return': '\nalways return 0.\n' | |
| }, | |
| 'sections_start_lines': { | |
| 'Description': 4, | |
| 'Return': 7, | |
| }, | |
| 'parameterdescs': {'arg1': '@arg1 does nothing\n'}, | |
| 'parameterlist': ['arg1'], | |
| 'parameterdesc_start_lines': {'arg1': 3}, | |
| 'parametertypes': {'arg1': 'char *arg1'}, | |
| 'other_stuff': { | |
| 'func_macro': False, | |
| 'functiontype': 'int', | |
| 'purpose': 'Exported function', | |
| 'typedef': False | |
| }, | |
| }] | |
| EXPORTS = {"function3"} | |
| def test_parse_pass(self): | |
| """ | |
| Test if export_symbol is properly handled. | |
| """ | |
| self.run_test(self.SOURCE, self.EXPECTED, self.EXPORTS) | |
| @unittest.expectedFailure | |
| def test_no_exports(self): | |
| """ | |
| Test if export_symbol is properly handled. | |
| """ | |
| self.run_test(self.SOURCE, [], {}) | |
| @unittest.expectedFailure | |
| def test_with_empty_expected(self): | |
| """ | |
| Test if export_symbol is properly handled. | |
| """ | |
| self.run_test(self.SOURCE, [], self.EXPORTS) | |
| @unittest.expectedFailure | |
| def test_with_unfilled_expected(self): | |
| """ | |
| Test if export_symbol is properly handled. | |
| """ | |
| self.run_test(self.SOURCE, [{}], self.EXPORTS) | |
| @unittest.expectedFailure | |
| def test_with_default_expected(self): | |
| """ | |
| Test if export_symbol is properly handled. | |
| """ | |
| self.run_test(self.SOURCE, [self.DEFAULT.copy()], self.EXPORTS) | |
| # | |
| # Class and logic to create dynamic tests from YAML | |
| # | |
| class KernelDocDynamicTests(): | |
| """ | |
| Dynamically create a set of tests from a YAML file. | |
| """ | |
| @classmethod | |
| def create_parser_test(cls, name, fname, source, symbols, exports): | |
| """ | |
| Return a function that will be attached to the test class. | |
| """ | |
| def test_method(self): | |
| """Lambda-like function to run tests with provided vars""" | |
| self.run_parser_test(source, symbols, exports, fname) | |
| test_method.__name__ = f"test_gen_{name}" | |
| setattr(CToKdocItem, test_method.__name__, test_method) | |
| @classmethod | |
| def create_out_test(cls, name, fname, symbols, out_type, data): | |
| """ | |
| Return a function that will be attached to the test class. | |
| """ | |
| def test_method(self): | |
| """Lambda-like function to run tests with provided vars""" | |
| self.run_out_test(fname, symbols, data) | |
| test_method.__name__ = f"test_{out_type}_{name}" | |
| if out_type == "man": | |
| setattr(KdocItemToMan, test_method.__name__, test_method) | |
| else: | |
| setattr(KdocItemToRest, test_method.__name__, test_method) | |
| @classmethod | |
| def create_src2out_test(cls, name, fname, source, out_type, data): | |
| """ | |
| Return a function that will be attached to the test class. | |
| """ | |
| def test_method(self): | |
| """Lambda-like function to run tests with provided vars""" | |
| self.run_out_test(fname, source, data) | |
| test_method.__name__ = f"test_{out_type}_{name}" | |
| if out_type == "man": | |
| setattr(CToMan, test_method.__name__, test_method) | |
| else: | |
| setattr(CToRest, test_method.__name__, test_method) | |
| @classmethod | |
| def create_tests(cls): | |
| """ | |
| Iterate over all scenarios and add a method to the class for each. | |
| The logic in this function assumes a valid test that are compliant | |
| with kdoc-test-schema.yaml. There is an unit test to check that. | |
| As such, it picks mandatory values directly, and uses get() for the | |
| optional ones. | |
| """ | |
| test_file = os.environ.get("yaml_file", TEST_FILE) | |
| with open(test_file, encoding="utf-8") as fp: | |
| testset = yaml.safe_load(fp) | |
| tests = testset["tests"] | |
| for idx, test in enumerate(tests): | |
| name = test["name"] | |
| fname = test["fname"] | |
| source = test["source"] | |
| expected_list = test["expected"] | |
| exports = test.get("exports", []) | |
| # | |
| # The logic below allows setting up to 5 types of test: | |
| # 1. from source to kdoc_item: test KernelDoc class; | |
| # 2. from kdoc_item to man: test ManOutput class; | |
| # 3. from kdoc_item to rst: test RestOutput class; | |
| # 4. from source to man without checking expected KdocItem; | |
| # 5. from source to rst without checking expected KdocItem. | |
| # | |
| for expected in expected_list: | |
| kdoc_item = expected.get("kdoc_item") | |
| man = expected.get("man", []) | |
| rst = expected.get("rst", []) | |
| if kdoc_item: | |
| if isinstance(kdoc_item, dict): | |
| kdoc_item = [kdoc_item] | |
| symbols = [] | |
| for arg in kdoc_item: | |
| arg["fname"] = fname | |
| arg["start_line"] = 1 | |
| symbols.append(KdocItem.from_dict(arg)) | |
| if source: | |
| cls.create_parser_test(name, fname, source, | |
| symbols, exports) | |
| if man: | |
| cls.create_out_test(name, fname, symbols, "man", man) | |
| if rst: | |
| cls.create_out_test(name, fname, symbols, "rst", rst) | |
| elif source: | |
| if man: | |
| cls.create_src2out_test(name, fname, source, "man", man) | |
| if rst: | |
| cls.create_src2out_test(name, fname, source, "rst", rst) | |
| KernelDocDynamicTests.create_tests() | |
| # | |
| # Run all tests | |
| # | |
| if __name__ == "__main__": | |
| runner = TestUnits() | |
| parser = runner.parse_args() | |
| parser.add_argument("-y", "--yaml-file", "--yaml", | |
| help='Name of the yaml file to load') | |
| args = parser.parse_args() | |
| if args.yaml_file: | |
| env["yaml_file"] = os.path.expanduser(args.yaml_file) | |
| # Run tests with customized arguments | |
| runner.run(__file__, parser=parser, args=args, env=env) |