From b7f176418fc041f677ea9ce4f0e5cc6fc55fd7c4 Mon Sep 17 00:00:00 2001 From: James Westman Date: Fri, 17 Jan 2025 16:44:21 -0600 Subject: [PATCH 1/4] completions: Add completions for response IDs --- blueprintcompiler/completions.py | 63 ++++++++++++++++++- blueprintcompiler/completions_utils.py | 20 ++++-- blueprintcompiler/language/__init__.py | 9 ++- .../language/adw_response_dialog.py | 16 +---- .../language/gtk_combo_box_text.py | 2 +- blueprintcompiler/language/gtk_file_filter.py | 2 +- blueprintcompiler/language/gtk_layout.py | 2 +- blueprintcompiler/language/gtk_scale.py | 2 +- blueprintcompiler/language/gtk_size_group.py | 2 +- blueprintcompiler/language/gtk_string_list.py | 2 +- blueprintcompiler/language/gtk_styles.py | 2 +- .../language/gtkbuilder_child.py | 11 +++- blueprintcompiler/language/response_id.py | 10 +-- blueprintcompiler/parse_tree.py | 12 +++- .../action_widget_have_no_id.err | 2 +- .../action_widget_in_invalid_container.err | 2 +- 16 files changed, 117 insertions(+), 42 deletions(-) diff --git a/blueprintcompiler/completions.py b/blueprintcompiler/completions.py index a4e86b9..1bfbec6 100644 --- a/blueprintcompiler/completions.py +++ b/blueprintcompiler/completions.py @@ -76,9 +76,13 @@ def complete( next_token = tokens[next_token_idx] # if the current token is an identifier or whitespace, move to the token before it - while tokens[token_idx].type in [TokenType.IDENT, TokenType.WHITESPACE]: + if tokens[token_idx].type == TokenType.IDENT: idx = tokens[token_idx].start token_idx -= 1 + else: + while tokens[token_idx].type == TokenType.WHITESPACE: + idx = tokens[token_idx].start + token_idx -= 1 yield from _complete(lsp, ast_node, tokens, idx, token_idx, next_token) @@ -236,7 +240,7 @@ def property_completer(ctx: CompletionContext): for prop_name, prop in ctx.ast_node.gir_class.properties.items(): yield get_property_completion( prop_name, - prop, + prop.type, ctx, annotations.is_property_translated(prop), prop.doc, @@ -245,7 +249,11 @@ def property_completer(ctx: CompletionContext): @completer( applies_in=[language.Property, language.A11yProperty], - matches=[[(TokenType.IDENT, None), (TokenType.OP, ":")]], + matches=[ + [(TokenType.IDENT, None), (TokenType.OP, ":")], + [(TokenType.PUNCTUATION, ",")], + [(TokenType.PUNCTUATION, "[")], + ], ) def prop_value_completer(ctx: CompletionContext): if isinstance(ctx.ast_node, language.Property): @@ -367,3 +375,52 @@ def template_completer(_ctx: CompletionContext): CompletionItemKind.Snippet, snippet="template ${1:ClassName} : ${2:ParentClass} {\n $0\n}", ) + + +@completer( + applies_in=[language.ObjectContent, language.ChildType], + matches=[[(TokenType.PUNCTUATION, "[")]], + applies_in_subclass=[("Gtk", "Dialog"), ("Gtk", "InfoBar")], +) +def response_id_completer(ctx: CompletionContext): + yield Completion( + "action", + CompletionItemKind.Snippet, + sort_text=get_sort_key(CompletionPriority.KEYWORD, "action"), + snippet="action response=$0", + ) + + +@completer( + [language.ChildAnnotation, language.ExtResponse], + [[(TokenType.IDENT, "action"), (TokenType.IDENT, "response"), (TokenType.OP, "=")]], +) +def complete_response_id(ctx: CompletionContext): + gir = ctx.ast_node.root.gir + response_type = gir.get_type("ResponseType", "Gtk") + yield from [ + Completion( + name, + kind=CompletionItemKind.EnumMember, + docs=member.doc, + ) + for name, member in response_type.members.items() + ] + + +@completer( + [language.ChildAnnotation, language.ExtResponse], + [ + [ + (TokenType.IDENT, "action"), + (TokenType.IDENT, "response"), + (TokenType.OP, "="), + (TokenType.IDENT, None), + ] + ], +) +def complete_response_default(ctx: CompletionContext): + yield Completion( + "default", + kind=CompletionItemKind.Keyword, + ) diff --git a/blueprintcompiler/completions_utils.py b/blueprintcompiler/completions_utils.py index bfca55a..36399b1 100644 --- a/blueprintcompiler/completions_utils.py +++ b/blueprintcompiler/completions_utils.py @@ -68,10 +68,22 @@ def completer(applies_in: T.List, matches: T.List = [], applies_in_subclass=None # For completers that apply in ObjectContent nodes, we can further # check that the object is the right class if applies_in_subclass is not None: - type = ast_node.root.gir.get_type( - applies_in_subclass[1], applies_in_subclass[0] - ) - if not ast_node.gir_class or not ast_node.gir_class.assignable_to(type): + parent_obj = ast_node + while parent_obj is not None and not hasattr(parent_obj, "gir_class"): + parent_obj = parent_obj.parent + + if ( + parent_obj is None + or not parent_obj.gir_class + or not any( + [ + parent_obj.gir_class.assignable_to( + parent_obj.root.gir.get_type(c[1], c[0]) + ) + for c in applies_in_subclass + ] + ) + ): return any_match = len(matches) == 0 diff --git a/blueprintcompiler/language/__init__.py b/blueprintcompiler/language/__init__.py index 5eb2b60..7f59d96 100644 --- a/blueprintcompiler/language/__init__.py +++ b/blueprintcompiler/language/__init__.py @@ -34,9 +34,16 @@ from .gtk_scale import ExtScaleMarks from .gtk_size_group import ExtSizeGroupWidgets from .gtk_string_list import ExtStringListStrings from .gtk_styles import ExtStyles -from .gtkbuilder_child import Child, ChildExtension, ChildInternal, ChildType +from .gtkbuilder_child import ( + Child, + ChildAnnotation, + ChildExtension, + ChildInternal, + ChildType, +) from .gtkbuilder_template import Template from .imports import GtkDirective, Import +from .response_id import ExtResponse from .types import ClassName from .ui import UI from .values import ( diff --git a/blueprintcompiler/language/adw_response_dialog.py b/blueprintcompiler/language/adw_response_dialog.py index c621df0..b1b43a4 100644 --- a/blueprintcompiler/language/adw_response_dialog.py +++ b/blueprintcompiler/language/adw_response_dialog.py @@ -140,7 +140,7 @@ class ExtAdwResponseDialog(AstNode): @completer( applies_in=[ObjectContent], - applies_in_subclass=("Adw", "MessageDialog"), + applies_in_subclass=[("Adw", "AlertDialog"), ("Adw", "MessageDialog")], matches=new_statement_patterns, ) def complete_adw_message_dialog(_ctx: CompletionContext): @@ -149,20 +149,6 @@ def complete_adw_message_dialog(_ctx: CompletionContext): ) -@completer( - applies_in=[ObjectContent], - applies_in_subclass=("Adw", "AlertDialog"), - matches=new_statement_patterns, -) -def complete_adw_alert_dialog(_ctx: CompletionContext): - yield Completion( - "responses", - CompletionItemKind.Keyword, - snippet="responses [\n\t$0\n]", - sort_text=get_sort_key(CompletionPriority.OBJECT_MEMBER, "responses"), - ) - - @decompiler("responses") def decompile_responses(ctx, gir): ctx.print(f"responses [") diff --git a/blueprintcompiler/language/gtk_combo_box_text.py b/blueprintcompiler/language/gtk_combo_box_text.py index 5a7a892..aa1fe1d 100644 --- a/blueprintcompiler/language/gtk_combo_box_text.py +++ b/blueprintcompiler/language/gtk_combo_box_text.py @@ -91,7 +91,7 @@ class ExtComboBoxItems(AstNode): @completer( applies_in=[ObjectContent], - applies_in_subclass=("Gtk", "ComboBoxText"), + applies_in_subclass=[("Gtk", "ComboBoxText")], matches=new_statement_patterns, ) def items_completer(_ctx: CompletionContext): diff --git a/blueprintcompiler/language/gtk_file_filter.py b/blueprintcompiler/language/gtk_file_filter.py index d0e53d2..36e7da4 100644 --- a/blueprintcompiler/language/gtk_file_filter.py +++ b/blueprintcompiler/language/gtk_file_filter.py @@ -98,7 +98,7 @@ ext_file_filter_suffixes = create_node("suffixes", "suffix") @completer( applies_in=[ObjectContent], - applies_in_subclass=("Gtk", "FileFilter"), + applies_in_subclass=[("Gtk", "FileFilter")], matches=new_statement_patterns, ) def file_filter_completer(_ctx: CompletionContext): diff --git a/blueprintcompiler/language/gtk_layout.py b/blueprintcompiler/language/gtk_layout.py index 63bc0f6..8dd3458 100644 --- a/blueprintcompiler/language/gtk_layout.py +++ b/blueprintcompiler/language/gtk_layout.py @@ -90,7 +90,7 @@ class ExtLayout(AstNode): @completer( applies_in=[ObjectContent], - applies_in_subclass=("Gtk", "Widget"), + applies_in_subclass=[("Gtk", "Widget")], matches=new_statement_patterns, ) def layout_completer(_ctx: CompletionContext): diff --git a/blueprintcompiler/language/gtk_scale.py b/blueprintcompiler/language/gtk_scale.py index 21089a4..e076d4c 100644 --- a/blueprintcompiler/language/gtk_scale.py +++ b/blueprintcompiler/language/gtk_scale.py @@ -134,7 +134,7 @@ class ExtScaleMarks(AstNode): @completer( applies_in=[ObjectContent], - applies_in_subclass=("Gtk", "Scale"), + applies_in_subclass=[("Gtk", "Scale")], matches=new_statement_patterns, ) def complete_marks(_ctx: CompletionContext): diff --git a/blueprintcompiler/language/gtk_size_group.py b/blueprintcompiler/language/gtk_size_group.py index d30eef9..e7a6a35 100644 --- a/blueprintcompiler/language/gtk_size_group.py +++ b/blueprintcompiler/language/gtk_size_group.py @@ -101,7 +101,7 @@ class ExtSizeGroupWidgets(AstNode): @completer( applies_in=[ObjectContent], - applies_in_subclass=("Gtk", "SizeGroup"), + applies_in_subclass=[("Gtk", "SizeGroup")], matches=new_statement_patterns, ) def size_group_completer(_ctx: CompletionContext): diff --git a/blueprintcompiler/language/gtk_string_list.py b/blueprintcompiler/language/gtk_string_list.py index a4fa3b5..4d15d32 100644 --- a/blueprintcompiler/language/gtk_string_list.py +++ b/blueprintcompiler/language/gtk_string_list.py @@ -72,7 +72,7 @@ class ExtStringListStrings(AstNode): @completer( applies_in=[ObjectContent], - applies_in_subclass=("Gtk", "StringList"), + applies_in_subclass=[("Gtk", "StringList")], matches=new_statement_patterns, ) def strings_completer(_ctx: CompletionContext): diff --git a/blueprintcompiler/language/gtk_styles.py b/blueprintcompiler/language/gtk_styles.py index 7c9252c..0836073 100644 --- a/blueprintcompiler/language/gtk_styles.py +++ b/blueprintcompiler/language/gtk_styles.py @@ -77,7 +77,7 @@ class ExtStyles(AstNode): @completer( applies_in=[ObjectContent], - applies_in_subclass=("Gtk", "Widget"), + applies_in_subclass=[("Gtk", "Widget")], matches=new_statement_patterns, ) def style_completer(_ctx: CompletionContext): diff --git a/blueprintcompiler/language/gtkbuilder_child.py b/blueprintcompiler/language/gtkbuilder_child.py index bee551c..0eb8f04 100644 --- a/blueprintcompiler/language/gtkbuilder_child.py +++ b/blueprintcompiler/language/gtkbuilder_child.py @@ -31,7 +31,12 @@ ALLOWED_PARENTS: T.List[T.Tuple[str, str]] = [ class ChildInternal(AstNode): - grammar = ["internal-child", UseIdent("internal_child")] + grammar = [ + "[", + "internal-child", + UseIdent("internal_child").expected("internal child name"), + Match("]").expected(), + ] @property def internal_child(self) -> str: @@ -39,7 +44,7 @@ class ChildInternal(AstNode): class ChildType(AstNode): - grammar = UseIdent("child_type").expected("a child type") + grammar = ["[", UseIdent("child_type").expected("a child type"), "]"] @property def child_type(self) -> str: @@ -59,7 +64,7 @@ class ChildExtension(AstNode): class ChildAnnotation(AstNode): - grammar = ["[", AnyOf(ChildInternal, ChildExtension, ChildType), "]"] + grammar = AnyOf(ChildInternal, ChildExtension, ChildType) @property def child(self) -> T.Union[ChildInternal, ChildExtension, ChildType]: diff --git a/blueprintcompiler/language/response_id.py b/blueprintcompiler/language/response_id.py index 939f71f..83843ed 100644 --- a/blueprintcompiler/language/response_id.py +++ b/blueprintcompiler/language/response_id.py @@ -28,19 +28,21 @@ class ExtResponse(AstNode): ALLOWED_PARENTS: T.List[T.Tuple[str, str]] = [("Gtk", "Dialog"), ("Gtk", "InfoBar")] - grammar = [ + grammar = Statement( + "[", Keyword("action"), Keyword("response"), - "=", + Match("=").expected(), AnyOf( UseIdent("response_id"), [ Optional(UseExact("sign", "-")), UseNumber("response_id"), ], - ), + ).expected("response ID"), Optional([Keyword("default"), UseLiteral("is_default", True)]), - ] + end="]", + ) @validate() def parent_has_action_widgets(self) -> None: diff --git a/blueprintcompiler/parse_tree.py b/blueprintcompiler/parse_tree.py index e590539..a215f19 100644 --- a/blueprintcompiler/parse_tree.py +++ b/blueprintcompiler/parse_tree.py @@ -329,8 +329,9 @@ class Statement(ParseNode): """ParseNode that attempts to match all of its children in sequence. If any child raises an error, the error will be logged but parsing will continue.""" - def __init__(self, *children): + def __init__(self, *children, end: str = ";"): self.children = [to_parse_node(child) for child in children] + self.end = end def _parse(self, ctx) -> bool: for child in self.children: @@ -340,11 +341,16 @@ class Statement(ParseNode): except CompileError as e: ctx.errors.append(e) ctx.set_group_incomplete() + + token = ctx.peek_token() + if str(token) == self.end: + ctx.next_token() + return True token = ctx.peek_token() - if str(token) != ";": - ctx.errors.append(CompileError("Expected `;`", token.range)) + if str(token) != self.end: + ctx.errors.append(CompileError(f"Expected `{self.end}`", token.range)) else: ctx.next_token() return True diff --git a/tests/sample_errors/action_widget_have_no_id.err b/tests/sample_errors/action_widget_have_no_id.err index b239d77..7d1620a 100644 --- a/tests/sample_errors/action_widget_have_no_id.err +++ b/tests/sample_errors/action_widget_have_no_id.err @@ -1 +1 @@ -4,6,22,Action widget must have ID +4,5,24,Action widget must have ID diff --git a/tests/sample_errors/action_widget_in_invalid_container.err b/tests/sample_errors/action_widget_in_invalid_container.err index ef3296c..52c5e5d 100644 --- a/tests/sample_errors/action_widget_in_invalid_container.err +++ b/tests/sample_errors/action_widget_in_invalid_container.err @@ -1 +1 @@ -4,6,18,Gtk.Box doesn't have action widgets +4,5,20,Gtk.Box doesn't have action widgets From 69e05bba3495304801a9788e9fce44432d2a2751 Mon Sep 17 00:00:00 2001 From: James Westman Date: Sat, 3 May 2025 09:08:05 -0500 Subject: [PATCH 2/4] completions: Fix generated signal handler name A syntax error in the snippet caused the generated signal handler name not to be used. --- blueprintcompiler/completions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blueprintcompiler/completions.py b/blueprintcompiler/completions.py index 1bfbec6..5964f31 100644 --- a/blueprintcompiler/completions.py +++ b/blueprintcompiler/completions.py @@ -356,7 +356,7 @@ def signal_completer(ctx: CompletionContext): .lower() ) - snippet = f"{signal_name} => \\$${{1:${name}_{signal_name.replace('-', '_')}}}()$0;" + snippet = f"{signal_name} => \\$${{1:{name}_{signal_name.replace('-', '_')}}}()$0;" yield Completion( signal_name, From d3c31447c97cf49c3f7d6cce522e86f3db383c37 Mon Sep 17 00:00:00 2001 From: James Westman Date: Sat, 3 May 2025 10:10:06 -0500 Subject: [PATCH 3/4] parser: Tweak parsing during error conditions When an explicit parsing error is encountered and a CompileError raised, apply the changes to the context state. This way, the rule that catches the exception (e.g. Statement or Until) knows where the error occurred. Also, changed "Expected" errors to be reported at the end of the previous non-whitespace token. --- blueprintcompiler/errors.py | 2 ++ blueprintcompiler/parse_tree.py | 15 +++++++++++---- blueprintcompiler/utils.py | 4 ++-- tests/sample_errors/incomplete_signal.err | 3 +-- tests/sample_errors/menu_toplevel_attribute.err | 3 +-- tests/sample_errors/no_import_version.err | 2 +- 6 files changed, 18 insertions(+), 11 deletions(-) diff --git a/blueprintcompiler/errors.py b/blueprintcompiler/errors.py index df1c2e1..9836c85 100644 --- a/blueprintcompiler/errors.py +++ b/blueprintcompiler/errors.py @@ -111,6 +111,8 @@ class CompileError(PrintableError): n_carets += line.count("\t", col_num, col_num + n_carets) line = line.replace("\t", " ") + n_carets = max(n_carets, 1) + stream.write( f"""{self.color}{Colors.BOLD}{self.category}: {self.message}{Colors.CLEAR} at {filename} line {line_num} column {col_num}: diff --git a/blueprintcompiler/parse_tree.py b/blueprintcompiler/parse_tree.py index a215f19..9bdbef1 100644 --- a/blueprintcompiler/parse_tree.py +++ b/blueprintcompiler/parse_tree.py @@ -235,7 +235,15 @@ class ParseNode: start_idx = ctx.index inner_ctx = ctx.create_child() - if self._parse(inner_ctx): + try: + result = self._parse(inner_ctx) + except Exception as e: + # If an exception occurs, there's an explicit error, not just a rule that didn't match. Apply the context + # state so that whichever rule handles the exception (e.g. a Statement) knows where the error occurred. + ctx.apply_child(inner_ctx) + raise e + + if result: ctx.apply_child(inner_ctx) if ctx.index == start_idx: return ParseResult.EMPTY @@ -269,11 +277,11 @@ class Err(ParseNode): if self.child.parse(ctx).failed(): start_idx = ctx.start while ctx.tokens[start_idx].type in SKIP_TOKENS: - start_idx += 1 + start_idx -= 1 start_token = ctx.tokens[start_idx] raise CompileError( - self.message, Range(start_token.start, start_token.start, ctx.text) + self.message, Range(start_token.end, start_token.end, ctx.text) ) return True @@ -411,7 +419,6 @@ class Until(ParseNode): ctx.skip_unexpected_token() except CompileError as e: ctx.errors.append(e) - ctx.next_token() return True diff --git a/blueprintcompiler/utils.py b/blueprintcompiler/utils.py index ea8102e..de6d493 100644 --- a/blueprintcompiler/utils.py +++ b/blueprintcompiler/utils.py @@ -76,8 +76,8 @@ def did_you_mean(word: str, options: T.List[str]) -> T.Optional[str]: def idx_to_pos(idx: int, text: str) -> T.Tuple[int, int]: if idx == 0 or len(text) == 0: return (0, 0) - line_num = text.count("\n", 0, idx) + 1 - col_num = idx - text.rfind("\n", 0, idx) - 1 + line_num = text.count("\n", 0, idx - 1) + 1 + col_num = idx - text.rfind("\n", 0, idx - 1) - 1 return (line_num - 1, col_num) diff --git a/tests/sample_errors/incomplete_signal.err b/tests/sample_errors/incomplete_signal.err index 901ef3b..c61ef28 100644 --- a/tests/sample_errors/incomplete_signal.err +++ b/tests/sample_errors/incomplete_signal.err @@ -1,2 +1 @@ -5,1,0,Expected a signal detail name -4,9,3,Unexpected tokens \ No newline at end of file +4,11,0,Expected a signal detail name \ No newline at end of file diff --git a/tests/sample_errors/menu_toplevel_attribute.err b/tests/sample_errors/menu_toplevel_attribute.err index 8f3ef26..ee588d0 100644 --- a/tests/sample_errors/menu_toplevel_attribute.err +++ b/tests/sample_errors/menu_toplevel_attribute.err @@ -1,2 +1 @@ -4,5,21,Attributes are not permitted at the top level of a menu -4,16,10,Unexpected tokens \ No newline at end of file +4,5,21,Attributes are not permitted at the top level of a menu \ No newline at end of file diff --git a/tests/sample_errors/no_import_version.err b/tests/sample_errors/no_import_version.err index db830e0..4ee792f 100644 --- a/tests/sample_errors/no_import_version.err +++ b/tests/sample_errors/no_import_version.err @@ -1 +1 @@ -1,11,0,Expected a version number for GTK +1,10,0,Expected a version number for GTK From d91cbef50c0b0de4b604159beb78ae23711a938d Mon Sep 17 00:00:00 2001 From: James Westman Date: Sat, 3 May 2025 10:14:10 -0500 Subject: [PATCH 4/4] completions: Add GtkScale mark positions --- blueprintcompiler/language/gtk_scale.py | 43 +++++++++++++++++-------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/blueprintcompiler/language/gtk_scale.py b/blueprintcompiler/language/gtk_scale.py index e076d4c..5dc49d8 100644 --- a/blueprintcompiler/language/gtk_scale.py +++ b/blueprintcompiler/language/gtk_scale.py @@ -23,22 +23,20 @@ from .values import StringValue class ExtScaleMark(AstNode): - grammar = [ + grammar = Statement( Keyword("mark"), Match("(").expected(), - [ - Optional(AnyOf(UseExact("sign", "-"), UseExact("sign", "+"))), - UseNumber("value"), - Optional( - [ - ",", - UseIdent("position"), - Optional([",", StringValue]), - ] - ), - ], - Match(")").expected(), - ] + Optional(AnyOf(UseExact("sign", "-"), UseExact("sign", "+"))), + UseNumber("value").expected("value"), + Optional( + [ + ",", + UseIdent("position").expected("position"), + Optional([",", to_parse_node(StringValue).expected("label")]), + ] + ), + end=")", + ) @property def value(self) -> float: @@ -153,6 +151,23 @@ def complete_mark(_ctx: CompletionContext): yield Completion("mark", CompletionItemKind.Keyword, snippet="mark ($0),") +@completer( + applies_in=[ExtScaleMark], + matches=[[(TokenType.NUMBER, None), (TokenType.PUNCTUATION, ",")]], +) +def complete_mark_position(ctx: CompletionContext): + gir = ctx.ast_node.root.gir + response_type = gir.get_type("PositionType", "Gtk") + yield from [ + Completion( + name, + kind=CompletionItemKind.EnumMember, + docs=member.doc, + ) + for name, member in response_type.members.items() + ] + + @decompiler("marks") def decompile_marks( ctx,