at master 13 kB view raw
1"""Command to look over a keyboard/keymap and check for common mistakes. 2""" 3from dotty_dict import dotty 4from pathlib import Path 5 6from milc import cli 7 8from qmk.decorators import automagic_keyboard, automagic_keymap 9from qmk.info import info_json 10from qmk.keyboard import keyboard_completer, keyboard_folder_or_all, is_all_keyboards, list_keyboards 11from qmk.keymap import locate_keymap, list_keymaps 12from qmk.path import keyboard 13from qmk.git import git_get_ignored_files 14from qmk.c_parse import c_source_files, preprocess_c_file 15from qmk.json_schema import json_load 16 17CHIBIOS_CONF_CHECKS = ['chconf.h', 'halconf.h', 'mcuconf.h', 'board.h'] 18INVALID_KB_FEATURES = set(['encoder_map', 'dip_switch_map', 'combo', 'tap_dance', 'via']) 19INVALID_KM_NAMES = ['via', 'vial'] 20 21 22def _list_defaultish_keymaps(kb): 23 """Return default like keymaps for a given keyboard 24 """ 25 defaultish = ['ansi', 'iso'] 26 27 # This is only here to flag it as "testable", so it doesn't fly under the radar during PR 28 defaultish.extend(INVALID_KM_NAMES) 29 30 keymaps = set() 31 for x in list_keymaps(kb, include_userspace=False): 32 if x in defaultish or x.startswith('default'): 33 keymaps.add(x) 34 35 return keymaps 36 37 38def _get_readme_files(kb, km=None): 39 """Return potential keyboard/keymap readme files 40 """ 41 search_path = locate_keymap(kb, km).parent if km else keyboard(kb) 42 43 readme_files = [] 44 45 if not km: 46 current_path = Path(search_path.parts[0]) 47 for path_part in search_path.parts[1:]: 48 current_path = current_path / path_part 49 readme_files.extend(current_path.glob('*readme.md')) 50 51 for file in search_path.glob("**/*readme.md"): 52 # Ignore keymaps when only globing keyboard files 53 if not km and 'keymaps' in file.parts: 54 continue 55 readme_files.append(file) 56 57 return set(readme_files) 58 59 60def _get_build_files(kb, km=None): 61 """Return potential keyboard/keymap build files 62 """ 63 search_path = locate_keymap(kb, km).parent if km else keyboard(kb) 64 65 build_files = [] 66 67 if not km: 68 current_path = Path() 69 for path_part in search_path.parts: 70 current_path = current_path / path_part 71 build_files.extend(current_path.glob('*rules.mk')) 72 73 for file in search_path.glob("**/*rules.mk"): 74 # Ignore keymaps when only globing keyboard files 75 if not km and 'keymaps' in file.parts: 76 continue 77 build_files.append(file) 78 79 return set(build_files) 80 81 82def _get_code_files(kb, km=None): 83 """Return potential keyboard/keymap code files 84 """ 85 search_path = locate_keymap(kb, km).parent if km else keyboard(kb) 86 87 code_files = [] 88 89 if not km: 90 current_path = Path() 91 for path_part in search_path.parts: 92 current_path = current_path / path_part 93 code_files.extend(current_path.glob('*.h')) 94 code_files.extend(current_path.glob('*.c')) 95 96 for file in c_source_files([search_path]): 97 # Ignore keymaps when only globing keyboard files 98 if not km and 'keymaps' in file.parts: 99 continue 100 code_files.append(file) 101 102 return code_files 103 104 105def _is_invalid_readme(file): 106 """Check if file contains any unfilled content 107 """ 108 tokens = [ 109 '%KEYBOARD%', 110 '%REAL_NAME%', 111 '%USER_NAME%', 112 'image replace me!', 113 'A short description of the keyboard/project', 114 'The PCBs, controllers supported', 115 'Links to where you can find this hardware', 116 ] 117 118 for line in file.read_text(encoding='utf-8').split("\n"): 119 if any(token in line for token in tokens): 120 return True 121 return False 122 123 124def _is_empty_rules(file): 125 """Check if file contains any useful content 126 """ 127 for line in file.read_text(encoding='utf-8').split("\n"): 128 if len(line) > 0 and not line.isspace() and not line.startswith('#'): 129 return False 130 return True 131 132 133def _is_empty_include(file): 134 """Check if file contains any useful content 135 """ 136 for line in preprocess_c_file(file).split("\n"): 137 if len(line) > 0 and not line.isspace() and not line.startswith('#pragma once'): 138 return False 139 return True 140 141 142def _has_license(file): 143 """Check file has a license header 144 """ 145 # Crude assumption that first line of license header is a comment 146 fline = open(file).readline().rstrip() 147 return fline.startswith(("/*", "//")) 148 149 150def _handle_json_errors(kb, info): 151 """Convert any json errors into lint errors 152 """ 153 ok = True 154 # Check for errors in the json 155 if info['parse_errors']: 156 ok = False 157 cli.log.error(f'{kb}: Errors found when generating info.json.') 158 159 if cli.config.lint.strict and info['parse_warnings']: 160 ok = False 161 cli.log.error(f'{kb}: Warnings found when generating info.json (Strict mode enabled.)') 162 return ok 163 164 165def _handle_invalid_features(kb, info): 166 """Check for features that should never be enabled at the keyboard level 167 """ 168 ok = True 169 features = set(info.get('features', [])) 170 for found in features & INVALID_KB_FEATURES: 171 ok = False 172 cli.log.error(f'{kb}: Invalid keyboard level feature detected - {found}') 173 return ok 174 175 176def _handle_invalid_config(kb, info): 177 """Check for invalid keyboard level config 178 """ 179 if info.get('url') == "": 180 cli.log.warning(f'{kb}: Invalid keyboard level config detected - Optional field "url" should not be empty.') 181 return True 182 183 184def _chibios_conf_includenext_check(target): 185 """Check the ChibiOS conf.h for the correct inclusion of the next conf.h 186 """ 187 for i, line in enumerate(target.open()): 188 if f'#include_next "{target.name}"' in line: 189 return f'Found `#include_next "{target.name}"` on line {i} of {target}, should be `#include_next <{target.name}>` (use angle brackets, not quotes)' 190 return None 191 192 193def _rules_mk_assignment_only(rules_mk): 194 """Check the keyboard-level rules.mk to ensure it only has assignments. 195 """ 196 errors = [] 197 continuation = None 198 for i, line in enumerate(rules_mk.open()): 199 line = line.strip() 200 201 if '#' in line: 202 line = line[:line.index('#')] 203 204 if continuation: 205 line = continuation + line 206 continuation = None 207 208 if line: 209 if line[-1] == '\\': 210 continuation = line[:-1] 211 continue 212 213 if line and '=' not in line: 214 errors.append(f'Non-assignment code on line +{i} {rules_mk}: {line}') 215 216 return errors 217 218 219def _handle_duplicating_code_defaults(kb, info): 220 def _collect_dotted_output(kb_info_json, prefix=''): 221 """Print the info.json in a plain text format with dot-joined keys. 222 """ 223 for key in sorted(kb_info_json): 224 new_prefix = f'{prefix}.{key}' if prefix else key 225 226 if isinstance(kb_info_json[key], dict): 227 yield from _collect_dotted_output(kb_info_json[key], new_prefix) 228 elif isinstance(kb_info_json[key], list): 229 # TODO: handle non primitives? 230 yield (new_prefix, kb_info_json[key]) 231 else: 232 yield (new_prefix, kb_info_json[key]) 233 234 defaults_map = json_load(Path('data/mappings/info_defaults.hjson')) 235 dotty_info = dotty(info) 236 237 for key, v_default in _collect_dotted_output(defaults_map): 238 v_info = dotty_info.get(key) 239 if v_default == v_info: 240 cli.log.warning(f'{kb}: Option "{key}" duplicates default value of "{v_default}"') 241 242 return True 243 244 245def keymap_check(kb, km): 246 """Perform the keymap level checks. 247 """ 248 ok = True 249 keymap_path = locate_keymap(kb, km) 250 251 if not keymap_path: 252 ok = False 253 cli.log.error("%s: Can't find %s keymap.", kb, km) 254 return ok 255 256 if km in INVALID_KM_NAMES: 257 ok = False 258 cli.log.error("%s: The keymap %s should not exist!", kb, km) 259 return ok 260 261 # Additional checks 262 invalid_files = git_get_ignored_files(keymap_path.parent.as_posix()) 263 for file in invalid_files: 264 cli.log.error(f'{kb}/{km}: The file "{file}" should not exist!') 265 ok = False 266 267 for file in _get_code_files(kb, km): 268 if not _has_license(file): 269 cli.log.error(f'{kb}/{km}: The file "{file}" does not have a license header!') 270 ok = False 271 272 if file.name in CHIBIOS_CONF_CHECKS: 273 check_error = _chibios_conf_includenext_check(file) 274 if check_error is not None: 275 cli.log.error(f'{kb}/{km}: {check_error}') 276 ok = False 277 278 return ok 279 280 281def keyboard_check(kb): # noqa C901 282 """Perform the keyboard level checks. 283 """ 284 ok = True 285 kb_info = info_json(kb) 286 287 if not _handle_json_errors(kb, kb_info): 288 ok = False 289 290 # Additional checks 291 if not _handle_invalid_features(kb, kb_info): 292 ok = False 293 294 if not _handle_invalid_config(kb, kb_info): 295 ok = False 296 297 if not _handle_duplicating_code_defaults(kb, kb_info): 298 ok = False 299 300 invalid_files = git_get_ignored_files(f'keyboards/{kb}/') 301 for file in invalid_files: 302 if 'keymap' in file: 303 continue 304 cli.log.error(f'{kb}: The file "{file}" should not exist!') 305 ok = False 306 307 if not _get_readme_files(kb): 308 cli.log.error(f'{kb}: Is missing a readme.md file!') 309 ok = False 310 311 for file in _get_readme_files(kb): 312 if _is_invalid_readme(file): 313 cli.log.error(f'{kb}: The file "{file}" still contains template tokens!') 314 ok = False 315 316 for file in _get_build_files(kb): 317 if _is_empty_rules(file): 318 cli.log.error(f'{kb}: The file "{file}" is effectively empty and should be removed!') 319 ok = False 320 321 if file.suffix in ['rules.mk']: 322 rules_mk_assignment_errors = _rules_mk_assignment_only(file) 323 if rules_mk_assignment_errors: 324 ok = False 325 cli.log.error('%s: Non-assignment code found in rules.mk. Move it to post_rules.mk instead.', kb) 326 for assignment_error in rules_mk_assignment_errors: 327 cli.log.error(assignment_error) 328 329 for file in _get_code_files(kb): 330 if not _has_license(file): 331 cli.log.error(f'{kb}: The file "{file}" does not have a license header!') 332 ok = False 333 334 if file.name in ['config.h']: 335 if _is_empty_include(file): 336 cli.log.error(f'{kb}: The file "{file}" is effectively empty and should be removed!') 337 ok = False 338 339 if file.name in CHIBIOS_CONF_CHECKS: 340 check_error = _chibios_conf_includenext_check(file) 341 if check_error is not None: 342 cli.log.error(f'{kb}: {check_error}') 343 ok = False 344 345 return ok 346 347 348@cli.argument('--strict', action='store_true', help='Treat warnings as errors') 349@cli.argument('-kb', '--keyboard', action='append', type=keyboard_folder_or_all, completer=keyboard_completer, help='Keyboard to check. May be passed multiple times.') 350@cli.argument('-km', '--keymap', help='The keymap to check') 351@cli.subcommand('Check keyboard and keymap for common mistakes.') 352@automagic_keyboard 353@automagic_keymap 354def lint(cli): 355 """Check keyboard and keymap for common mistakes. 356 """ 357 # Determine our keyboard list 358 if not cli.config.lint.keyboard: 359 cli.log.error('Missing required arguments: --keyboard') 360 cli.print_help() 361 return False 362 363 if isinstance(cli.config.lint.keyboard, str): 364 # if provided via config - string not array 365 keyboard_list = [cli.config.lint.keyboard] 366 elif any(is_all_keyboards(kb) for kb in cli.args.keyboard): 367 keyboard_list = list_keyboards() 368 else: 369 keyboard_list = list(set(cli.config.lint.keyboard)) 370 371 failed = [] 372 373 # Lint each keyboard 374 for kb in keyboard_list: 375 # Determine keymaps to also check 376 if cli.args.keymap == 'all': 377 keymaps = list_keymaps(kb) 378 elif cli.config.lint.keymap: 379 keymaps = {cli.config.lint.keymap} 380 else: 381 keymaps = _list_defaultish_keymaps(kb) 382 # Ensure that at least a 'default' keymap always exists 383 keymaps.add('default') 384 385 ok = True 386 387 # keyboard level checks 388 if not keyboard_check(kb): 389 ok = False 390 391 # Keymap specific checks 392 for keymap in keymaps: 393 if not keymap_check(kb, keymap): 394 ok = False 395 396 # Report status 397 if not ok: 398 failed.append(kb) 399 400 # Check and report the overall status 401 if failed: 402 cli.log.error('Lint check failed for: %s', ', '.join(failed)) 403 return False 404 405 cli.log.info('Lint check passed!') 406 return True