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