xref: /linux/tools/unittests/test_kdoc_parser.py (revision 6e0d7b63676b85490bbaf01c9a8ebcd692bed981)
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