diff options
Diffstat (limited to 'src/debputy/lsp/lsp_debian_debputy_manifest.py')
-rw-r--r-- | src/debputy/lsp/lsp_debian_debputy_manifest.py | 337 |
1 files changed, 272 insertions, 65 deletions
diff --git a/src/debputy/lsp/lsp_debian_debputy_manifest.py b/src/debputy/lsp/lsp_debian_debputy_manifest.py index d24d441..ba30c75 100644 --- a/src/debputy/lsp/lsp_debian_debputy_manifest.py +++ b/src/debputy/lsp/lsp_debian_debputy_manifest.py @@ -25,6 +25,8 @@ from lsprotocol.types import ( CompletionParams, CompletionList, CompletionItem, + DiagnosticRelatedInformation, + Location, ) from debputy.lsp.quickfixes import propose_correct_text_quick_fix from debputy.manifest_parser.base_types import DebputyDispatchableType @@ -123,7 +125,7 @@ def _word_range_at_position( @lint_diagnostics(_LANGUAGE_IDS) def _lint_debian_debputy_manifest( - _doc_reference: str, + doc_reference: str, path: str, lines: List[str], position_codec: LintCapablePositionCodec, @@ -172,94 +174,299 @@ def _lint_debian_debputy_manifest( ) else: feature_set = lsp_get_plugin_features() - root_parser = feature_set.manifest_parser_generator.dispatchable_object_parsers[ - OPARSER_MANIFEST_ROOT - ] - diagnostics.extend(_lint_content(root_parser, content, lines, position_codec)) + pg = feature_set.manifest_parser_generator + root_parser = pg.dispatchable_object_parsers[OPARSER_MANIFEST_ROOT] + diagnostics.extend( + _lint_content( + doc_reference, + pg, + root_parser, + content, + lines, + position_codec, + ) + ) return diagnostics +def _unknown_key( + key: str, + expected_keys: Iterable[str], + line: int, + col: int, + lines: List[str], + position_codec: LintCapablePositionCodec, +) -> Tuple["Diagnostic", Optional[str]]: + key_range = position_codec.range_to_client_units( + lines, + Range( + Position( + line, + col, + ), + Position( + line, + col + len(key), + ), + ), + ) + + candidates = detect_possible_typo(key, expected_keys) + extra = "" + corrected_key = None + if candidates: + extra = f' It looks like a typo of "{candidates[0]}".' + # TODO: We should be able to tell that `install-doc` and `install-docs` are the same. + # That would enable this to work in more cases. + corrected_key = candidates[0] if len(candidates) == 1 else None + + diagnostic = Diagnostic( + key_range, + f'Unknown or unsupported key "{key}".{extra}', + DiagnosticSeverity.Error, + source="debputy", + data=[propose_correct_text_quick_fix(n) for n in candidates], + ) + return diagnostic, corrected_key + + +def _conflicting_key( + uri: str, + key_a: str, + key_b: str, + key_a_line: int, + key_a_col: int, + key_b_line: int, + key_b_col: int, + lines: List[str], + position_codec: LintCapablePositionCodec, +) -> Iterable["Diagnostic"]: + key_a_range = position_codec.range_to_client_units( + lines, + Range( + Position( + key_a_line, + key_a_col, + ), + Position( + key_a_line, + key_a_col + len(key_a), + ), + ), + ) + key_b_range = position_codec.range_to_client_units( + lines, + Range( + Position( + key_b_line, + key_b_col, + ), + Position( + key_b_line, + key_b_col + len(key_b), + ), + ), + ) + yield Diagnostic( + key_a_range, + f'The "{key_a}" cannot be used with "{key_b}".', + DiagnosticSeverity.Error, + source="debputy", + related_information=[ + DiagnosticRelatedInformation( + location=Location( + uri, + key_b_range, + ), + message=f'The attribute "{key_b}" is used here.', + ) + ], + ) + + yield Diagnostic( + key_b_range, + f'The "{key_b}" cannot be used with "{key_a}".', + DiagnosticSeverity.Error, + source="debputy", + related_information=[ + DiagnosticRelatedInformation( + location=Location( + uri, + key_a_range, + ), + message=f'The attribute "{key_a}" is used here.', + ) + ], + ) + + +def _lint_attr_value( + uri: str, + attr: AttributeDescription, + pg: ParserGenerator, + value: Any, + lines: List[str], + position_codec: LintCapablePositionCodec, +) -> Iterable["Diagnostic"]: + attr_type = attr.attribute_type + orig = get_origin(attr_type) + valid_values: Sequence[Any] = tuple() + if orig == Literal: + valid_values = get_args(attr.attribute_type) + elif orig == bool or attr.attribute_type == bool: + valid_values = ("true", "false") + elif isinstance(attr_type, type) and issubclass(attr_type, DebputyDispatchableType): + parser = pg.dispatch_parser_table_for(attr_type) + yield from _lint_content( + uri, + pg, + parser, + value, + lines, + position_codec, + ) + return + + if value in valid_values: + return + # TODO: Emit diagnostic for broken values + return + + +def _lint_declarative_mapping_input_parser( + uri: str, + pg: ParserGenerator, + parser: DeclarativeMappingInputParser, + content: Any, + lines: List[str], + position_codec: LintCapablePositionCodec, +) -> Iterable["Diagnostic"]: + if not isinstance(content, CommentedMap): + return + lc = content.lc + for key, value in content.items(): + attr = parser.manifest_attributes.get(key) + line, col = lc.key(key) + if attr is None: + diag, corrected_key = _unknown_key( + key, + parser.manifest_attributes, + line, + col, + lines, + position_codec, + ) + yield diag + if corrected_key: + key = corrected_key + attr = parser.manifest_attributes.get(corrected_key) + if attr is None: + continue + + yield from _lint_attr_value( + uri, + attr, + pg, + value, + lines, + position_codec, + ) + + for forbidden_key in attr.conflicting_attributes: + if forbidden_key in content: + con_line, con_col = lc.key(forbidden_key) + yield from _conflicting_key( + uri, + key, + forbidden_key, + line, + col, + con_line, + con_col, + lines, + position_codec, + ) + for mx in parser.mutually_exclusive_attributes: + matches = content.keys() & mx + if len(matches) < 2: + continue + key, *others = list(matches) + line, col = lc.key(key) + for other in others: + con_line, con_col = lc.key(other) + yield from _conflicting_key( + uri, + key, + other, + line, + col, + con_line, + con_col, + lines, + position_codec, + ) + + def _lint_content( + uri: str, + pg: ParserGenerator, parser: DeclarativeInputParser[Any], content: Any, lines: List[str], position_codec: LintCapablePositionCodec, -) -> Iterable[Diagnostic]: +) -> Iterable["Diagnostic"]: if isinstance(parser, DispatchingParserBase): if not isinstance(content, CommentedMap): return lc = content.lc for key, value in content.items(): - if not parser.is_known_keyword(key): + is_known = parser.is_known_keyword(key) + if not is_known: line, col = lc.key(key) - key_range = position_codec.range_to_client_units( + diag, corrected_key = _unknown_key( + key, + parser.registered_keywords(), + line, + col, lines, - Range( - Position( - line, - col, - ), - Position( - line, - col + len(key), - ), - ), + position_codec, ) + yield diag + if corrected_key is not None: + key = corrected_key + is_known = True - candidates = detect_possible_typo(key, parser.registered_keywords()) - - yield Diagnostic( - key_range, - f"Unknown or unsupported key {key}", - DiagnosticSeverity.Error, - source="debputy", - data=[propose_correct_text_quick_fix(n) for n in candidates], - ) - else: + if is_known: subparser = parser.parser_for(key) assert subparser is not None - yield from _lint_content(subparser.parser, value, lines, position_codec) + yield from _lint_content( + uri, + pg, + subparser.parser, + value, + lines, + position_codec, + ) elif isinstance(parser, ListWrappedDeclarativeInputParser): if not isinstance(content, CommentedSeq): return subparser = parser.delegate for value in content: - yield from _lint_content(subparser, value, lines, position_codec) + yield from _lint_content(uri, pg, subparser, value, lines, position_codec) elif isinstance(parser, InPackageContextParser): if not isinstance(content, CommentedMap): return for v in content.values(): - yield from _lint_content(parser.delegate, v, lines, position_codec) + yield from _lint_content(uri, pg, parser.delegate, v, lines, position_codec) elif isinstance(parser, DeclarativeMappingInputParser): - if not isinstance(content, CommentedMap): - return - lc = content.lc - for key, value in content.items(): - attr = parser.manifest_attributes.get(key) - if attr is None: - line, col = lc.key(key) - key_range = position_codec.range_to_client_units( - lines, - Range( - Position( - line, - col, - ), - Position( - line, - col + len(key), - ), - ), - ) - - candidates = detect_possible_typo(key, parser.manifest_attributes) - yield Diagnostic( - key_range, - f"Unknown or unsupported key {key}", - DiagnosticSeverity.Error, - source="debputy", - data=[propose_correct_text_quick_fix(n) for n in candidates], - ) + yield from _lint_declarative_mapping_input_parser( + uri, + pg, + parser, + content, + lines, + position_codec, + ) def is_at(position: Position, lc_pos: Tuple[int, int]) -> bool: @@ -469,7 +676,7 @@ def _guess_rule_name(segments: List[Union[str, int]], idx: int) -> str: return "<Bug: unknown rule name>" -def _ecsape(v: str) -> str: +def _escape(v: str) -> str: return '"' + v.replace("\n", "\\n") + '"' @@ -479,7 +686,7 @@ def _insert_snippet(lines: List[str], server_position: Position) -> bool: line = lines[line_no] pos_rhs = line[server_position.character :] if pos_rhs and not pos_rhs.isspace(): - _info(f"No insertion: {_ecsape(line[server_position.character:])}") + _info(f"No insertion: {_escape(line[server_position.character:])}") return False lhs_ws = line[: server_position.character] lhs = lhs_ws.strip() @@ -492,17 +699,17 @@ def _insert_snippet(lines: List[str], server_position: Position) -> bool: snippet = _COMPLETION_HINT_KEY if ":" not in lhs else _COMPLETION_HINT_VALUE new_line = line[: server_position.character] + snippet elif not lhs or (lhs_ws and not lhs_ws[0].isspace()): - _info(f"Insertion of key or value: {_ecsape(line[server_position.character:])}") + _info(f"Insertion of key or value: {_escape(line[server_position.character:])}") # Respect the provided indentation snippet = _COMPLETION_HINT_KEY if ":" not in lhs else _COMPLETION_HINT_VALUE new_line = line[: server_position.character] + snippet elif lhs.isalpha() and ":" not in lhs: - _info(f"Expanding value to a key: {_ecsape(line[server_position.character:])}") + _info(f"Expanding value to a key: {_escape(line[server_position.character:])}") # Respect the provided indentation new_line = line[: server_position.character] + _COMPLETION_HINT_KEY else: c = line[server_position.character] - _info(f"Not touching line: {_ecsape(line)} -- {_ecsape(c)}") + _info(f"Not touching line: {_escape(line)} -- {_escape(c)}") return False _info(f'Evaluating complete on synthetic line: "{new_line}"') lines[line_no] = new_line |