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 for key, value in expected.items(): 171 result = clean_whitespc(d[key], relax_whitespace) 172 value = clean_whitespc(value, relax_whitespace) 173 174 if debug_level > 1: 175 sys.stderr.write(f"{key}: assert('{result}' == '{value}')\n") 176 177 self.assertEqual(result, value, msg=f"at {key}") 178 179# 180# Ancillary function that replicates kdoc_files way to generate output 181# 182def cleanup_timestamp(text): 183 lines = text.split("\n") 184 185 for i, line in enumerate(lines): 186 if not line.startswith('.TH'): 187 continue 188 189 parts = shlex.split(line) 190 if len(parts) > 3: 191 parts[3] = "" 192 193 lines[i] = " ".join(parts) 194 195 196 return "\n".join(lines) 197 198def gen_output(fname, out_style, symbols, expected, 199 config=None, relax_whitespace=False): 200 """ 201 Use the output class to return an output content from KdocItem symbols. 202 """ 203 204 if not config: 205 config = MockKdocConfig() 206 207 out_style.set_config(config) 208 209 msg = out_style.output_symbols(fname, symbols) 210 211 result = clean_whitespc(msg, relax_whitespace) 212 result = cleanup_timestamp(result) 213 214 expected = clean_whitespc(expected, relax_whitespace) 215 expected = cleanup_timestamp(expected) 216 217 return result, expected 218 219# 220# Classes to be used by dynamic test generation from YAML 221# 222class CToKdocItem(GenerateKdocItem): 223 def setUp(self): 224 self.maxDiff = None 225 226 def run_parser_test(self, source, symbols, exports, fname): 227 if isinstance(symbols, dict): 228 symbols = [symbols] 229 230 if isinstance(exports, str): 231 exports=set([exports]) 232 elif isinstance(exports, list): 233 exports=set(exports) 234 235 self.run_test(source, symbols, exports=exports, 236 fname=fname, relax_whitespace=True) 237 238class KdocItemToMan(unittest.TestCase): 239 out_style = ManFormat() 240 241 def setUp(self): 242 self.maxDiff = None 243 244 def run_out_test(self, fname, symbols, expected): 245 """ 246 Generate output using out_style, 247 """ 248 result, expected = gen_output(fname, self.out_style, 249 symbols, expected) 250 251 self.assertEqual(result, expected) 252 253class KdocItemToRest(unittest.TestCase): 254 out_style = RestFormat() 255 256 def setUp(self): 257 self.maxDiff = None 258 259 def run_out_test(self, fname, symbols, expected): 260 """ 261 Generate output using out_style, 262 """ 263 result, expected = gen_output(fname, self.out_style, symbols, 264 expected, relax_whitespace=True) 265 266 self.assertEqual(result, expected) 267 268 269class CToMan(unittest.TestCase): 270 out_style = ManFormat() 271 config = MockKdocConfig() 272 xforms = CTransforms() 273 274 def setUp(self): 275 self.maxDiff = None 276 277 def run_out_test(self, fname, source, expected): 278 """ 279 Generate output using out_style, 280 """ 281 patcher = patch('builtins.open', 282 new_callable=mock_open, read_data=source) 283 284 kernel_doc = KernelDoc(self.config, fname, self.xforms) 285 286 with patcher: 287 export_table, entries = kernel_doc.parse_kdoc() 288 289 result, expected = gen_output(fname, self.out_style, 290 entries, expected, config=self.config) 291 292 self.assertEqual(result, expected) 293 294 295class CToRest(unittest.TestCase): 296 out_style = RestFormat() 297 config = MockKdocConfig() 298 xforms = CTransforms() 299 300 def setUp(self): 301 self.maxDiff = None 302 303 def run_out_test(self, fname, source, expected): 304 """ 305 Generate output using out_style, 306 """ 307 patcher = patch('builtins.open', 308 new_callable=mock_open, read_data=source) 309 310 kernel_doc = KernelDoc(self.config, fname, self.xforms) 311 312 with patcher: 313 export_table, entries = kernel_doc.parse_kdoc() 314 315 result, expected = gen_output(fname, self.out_style, entries, 316 expected, relax_whitespace=True, 317 config=self.config) 318 319 self.assertEqual(result, expected) 320 321 322# 323# Selftest class 324# 325class TestSelfValidate(GenerateKdocItem): 326 """ 327 Tests to check if logic inside GenerateKdocItem.run_test() is working. 328 """ 329 330 SOURCE = """ 331 /** 332 * function3: Exported function 333 * @arg1: @arg1 does nothing 334 * 335 * Does nothing 336 * 337 * return: 338 * always return 0. 339 */ 340 int function3(char *arg1) { return 0; }; 341 EXPORT_SYMBOL(function3); 342 """ 343 344 EXPECTED = [{ 345 'name': 'function3', 346 'type': 'function', 347 'declaration_start_line': 2, 348 349 'sections_start_lines': { 350 'Description': 4, 351 'Return': 7, 352 }, 353 'sections': { 354 'Description': 'Does nothing\n\n', 355 'Return': '\nalways return 0.\n' 356 }, 357 358 'sections_start_lines': { 359 'Description': 4, 360 'Return': 7, 361 }, 362 363 'parameterdescs': {'arg1': '@arg1 does nothing\n'}, 364 'parameterlist': ['arg1'], 365 'parameterdesc_start_lines': {'arg1': 3}, 366 'parametertypes': {'arg1': 'char *arg1'}, 367 368 'other_stuff': { 369 'func_macro': False, 370 'functiontype': 'int', 371 'purpose': 'Exported function', 372 'typedef': False 373 }, 374 }] 375 376 EXPORTS = {"function3"} 377 378 def test_parse_pass(self): 379 """ 380 Test if export_symbol is properly handled. 381 """ 382 self.run_test(self.SOURCE, self.EXPECTED, self.EXPORTS) 383 384 @unittest.expectedFailure 385 def test_no_exports(self): 386 """ 387 Test if export_symbol is properly handled. 388 """ 389 self.run_test(self.SOURCE, [], {}) 390 391 @unittest.expectedFailure 392 def test_with_empty_expected(self): 393 """ 394 Test if export_symbol is properly handled. 395 """ 396 self.run_test(self.SOURCE, [], self.EXPORTS) 397 398 @unittest.expectedFailure 399 def test_with_unfilled_expected(self): 400 """ 401 Test if export_symbol is properly handled. 402 """ 403 self.run_test(self.SOURCE, [{}], self.EXPORTS) 404 405 @unittest.expectedFailure 406 def test_with_default_expected(self): 407 """ 408 Test if export_symbol is properly handled. 409 """ 410 self.run_test(self.SOURCE, [self.DEFAULT.copy()], self.EXPORTS) 411 412# 413# Class and logic to create dynamic tests from YAML 414# 415 416class KernelDocDynamicTests(): 417 """ 418 Dynamically create a set of tests from a YAML file. 419 """ 420 421 @classmethod 422 def create_parser_test(cls, name, fname, source, symbols, exports): 423 """ 424 Return a function that will be attached to the test class. 425 """ 426 def test_method(self): 427 """Lambda-like function to run tests with provided vars""" 428 self.run_parser_test(source, symbols, exports, fname) 429 430 test_method.__name__ = f"test_gen_{name}" 431 432 setattr(CToKdocItem, test_method.__name__, test_method) 433 434 @classmethod 435 def create_out_test(cls, name, fname, symbols, out_type, data): 436 """ 437 Return a function that will be attached to the test class. 438 """ 439 def test_method(self): 440 """Lambda-like function to run tests with provided vars""" 441 self.run_out_test(fname, symbols, data) 442 443 test_method.__name__ = f"test_{out_type}_{name}" 444 445 if out_type == "man": 446 setattr(KdocItemToMan, test_method.__name__, test_method) 447 else: 448 setattr(KdocItemToRest, test_method.__name__, test_method) 449 450 @classmethod 451 def create_src2out_test(cls, name, fname, source, out_type, data): 452 """ 453 Return a function that will be attached to the test class. 454 """ 455 def test_method(self): 456 """Lambda-like function to run tests with provided vars""" 457 self.run_out_test(fname, source, data) 458 459 test_method.__name__ = f"test_{out_type}_{name}" 460 461 if out_type == "man": 462 setattr(CToMan, test_method.__name__, test_method) 463 else: 464 setattr(CToRest, test_method.__name__, test_method) 465 466 @classmethod 467 def create_tests(cls): 468 """ 469 Iterate over all scenarios and add a method to the class for each. 470 471 The logic in this function assumes a valid test that are compliant 472 with kdoc-test-schema.yaml. There is an unit test to check that. 473 As such, it picks mandatory values directly, and uses get() for the 474 optional ones. 475 """ 476 477 test_file = os.environ.get("yaml_file", TEST_FILE) 478 479 with open(test_file, encoding="utf-8") as fp: 480 testset = yaml.safe_load(fp) 481 482 tests = testset["tests"] 483 484 for idx, test in enumerate(tests): 485 name = test["name"] 486 fname = test["fname"] 487 source = test["source"] 488 expected_list = test["expected"] 489 490 exports = test.get("exports", []) 491 492 # 493 # The logic below allows setting up to 5 types of test: 494 # 1. from source to kdoc_item: test KernelDoc class; 495 # 2. from kdoc_item to man: test ManOutput class; 496 # 3. from kdoc_item to rst: test RestOutput class; 497 # 4. from source to man without checking expected KdocItem; 498 # 5. from source to rst without checking expected KdocItem. 499 # 500 for expected in expected_list: 501 kdoc_item = expected.get("kdoc_item") 502 man = expected.get("man", []) 503 rst = expected.get("rst", []) 504 505 if kdoc_item: 506 if isinstance(kdoc_item, dict): 507 kdoc_item = [kdoc_item] 508 509 symbols = [] 510 511 for arg in kdoc_item: 512 arg["fname"] = fname 513 arg["start_line"] = 1 514 515 symbols.append(KdocItem.from_dict(arg)) 516 517 if source: 518 cls.create_parser_test(name, fname, source, 519 symbols, exports) 520 521 if man: 522 cls.create_out_test(name, fname, symbols, "man", man) 523 524 if rst: 525 cls.create_out_test(name, fname, symbols, "rst", rst) 526 527 elif source: 528 if man: 529 cls.create_src2out_test(name, fname, source, "man", man) 530 531 if rst: 532 cls.create_src2out_test(name, fname, source, "rst", rst) 533 534KernelDocDynamicTests.create_tests() 535 536# 537# Run all tests 538# 539if __name__ == "__main__": 540 runner = TestUnits() 541 parser = runner.parse_args() 542 parser.add_argument("-y", "--yaml-file", "--yaml", 543 help='Name of the yaml file to load') 544 545 args = parser.parse_args() 546 547 if args.yaml_file: 548 env["yaml_file"] = os.path.expanduser(args.yaml_file) 549 550 # Run tests with customized arguments 551 runner.run(__file__, parser=parser, args=args, env=env) 552