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