~ruther/qmk_firmware

4d33d72975f2d63c7b6ff6fd4aa7e0f4c4347583 — Zach White 4 years ago 7ce5402
New command: qmk lint (#10761)

* Basic qmk lint command

* check for keymap readme

* change the workflow from qmk info to qmk lint

* add a strict mode

* parsing -> parse

* document qmk lint

* small info logging cleanup

* Apply suggestions from code review

Co-authored-by: Ryan <fauxpark@gmail.com>

* honor --strict in more places

* change the job name to lint

Co-authored-by: Ryan <fauxpark@gmail.com>
R .github/workflows/info.yml => .github/workflows/lint.yml +3 -6
@@ 6,7 6,7 @@ on:
    - 'keyboards/**'

jobs:
  info:
  lint:
    runs-on: ubuntu-latest

    container: qmkfm/base_container


@@ 27,7 27,7 @@ jobs:
        echo ${{ github.event.pull_request.base.sha }}
        echo '${{ steps.file_changes.outputs.files}}'

    - name: Run qmk info
    - name: Run qmk lint
      shell: 'bash {0}'
      run: |
        QMK_CHANGES=$(echo -e '${{ steps.file_changes.outputs.files}}')


@@ 45,10 45,7 @@ jobs:
          if [[ $KEYMAP_ONLY -gt 0 ]]; then
            echo "linting ${KB}"

            # TODO: info info always returns 0 - right now the only way to know failure is to inspect log lines
            qmk info -l -kb ${KB} 2>&1 | tee /tmp/$$
            !(grep -cq ☒ /tmp/$$)
            : $((exit_code = $exit_code + $?))
            qmk lint --keyboard ${KB}
          fi
        done
        exit $exit_code

M docs/cli_commands.md => docs/cli_commands.md +18 -1
@@ 178,6 178,24 @@ Creates a keymap.json from a keymap.c.
qmk c2json [--no-cpp] [-o OUTPUT] filename
```

## `qmk lint`

Checks over a keyboard and/or keymap and highlights common errors, problems, and anti-patterns.

**Usage**:

```
qmk lint [-km KEYMAP] [-kb KEYBOARD] [--strict]
```

This command is directory aware. It will automatically fill in KEYBOARD and/or KEYMAP if you are in a keyboard or keymap directory.

**Examples**:

Do a basic lint check:

    qmk lint -kb rominronin/katana60/rev2

## `qmk list-keyboards`

This command lists all the keyboards currently defined in `qmk_firmware`


@@ 309,4 327,3 @@ This command runs the python test suite. If you make changes to python code you 
```
qmk pytest
```


M docs/hardware_keyboard_guidelines.md => docs/hardware_keyboard_guidelines.md +19 -0
@@ 3,6 3,25 @@
Since starting, QMK has grown by leaps and bounds thanks to people like you who contribute to creating and maintaining our community keyboards. As we've grown we've discovered some patterns that work well, and ask that you conform to them to make it easier for other people to benefit from your hard work.


## Use QMK Lint

We have provided a tool, `qmk lint`, which will let you check over your keyboard for problems. We suggest using it frequently while working on your keyboard and keymap. 

Example passing check:

```
$ qmk lint -kb rominronin/katana60/rev2
Ψ Lint check passed!
```

Example failing check:

```
$ qmk lint -kb clueboard/66/rev3
☒ Missing keyboards/clueboard/66/rev3/readme.md
☒ Lint check failed!
```

## Naming Your Keyboard/Project

All keyboard names are in lower case, consisting only of letters, numbers, and underscore (`_`). Names may not begin with an underscore. Forward slash (`/`) is used as a sub-folder separation character.

M lib/python/qmk/cli/__init__.py => lib/python/qmk/cli/__init__.py +1 -0
@@ 19,6 19,7 @@ from . import hello
from . import info
from . import json
from . import json2c
from . import lint
from . import list
from . import kle2json
from . import new

A lib/python/qmk/cli/lint.py => lib/python/qmk/cli/lint.py +70 -0
@@ 0,0 1,70 @@
"""Command to look over a keyboard/keymap and check for common mistakes.
"""
from milc import cli

from qmk.decorators import automagic_keyboard, automagic_keymap
from qmk.info import info_json
from qmk.keymap import locate_keymap
from qmk.path import is_keyboard, keyboard


@cli.argument('--strict', action='store_true', help='Treat warnings as errors.')
@cli.argument('-kb', '--keyboard', help='The keyboard to check.')
@cli.argument('-km', '--keymap', help='The keymap to check.')
@cli.subcommand('Check keyboard and keymap for common mistakes.')
@automagic_keyboard
@automagic_keymap
def lint(cli):
    """Check keyboard and keymap for common mistakes.
    """
    if not cli.config.lint.keyboard:
        cli.log.error('Missing required argument: --keyboard')
        cli.print_help()
        return False

    if not is_keyboard(cli.config.lint.keyboard):
        cli.log.error('No such keyboard: %s', cli.config.lint.keyboard)
        return False

    # Gather data about the keyboard.
    ok = True
    keyboard_path = keyboard(cli.config.lint.keyboard)
    keyboard_info = info_json(cli.config.lint.keyboard)
    readme_path = keyboard_path / 'readme.md'

    # Check for errors in the info.json
    if keyboard_info['parse_errors']:
        ok = False
        cli.log.error('Errors found when generating info.json.')

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

    # Check for a readme.md and warn if it doesn't exist
    if not readme_path.exists():
        ok = False
        cli.log.error('Missing %s', readme_path)

    # Keymap specific checks
    if cli.config.lint.keymap:
        keymap_path = locate_keymap(cli.config.lint.keyboard, cli.config.lint.keymap)

        if not keymap_path:
            ok = False
            cli.log.error("Can't find %s keymap for %s keyboard.", cli.config.lint.keymap, cli.config.lint.keyboard)
        else:
            keymap_readme = keymap_path.parent / 'readme.md'
            if not keymap_readme.exists():
                cli.log.warning('Missing %s', keymap_readme)

                if cli.config.lint.strict:
                    ok = False

    # Check and report the overall status
    if ok:
        cli.log.info('Lint check passed!')
        return True

    cli.log.error('Lint check failed!')
    return False

M lib/python/qmk/info.py => lib/python/qmk/info.py +31 -12
@@ 28,6 28,8 @@ def info_json(keyboard):
        'keyboard_folder': str(keyboard),
        'keymaps': {},
        'layouts': {},
        'parse_errors': [],
        'parse_warnings': [],
        'maintainer': 'qmk',
    }



@@ 36,7 38,7 @@ def info_json(keyboard):
        info_data['keymaps'][keymap.name] = {'url': f'https://raw.githubusercontent.com/qmk/qmk_firmware/master/{keymap}/keymap.json'}

    # Populate layout data
    for layout_name, layout_json in _find_all_layouts(keyboard, rules).items():
    for layout_name, layout_json in _find_all_layouts(info_data, keyboard, rules).items():
        if not layout_name.startswith('LAYOUT_kc'):
            info_data['layouts'][layout_name] = layout_json



@@ 104,14 106,16 @@ def _extract_rules_mk(info_data):
    mcu = rules.get('MCU')

    if mcu in CHIBIOS_PROCESSORS:
        arm_processor_rules(info_data, rules)
        return arm_processor_rules(info_data, rules)

    elif mcu in LUFA_PROCESSORS + VUSB_PROCESSORS:
        avr_processor_rules(info_data, rules)
    else:
        cli.log.warning("%s: Unknown MCU: %s" % (info_data['keyboard_folder'], mcu))
        unknown_processor_rules(info_data, rules)
        return avr_processor_rules(info_data, rules)

    return info_data
    msg = "Unknown MCU: " + str(mcu)

    _log_warning(info_data, msg)

    return unknown_processor_rules(info_data, rules)


def _search_keyboard_h(path):


@@ 127,7 131,7 @@ def _search_keyboard_h(path):
    return layouts


def _find_all_layouts(keyboard, rules):
def _find_all_layouts(info_data, keyboard, rules):
    """Looks for layout macros associated with this keyboard.
    """
    layouts = _search_keyboard_h(Path(keyboard))


@@ 135,7 139,7 @@ def _find_all_layouts(keyboard, rules):
    if not layouts:
        # If we didn't find any layouts above we widen our search. This is error
        # prone which is why we want to encourage people to follow the standard above.
        cli.log.warning('%s: Falling back to searching for KEYMAP/LAYOUT macros.' % (keyboard))
        _log_warning(info_data, 'Falling back to searching for KEYMAP/LAYOUT macros.')
        for file in glob('keyboards/%s/*.h' % keyboard):
            if file.endswith('.h'):
                these_layouts = find_layouts(file)


@@ 153,11 157,25 @@ def _find_all_layouts(keyboard, rules):
                supported_layouts.remove(layout_name)

        if supported_layouts:
            cli.log.error('%s: Missing LAYOUT() macro for %s' % (keyboard, ', '.join(supported_layouts)))
            _log_error(info_data, 'Missing LAYOUT() macro for %s' % (', '.join(supported_layouts)))

    return layouts


def _log_error(info_data, message):
    """Send an error message to both JSON and the log.
    """
    info_data['parse_errors'].append(message)
    cli.log.error('%s: %s', info_data.get('keyboard_folder', 'Unknown Keyboard!'), message)


def _log_warning(info_data, message):
    """Send a warning message to both JSON and the log.
    """
    info_data['parse_warnings'].append(message)
    cli.log.warning('%s: %s', info_data.get('keyboard_folder', 'Unknown Keyboard!'), message)


def arm_processor_rules(info_data, rules):
    """Setup the default info for an ARM board.
    """


@@ 216,7 234,7 @@ def merge_info_jsons(keyboard, info_data):
            new_info_data = json.load(info_fd)

        if not isinstance(new_info_data, dict):
            cli.log.error("Invalid file %s, root object should be a dictionary.", str(info_file))
            _log_error(info_data, "Invalid file %s, root object should be a dictionary.", str(info_file))
            continue

        # Copy whitelisted keys into `info_data`


@@ 230,7 248,8 @@ def merge_info_jsons(keyboard, info_data):
                # Only pull in layouts we have a macro for
                if layout_name in info_data['layouts']:
                    if info_data['layouts'][layout_name]['key_count'] != len(json_layout['layout']):
                        cli.log.error('%s: %s: Number of elements in info.json does not match! info.json:%s != %s:%s', info_data['keyboard_folder'], layout_name, len(json_layout['layout']), layout_name, len(info_data['layouts'][layout_name]['layout']))
                        msg = '%s: Number of elements in info.json does not match! info.json:%s != %s:%s'
                        _log_error(info_data, msg % (layout_name, len(json_layout['layout']), layout_name, len(info_data['layouts'][layout_name]['layout'])))
                    else:
                        for i, key in enumerate(info_data['layouts'][layout_name]['layout']):
                            key.update(json_layout['layout'][i])

M lib/python/qmk/path.py => lib/python/qmk/path.py +10 -4
@@ 28,15 28,21 @@ def under_qmk_firmware():
        return None


def keymap(keyboard):
def keyboard(keyboard_name):
    """Returns the path to a keyboard's directory relative to the qmk root.
    """
    return Path('keyboards') / keyboard_name


def keymap(keyboard_name):
    """Locate the correct directory for storing a keymap.

    Args:

        keyboard
        keyboard_name
            The name of the keyboard. Example: clueboard/66/rev3
    """
    keyboard_folder = Path('keyboards') / keyboard
    keyboard_folder = keyboard(keyboard_name)

    for i in range(MAX_KEYBOARD_SUBFOLDERS):
        if (keyboard_folder / 'keymaps').exists():


@@ 45,7 51,7 @@ def keymap(keyboard):
        keyboard_folder = keyboard_folder.parent

    logging.error('Could not find the keymaps directory!')
    raise NoSuchKeyboardError('Could not find keymaps directory for: %s' % keyboard)
    raise NoSuchKeyboardError('Could not find keymaps directory for: %s' % keyboard_name)


def normpath(path):