summaryrefslogtreecommitdiff
path: root/lib/python/qmk/cli/lint.py
blob: 7ebb0cf9c454e18d01b872ce9efa35bb4a83b184 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
"""Command to look over a keyboard/keymap and check for common mistakes.
"""
from pathlib import Path

from milc import cli

from qmk.decorators import automagic_keyboard, automagic_keymap
from qmk.info import info_json
from qmk.keyboard import keyboard_completer, list_keyboards
from qmk.keymap import locate_keymap, list_keymaps
from qmk.path import is_keyboard, keyboard
from qmk.git import git_get_ignored_files
from qmk.c_parse import c_source_files

CHIBIOS_CONF_CHECKS = ['chconf.h', 'halconf.h', 'mcuconf.h', 'board.h']
INVALID_KB_FEATURES = set(['encoder_map', 'dip_switch_map', 'combo', 'tap_dance', 'via'])


def _list_defaultish_keymaps(kb):
    """Return default like keymaps for a given keyboard
    """
    defaultish = ['ansi', 'iso', 'via']

    # This is only here to flag it as "testable", so it doesn't fly under the radar during PR
    defaultish.append('vial')

    keymaps = set()
    for x in list_keymaps(kb):
        if x in defaultish or x.startswith('default'):
            keymaps.add(x)

    return keymaps


def _get_code_files(kb, km=None):
    """Return potential keyboard/keymap code files
    """
    search_path = locate_keymap(kb, km).parent if km else keyboard(kb)

    code_files = []
    for file in c_source_files([search_path]):
        # Ignore keymaps when only globing keyboard files
        if not km and 'keymaps' in file.parts:
            continue
        code_files.append(file)

    return code_files


def _has_license(file):
    """Check file has a license header
    """
    # Crude assumption that first line of license header is a comment
    fline = open(file).readline().rstrip()
    return fline.startswith(("/*", "//"))


def _handle_json_errors(kb, info):
    """Convert any json errors into lint errors
    """
    ok = True
    # Check for errors in the json
    if info['parse_errors']:
        ok = False
        cli.log.error(f'{kb}: Errors found when generating info.json.')

    if cli.config.lint.strict and info['parse_warnings']:
        ok = False
        cli.log.error(f'{kb}: Warnings found when generating info.json (Strict mode enabled.)')
    return ok


def _handle_invalid_features(kb, info):
    """Check for features that should never be enabled at the keyboard level
    """
    ok = True
    features = set(info.get('features', []))
    for found in features & INVALID_KB_FEATURES:
        ok = False
        cli.log.error(f'{kb}: Invalid keyboard level feature detected - {found}')
    return ok


def _chibios_conf_includenext_check(target):
    """Check the ChibiOS conf.h for the correct inclusion of the next conf.h
    """
    for i, line in enumerate(target.open()):
        if f'#include_next "{target.name}"' in line:
            return f'Found `#include_next "{target.name}"` on line {i} of {target}, should be `#include_next <{target.name}>` (use angle brackets, not quotes)'
    return None


def _rules_mk_assignment_only(kb):
    """Check the keyboard-level rules.mk to ensure it only has assignments.
    """
    keyboard_path = keyboard(kb)
    current_path = Path()
    errors = []

    for path_part in keyboard_path.parts:
        current_path = current_path / path_part
        rules_mk = current_path / 'rules.mk'

        if rules_mk.exists():
            continuation = None

            for i, line in enumerate(rules_mk.open()):
                line = line.strip()

                if '#' in line:
                    line = line[:line.index('#')]

                if continuation:
                    line = continuation + line
                    continuation = None

                if line:
                    if line[-1] == '\\':
                        continuation = line[:-1]
                        continue

                    if line and '=' not in line:
                        errors.append(f'Non-assignment code on line +{i} {rules_mk}: {line}')

    return errors


