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