1#!/usr/bin/env python3 2# SPDX-License-Identifier: GPL-2.0 3# Copyright(c) 2026: Mauro Carvalho Chehab <mchehab@kernel.org>. 4# 5# pylint: disable=C0200,C0413,W0102,R0914 6 7""" 8Unit tests for kernel-doc parser. 9""" 10 11import logging 12import os 13import re 14import shlex 15import sys 16import unittest 17 18from textwrap import dedent 19from unittest.mock import patch, MagicMock, mock_open 20 21import yaml 22 23SRC_DIR = os.path.dirname(os.path.realpath(__file__)) 24sys.path.insert(0, os.path.join(SRC_DIR, "../lib/python")) 25 26from kdoc.kdoc_files import KdocConfig 27from kdoc.kdoc_item import KdocItem 28from kdoc.kdoc_parser import KernelDoc 29from kdoc.kdoc_output import RestFormat, ManFormat 30 31from kdoc.xforms_lists import CTransforms 32 33from unittest_helper import TestUnits 34 35 36# 37# Test file 38# 39TEST_FILE = os.path.join(SRC_DIR, "kdoc-test.yaml") 40 41env = { 42 "yaml_file": TEST_FILE 43} 44 45# 46# Ancillary logic to clean whitespaces 47# 48#: Regex to help cleaning whitespaces 49RE_WHITESPC = re.compile(r"[ \t]++") 50RE_BEGINSPC = re.compile(r"^\s+", re.MULTILINE) 51RE_ENDSPC = re.compile(r"\s+$", re.MULTILINE) 52 53def clean_whitespc(val, relax_whitespace=False): 54 """ 55 Cleanup whitespaces to avoid false positives. 56 57 By default, strip only bein/end whitespaces, but, when relax_whitespace 58 is true, also replace multiple whitespaces in the middle. 59 """ 60 61 if isinstance(val, str): 62 val = val.strip() 63 if relax_whitespace: 64 val = RE_WHITESPC.sub(" ", val) 65 val = RE_BEGINSPC.sub("", val) 66 val = RE_ENDSPC.sub("", val) 67 elif isinstance(val, list): 68 val = [clean_whitespc(item, relax_whitespace) for item in val] 69 elif isinstance(val, dict): 70 val = {k: clean_whitespc(v, relax_whitespace) for k, v in val.items()} 71 return val 72 73# 74# Helper classes to help mocking with logger and config 75# 76class MockLogging(logging.Handler): 77 """ 78 Simple class to store everything on a list 79 """ 80 81 def __init__(self, level=logging.NOTSET): 82 super().__init__(level) 83 self.messages = [] 84 self.formatter = logging.Formatter() 85 86 def emit(self, record: logging.LogRecord) -> None: 87 """ 88 Append a formatted record to self.messages. 89 """ 90 try: 91 # The `format` method uses the handler's formatter. 92 message = self.format(record) 93 self.messages.append(message) 94 except Exception: 95 self.handleError(record) 96 97class MockKdocConfig(KdocConfig): 98 def __init__(self, *args, **kwargs): 99 super().__init__(*args, **kwargs) 100 101 self.log = logging.getLogger(__file__) 102 self.handler = MockLogging() 103 self.log.addHandler(self.handler) 104 105 def warning(self, msg): 106 """Ancillary routine to output a warning and increment error count.""" 107 108 self.log.warning(msg) 109 110# 111# Helper class to generate KdocItem and validate its contents 112# 113# TODO: check self.config.handler.messages content 114# 115class GenerateKdocItem(unittest.TestCase): 116 """ 117 Base class to run KernelDoc parser class 118 """ 119 120 DEFAULT = vars(KdocItem("", "", "", 0)) 121 122 config = MockKdocConfig() 123 xforms = CTransforms() 124 125 def setUp(self): 126 self.maxDiff = None 127 128 def run_test(self, source, __expected_list, exports={}, fname="test.c", 129 relax_whitespace=False): 130 """ 131 Stores expected values and patch the test to use source as 132 a "file" input. 133 """ 134 debug_level = int(os.getenv("VERBOSE", "0")) 135 source = dedent(source) 136 137 # Ensure that default values will be there 138 expected_list = [] 139 for e in __expected_list: 140 if not isinstance(e, dict): 141 e = vars(e) 142 143 new_e = self.DEFAULT.copy() 144 new_e["fname"] = fname 145 for key, value in e.items(): 146 new_e[key] = value 147 148 expected_list.append(new_e) 149 150 patcher = patch('builtins.open', 151 new_callable=mock_open, read_data=source) 152 153 kernel_doc = KernelDoc(self.config, fname, self.xforms) 154 155 with patcher: 156 export_table, entries = kernel_doc.parse_kdoc() 157 158 self.assertEqual(export_table, exports) 159 self.assertEqual(len(entries), len(expected_list)) 160 161 for i in range(0, len(entries)): 162 163 entry = entries[i] 164 expected = expected_list[i] 165 self.assertNotEqual(expected, None) 166 self.assertNotEqual(expected, {}) 167 self.assertIsInstance(entry, KdocItem) 168 169 d = vars(entry) 170 171 other_stuff = d.get("other_stuff", {}) 172 if "source" in other_stuff: 173 del other_stuff["source"] 174 175 for key, value in expected.items(): 176 if key == "other_stuff": 177 if "source" in value: 178 del value["source"] 179 180 result = clean_whitespc(d[key], relax_whitespace) 181 value = clean_whitespc(value, relax_whitespace) 182 183 if debug_level > 1: 184 sys.stderr.write(f"{key}: assert('{result}' == '{value}')\n") 185 186 self.assertEqual(result, value, msg=f"at {key}") 187 188# 189# Ancillary function that replicates kdoc_files way to generate output 190# 191def cleanup_timestamp(text): 192 lines = text.split("\n") 193 194 for i, line in enumerate(lines): 195 if not line.startswith('.TH'): 196 continue 197 198 parts = shlex.split(line) 199 if len(parts) > 3: 200 parts[3] = "" 201 202 lines[i] = " ".join(parts) 203 204 205 return "\n".join(lines) 206 207def gen_output(fname, out_style, symbols, expected, 208 config=None, relax_whitespace=False): 209 """ 210 Use the output class to return an output content from KdocItem symbols. 211 """ 212 213 if not config: 214 config = MockKdocConfig() 215 216 out_style.set_config(config) 217 218 msg = out_style.output_symbols(fname, symbols) 219 220 result = clean_whitespc(msg, relax_whitespace) 221 result = cleanup_timestamp(result) 222 223 expected = clean_whitespc(expected, relax_whitespace) 224 expected = cleanup_timestamp(expected) 225 226 return result, expected 227 228# 229# Classes to be used by dynamic test generation from YAML 230# 231class CToKdocItem(GenerateKdocItem): 232 def setUp(self): 233 self.maxDiff = None 234 235 def run_parser_test(self, source, symbols, exports, fname): 236 if isinstance(symbols, dict): 237 symbols = [symbols] 238 239 if isinstance(exports, str): 240 exports=set([exports]) 241 elif isinstance(exports, list): 242 exports=set(exports) 243 244 self.run_test(source, symbols, exports=exports, 245 fname=fname, relax_whitespace=True) 246 247class KdocItemToMan(unittest.TestCase): 248 out_style = ManFormat() 249 250 def setUp(self): 251 self.maxDiff = None 252 253 def run_out_test(self, fname, symbols, expected): 254 """ 255 Generate output using out_style, 256 """ 257 result, expected = gen_output(fname, self.out_style, 258 symbols, expected) 259 260 self.assertEqual(result, expected) 261 262class KdocItemToRest(unittest.TestCase): 263 out_style = RestFormat() 264 265 def setUp(self): 266 self.maxDiff = None 267 268 def run_out_test(self, fname, symbols, expected): 269 """ 270 Generate output using out_style, 271 """ 272 result, expected = gen_output(fname, self.out_style, symbols, 273 expected, relax_whitespace=True) 274 275 self.assertEqual(result, expected) 276 277 278class CToMan(unittest.TestCase): 279 out_style = ManFormat() 280 config = MockKdocConfig() 281 xforms = CTransforms() 282 283 def setUp(self): 284 self.maxDiff = None 285 286 def run_out_test(self, fname, source, expected): 287 """ 288 Generate output using out_style, 289 """ 290 patcher = patch('builtins.open', 291 new_callable=mock_open, read_data=source) 292 293 kernel_doc = KernelDoc(self.config, fname, self.xforms) 294 295 with patcher: 296 export_table, entries = kernel_doc.parse_kdoc() 297 298 result, expected = gen_output(fname, self.out_style, 299 entries, expected, config=self.config) 300 301 self.assertEqual(result, expected) 302 303 304class CToRest(unittest.TestCase): 305 out_style = RestFormat() 306 config = MockKdocConfig() 307 xforms = CTransforms() 308 309 def setUp(self): 310 self.maxDiff = None 311 312 def run_out_test(self, fname, source, expected): 313 """ 314 Generate output using out_style, 315 """ 316 patcher = patch('builtins.open', 317 new_callable=mock_open, read_data=source) 318 319 kernel_doc = KernelDoc(self.config, fname, self.xforms) 320 321 with patcher: 322 export_table, entries = kernel_doc.parse_kdoc() 323 324 result, expected = gen_output(fname, self.out_style, entries, 325 expected, relax_whitespace=True, 326 config=self.config) 327 328 self.assertEqual(result, expected) 329 330 331# 332# Selftest class 333# 334class TestSelfValidate(GenerateKdocItem): 335 """ 336 Tests to check if logic inside GenerateKdocItem.run_test() is working. 337 """ 338 339 SOURCE = """ 340 /** 341 * function3: Exported function 342 * @arg1: @arg1 does nothing 343 * 344 * Does nothing 345 * 346 * return: 347 * always return 0. 348 */ 349 int function3(char *arg1) { return 0; }; 350 EXPORT_SYMBOL(function3); 351 """ 352 353 EXPECTED = [{ 354 'name': 'function3', 355 'type': 'function', 356 'declaration_start_line': 2, 357 358 'sections_start_lines': { 359 'Description': 4, 360 'Return': 7, 361 }, 362 'sections': { 363 'Description': 'Does nothing\n\n', 364 'Return': '\nalways return 0.\n' 365 }, 366 367 'sections_start_lines': { 368 'Description': 4, 369 'Return': 7, 370 }, 371 372 'parameterdescs': {'arg1': '@arg1 does nothing\n'}, 373 'parameterlist': ['arg1'], 374 'parameterdesc_start_lines': {'arg1': 3}, 375 'parametertypes': {'arg1': 'char *arg1'}, 376 377 'other_stuff': { 378 'func_macro': False, 379 'functiontype': 'int', 380 'purpose': 'Exported function', 381 'typedef': False 382 }, 383 }] 384 385 EXPORTS = {"function3"} 386 387 def test_parse_pass(self): 388 """ 389 Test if export_symbol is properly handled. 390 """ 391 self.run_test(self.SOURCE, self.EXPECTED, self.EXPORTS) 392 393 @unittest.expectedFailure 394 def test_no_exports(self): 395 """ 396 Test if export_symbol is properly handled. 397 """ 398 self.run_test(self.SOURCE, [], {}) 399 400 @unittest.expectedFailure 401 def test_with_empty_expected(self): 402 """ 403 Test if export_symbol is properly handled. 404 """ 405 self.run_test(self.SOURCE, [], self.EXPORTS) 406 407 @unittest.expectedFailure 408 def test_with_unfilled_expected(self): 409 """ 410 Test if export_symbol is properly handled. 411 """ 412 self.run_test(self.SOURCE, [{}], self.EXPORTS) 413 414 @unittest.expectedFailure 415 def test_with_default_expected(self): 416 """ 417 Test if export_symbol is properly handled. 418 """ 419 self.run_test(self.SOURCE, [self.DEFAULT.copy()], self.EXPORTS) 420 421# 422# Class and logic to create dynamic tests from YAML 423# 424 425class KernelDocDynamicTests(): 426 """ 427 Dynamically create a set of tests from a YAML file. 428 """ 429 430 @classmethod 431 def create_parser_test(cls, name, fname, source, symbols, exports): 432 """ 433 Return a function that will be attached to the test class. 434 """ 435 def test_method(self): 436 """Lambda-like function to run tests with provided vars""" 437 self.run_parser_test(source, symbols, exports, fname) 438 439 test_method.__name__ = f"test_gen_{name}" 440 441 setattr(CToKdocItem, test_method.__name__, test_method) 442 443 @classmethod 444 def create_out_test(cls, name, fname, symbols, out_type, data): 445 """ 446 Return a function that will be attached to the test class. 447 """ 448 def test_method(self): 449 """Lambda-like function to run tests with provided vars""" 450 self.run_out_test(fname, symbols, data) 451 452 test_method.__name__ = f"test_{out_type}_{name}" 453 454 if out_type == "man": 455 setattr(KdocItemToMan, test_method.__name__, test_method) 456 else: 457 setattr(KdocItemToRest, test_method.__name__, test_method) 458 459 @classmethod 460 def create_src2out_test(cls, name, fname, source, out_type, data): 461 """ 462 Return a function that will be attached to the test class. 463 """ 464 def test_method(self): 465 """Lambda-like function to run tests with provided vars""" 466 self.run_out_test(fname, source, data) 467 468 test_method.__name__ = f"test_{out_type}_{name}" 469 470 if out_type == "man": 471 setattr(CToMan, test_method.__name__, test_method) 472 else: 473 setattr(CToRest, test_method.__name__, test_method) 474 475 @classmethod 476 def create_tests(cls): 477 """ 478 Iterate over all scenarios and add a method to the class for each. 479 480 The logic in this function assumes a valid test that are compliant 481 with kdoc-test-schema.yaml. There is an unit test to check that. 482 As such, it picks mandatory values directly, and uses get() for the 483 optional ones. 484 """ 485 486 test_file = os.environ.get("yaml_file", TEST_FILE) 487 488 with open(test_file, encoding="utf-8") as fp: 489 testset = yaml.safe_load(fp) 490 491 tests = testset["tests"] 492 493 for idx, test in enumerate(tests): 494 name = test["name"] 495 fname = test["fname"] 496 source = test["source"] 497 expected_list = test["expected"] 498 499 exports = test.get("exports", []) 500 501 # 502 # The logic below allows setting up to 5 types of test: 503 # 1. from source to kdoc_item: test KernelDoc class; 504 # 2. from kdoc_item to man: test ManOutput class; 505 # 3. from kdoc_item to rst: test RestOutput class; 506 # 4. from source to man without checking expected KdocItem; 507 # 5. from source to rst without checking expected KdocItem. 508 # 509 for expected in expected_list: 510 kdoc_item = expected.get("kdoc_item") 511 man = expected.get("man", []) 512 rst = expected.get("rst", []) 513 514 if kdoc_item: 515 if isinstance(kdoc_item, dict): 516 kdoc_item = [kdoc_item] 517 518 symbols = [] 519 520 for arg in kdoc_item: 521 arg["fname"] = fname 522 arg["start_line"] = 1 523 524 symbols.append(KdocItem.from_dict(arg)) 525 526 if source: 527 cls.create_parser_test(name, fname, source, 528 symbols, exports) 529 530 if man: 531 cls.create_out_test(name, fname, symbols, "man", man) 532 533 if rst: 534 cls.create_out_test(name, fname, symbols, "rst", rst) 535 536 elif source: 537 if man: 538 cls.create_src2out_test(name, fname, source, "man", man) 539 540 if rst: 541 cls.create_src2out_test(name, fname, source, "rst", rst) 542 543KernelDocDynamicTests.create_tests() 544 545# 546# Run all tests 547# 548if __name__ == "__main__": 549 runner = TestUnits() 550 parser = runner.parse_args() 551 parser.add_argument("-y", "--yaml-file", "--yaml", 552 help='Name of the yaml file to load') 553 554 args = parser.parse_args() 555 556 if args.yaml_file: 557 env["yaml_file"] = os.path.expanduser(args.yaml_file) 558 559 # Run tests with customized arguments 560 runner.run(__file__, parser=parser, args=args, env=env) 561