def keymap_check(kb, km):
    """Perform the keymap level checks.
    """
    ok = True
    keymap_path = locate_keymap(kb, km)

    if not keymap_path:
        ok = False
        cli.log.error("%s: Can't find %s keymap.", kb, km)
        return ok

    # Additional checks
    invalid_files = git_get_ignored_files(keymap_path.parent.as_posix())
    for file in invalid_files:
        cli.log.error(f'{kb}/{km}: The file "{file}" should not exist!')
        ok = False

    for file in _get_code_files(kb, km):
        if not _has_license(file):
            cli.log.error(f'{kb}/{km}: The file "{file}" does not have a license header!')
            ok = False

        if file.name in CHIBIOS_CONF_CHECKS:
            check_error = _chibios_conf_includenext_check(file)
            if check_error is not None:
                cli.log.error(f'{kb}/{km}: {check_error}')
                ok = False

    return ok


def keyboard_check(kb):
    """Perform the keyboard level checks.
    """
    ok = True
    kb_info = info_json(kb)

    if not _handle_json_errors(kb, kb_info):
        ok = False

    # Additional checks
    if not _handle_invalid_features(kb, kb_info):
        ok = False

    rules_mk_assignment_errors = _rules_mk_assignment_only(kb)
    if rules_mk_assignment_errors:
        ok = False
        cli.log.error('%s: Non-assignment code found in rules.mk. Move it to post_rules.mk instead.', kb)
        for assignment_error in rules_mk_assignment_errors:
            cli.log.error(assignment_error)

    invalid_files = git_get_ignored_files(f'keyboards/{kb}/')
    for file in invalid_files:
        if 'keymap' in file:
            continue
        cli.log.error(f'{kb}: The file "{file}" should not exist!')
        ok = False

    for file in _get_code_files(kb):
        if not _has_license(file):
            cli.log.error(f'{kb}: The file "{file}" does not have a license header!')
            ok = False

        if file.name in CHIBIOS_CONF_CHECKS:
            check_error = _chibios_conf_includenext_check(file)
            if check_error is not None:
                cli.log.error(f'{kb}: {check_error}')
                ok = False

    return ok


@cli.argument('--strict', action='store_true', help='Treat warnings as errors')
@cli.argument('-kb', '--keyboard', completer=keyboard_completer, help='Comma separated list of keyboards to check')
@cli.argument('-km', '--keymap', help='The keymap to check')
@cli.argument('--all-kb', action='store_true', arg_only=True, help='Check all keyboards')
@cli.argument('--all-km', action='store_true', arg_only=True, help='Check all keymaps')
@cli.subcommand('Check keyboard and keymap for common mistakes.')
@automagic_keyboard
@automagic_keymap
def lint(cli):
    """Check keyboard and keymap for common mistakes.
    """
    failed = []

    # Determine our keyboard list
    if cli.args.all_kb:
        if cli.args.keyboard:
            cli.log.warning('Both --all-kb and --keyboard passed, --all-kb takes precedence.')

        keyboard_list = list_keyboards()
    elif not cli.config.lint.keyboard:
        cli.log.error('Missing required arguments: --keyboard or --all-kb')
        cli.print_help()
        return False
    else:
        keyboard_list = cli.config.lint.keyboard.split(',')

    # Lint each keyboard
    for kb in keyboard_list:
        if not is_keyboard(kb):
            cli.log.error('No such keyboard: %s', kb)
            continue

        # Determine keymaps to also check
        if cli.args.all_km:
            keymaps = list_keymaps(kb)
        elif cli.config.lint.keymap:
            keymaps = {cli.config.lint.keymap}
        else:
            keymaps = _list_defaultish_keymaps(kb)
            # Ensure that at least a 'default' keymap always exists
            keymaps.add('default')

        ok = True

        # keyboard level checks
        if not keyboard_check(kb):
            ok = False

        # Keymap specific checks
        for keymap in keymaps:
            if not keymap_check(kb, keymap):
                ok = False

        # Report status
        if not ok:
            failed.append(kb)

    # Check and report the overall status
    if failed:
        cli.log.error('Lint check failed for: %s', ', '.join(failed))
        return False

    cli.log.info('Lint check passed!')
    return True