logo

qmk_firmware

custom branch of QMK firmware git clone https://anongit.hacktivis.me/git/qmk_firmware.git

lint.py (12732B)


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