Skip to content

Usage linting

Statically lint the usage grammar itself, before any argv is parsed. See the Usage linting guide.

check

check(doc: str) -> list[Diagnostic]

Statically lint the usage grammar itself, before any argv is parsed.

Returns a list of "warning"-level diagnostics for defects the usage message contains that docopt would otherwise accept silently: an option declared but never usable, a [default: ...] on an always-required element, an empty [options] shortcut.

The check is read-only and does not affect parsing or matching. A usage message too malformed to parse returns no warnings, since that error surfaces at parse time instead.

Source code in src/docopt2/_lint.py
def check(doc: str) -> list[Diagnostic]:
    """Statically lint the usage grammar itself, before any argv is parsed.

    Returns a list of ``"warning"``-level diagnostics for defects the usage message contains that
    [`docopt`][docopt2.docopt] would otherwise accept silently: an option declared but never usable, a
    ``[default: ...]`` on an always-required element, an empty ``[options]`` shortcut.

    The check is read-only and does not affect parsing or matching. A usage message too malformed to parse
    returns no warnings, since that error surfaces at parse time instead.
    """
    warnings: list[Diagnostic] = []
    declared: list[tuple[Option, Span, Span]] = []
    for line, offset in _section_lines(doc, "options:"):
        if not _OPTION_LINE.match(line.lstrip()):
            continue  # a wrapped description, or a prose bullet: `- fast, quick` is not an option
        try:
            option = Option.parse(line)
        except DocoptLanguageError:
            return []  # docopt rejects the whole message; that error is the one worth reading, not a lint
        declared.append((option, _token_span(line, offset), _default_span(line, offset)))
    options = [option for option, _name, _default in declared]
    try:
        # formal_tokens, not formal_usage: the latter builds the same tree but offsets every leaf span, and
        # the redundant-alternative rule reads the source a branch was written from.
        usage = single_usage_section(doc)
        pattern = parse_pattern(formal_tokens(usage), list(options))
    except (DocoptLanguageError, RecursionError):
        return warnings
    in_usage = {leaf.name for leaf in pattern.flat(Option)}
    required = set(always_required_names(pattern))
    has_shortcut = bool(pattern.flat(OptionsShortcut))

    for option, name_span, default_span in declared:
        if option.name not in in_usage and not has_shortcut:
            warnings.append(
                _warn(
                    f"option `{option.name}` is declared but never used",
                    doc,
                    name_span,
                    "in the options:",
                    "declared here",
                    f"add `{option.name}` to a usage line, or add `[options]` to accept it",
                )
            )
        if option.argcount and option.value is not None and option.name in required:
            warnings.append(
                _warn(
                    f"dead default on `{option.name}`, which the usage always requires",
                    doc,
                    default_span,
                    "in the options:",
                    "never applies",
                    f"make `{option.name}` optional with `[ ... ]`, or drop the default",
                )
            )
    for line, offset in _section_lines(doc, "arguments:"):
        name = line.split()[0]
        if _DEFAULT.search(line) and name in required:
            warnings.append(
                _warn(
                    f"dead default on `{name}`, which the usage always requires",
                    doc,
                    _default_span(line, offset),
                    "in the arguments:",
                    "never applies",
                    f"make `{name}` optional with `[{name}]`, or drop the default",
                )
            )
    if _variadic_units(pattern) >= 2:
        warnings.append(
            Diagnostic(
                summary="ambiguous grammar: two variadic positionals share one sequence",
                help="the token split between them is undefined; make one non-variadic, or split into branches",
                level="warning",
            )
        )
    # Not `flat(Either)`: it returns a matching node without descending into it, and a multi-line usage is
    # the outermost Either - so an `(a | a)` written inside a usage line would go unseen.
    for either in _nested_eithers(pattern):
        seen: set[str] = set()
        for branch in either.children:
            if _branch_key(branch, usage) in seen:
                warnings.append(
                    Diagnostic(
                        summary="redundant alternative: this branch repeats an earlier one",
                        help="one of the identical `|` alternatives can never be reached; remove it or fix the typo",
                        level="warning",
                    )
                )
            seen.add(_branch_key(branch, usage))
    if has_shortcut and not options:
        at = doc.lower().find("[options]")
        span = (at, at + len("[options]")) if at != -1 else None
        warnings.append(
            _warn(
                "`[options]` accepts nothing - no options are described",
                doc,
                span,
                "in the usage:",
                "expands to nothing",
                "describe options in an `Options:` section, or remove `[options]`",
            )
        )
    return warnings