#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-2.0 # Copyright(c) 2026: Mauro Carvalho Chehab . # # 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)