From d751f3ad8f765da6fa14bf4a94d29694d6a0b4f0 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 6 Jul 2025 15:23:17 -0700 Subject: [PATCH 1/5] Support @abstract functions --- gdtoolkit/common/ast.py | 7 +++++++ gdtoolkit/formatter/annotation.py | 1 + gdtoolkit/formatter/class_statement.py | 19 +++++++++++++++++++ gdtoolkit/linter/class_checks.py | 2 ++ gdtoolkit/parser/gdscript.lark | 3 +++ .../abstract_functions.in.gd | 12 ++++++++++++ .../abstract_functions.out.gd | 18 ++++++++++++++++++ 7 files changed, 62 insertions(+) create mode 100644 tests/formatter/input-output-pairs/abstract_functions.in.gd create mode 100644 tests/formatter/input-output-pairs/abstract_functions.out.gd diff --git a/gdtoolkit/common/ast.py b/gdtoolkit/common/ast.py index f3ca564d..938e9405 100644 --- a/gdtoolkit/common/ast.py +++ b/gdtoolkit/common/ast.py @@ -39,6 +39,9 @@ def _load_sub_statements(self): raise NotImplementedError if self.kind in ["func_def", "static_func_def"]: self.sub_statements = [Statement(n) for n in self.lark_node.children[1:]] + elif self.kind == "abstract_func_def": + # Abstract functions don't have a body, so no sub-statements + pass elif self.kind == "if_stmt": for branch in self.lark_node.children: if branch.data in ["if_branch", "elif_branch"]: @@ -141,6 +144,10 @@ def _load_data_from_node_children(self, node: Tree) -> None: function = Function(stmt) self.functions.append(function) self.all_functions.append(function) + if stmt.data == "abstract_func_def": + function = Function(stmt) + self.functions.append(function) + self.all_functions.append(function) def _load_data_from_class_def(self, class_def: Tree) -> None: name_token = find_name_token_among_children(class_def) diff --git a/gdtoolkit/formatter/annotation.py b/gdtoolkit/formatter/annotation.py index f0a9348d..43cb4950 100644 --- a/gdtoolkit/formatter/annotation.py +++ b/gdtoolkit/formatter/annotation.py @@ -9,6 +9,7 @@ from .expression_to_str import expression_to_str _STANDALONE_ANNOTATIONS = [ + "abstract", "export_category", "export_group", "export_subgroup", diff --git a/gdtoolkit/formatter/class_statement.py b/gdtoolkit/formatter/class_statement.py index b9ed7b9e..cd4cca5d 100644 --- a/gdtoolkit/formatter/class_statement.py +++ b/gdtoolkit/formatter/class_statement.py @@ -40,6 +40,7 @@ def format_class_statement(statement: Tree, context: Context) -> Outcome: "static_func_def": lambda s, c: _format_func_statement( s.children[0], c, "static " ), + "abstract_func_def": _format_abstract_func_statement, "annotation": format_standalone_annotation, "property_body_def": format_property_body, } # type: Dict[str, Callable] @@ -203,3 +204,21 @@ def _format_enum_statement(statement: Tree, context: Context) -> Outcome: ) enum_body = actual_enum.children[-1] return format_concrete_expression(enum_body, expression_context, context) + + +def _format_abstract_func_statement(statement: Tree, context: Context) -> Outcome: + abstract_func_header = statement.children[0] + return _format_abstract_func_header(abstract_func_header, context) + + +def _format_abstract_func_header(statement: Tree, context: Context) -> Outcome: + name = statement.children[0].value + has_return_type = len(statement.children) > 2 + expression_context = ExpressionContext( + f"func {name}", + get_line(statement), + f" -> {statement.children[2].value}" if has_return_type else "", + get_end_line(statement), + ) + func_args = statement.children[1] + return format_concrete_expression(func_args, expression_context, context) diff --git a/gdtoolkit/linter/class_checks.py b/gdtoolkit/linter/class_checks.py index 6c6fc82c..3da20217 100644 --- a/gdtoolkit/linter/class_checks.py +++ b/gdtoolkit/linter/class_checks.py @@ -122,6 +122,8 @@ def _map_statement_to_section(statement: Statement) -> str: return "others" if statement.kind == "static_func_def": return "others" + if statement.kind == "abstract_func_def": + return "others" if statement.kind == "docstr_stmt": return "docstrings" if statement.kind == "static_class_var_stmt": diff --git a/gdtoolkit/parser/gdscript.lark b/gdtoolkit/parser/gdscript.lark index 8f9cd768..28be9295 100644 --- a/gdtoolkit/parser/gdscript.lark +++ b/gdtoolkit/parser/gdscript.lark @@ -18,6 +18,7 @@ _simple_class_stmt: annotation* single_class_stmt (";" annotation* single_class_ | property_body_def | func_def | "static" func_def -> static_func_def + | abstract_func_def annotation: "@" NAME [annotation_args] annotation_args: "(" [test_expr ("," test_expr)* [trailing_comma]] ")" @@ -71,7 +72,9 @@ _property_delegates: property_delegate_set ["," [_NL] property_delegate_get] property_custom_getter_args: "(" ")" func_def: func_header _func_suite +abstract_func_def: abstract_func_header func_header: "func" NAME func_args ["->" TYPE_HINT] ":" +abstract_func_header: "func" NAME func_args ["->" TYPE_HINT] func_args: "(" [func_arg ("," func_arg)* [trailing_comma]] ")" ?func_arg: func_arg_regular | func_arg_inf diff --git a/tests/formatter/input-output-pairs/abstract_functions.in.gd b/tests/formatter/input-output-pairs/abstract_functions.in.gd new file mode 100644 index 00000000..7438eb13 --- /dev/null +++ b/tests/formatter/input-output-pairs/abstract_functions.in.gd @@ -0,0 +1,12 @@ +@abstract class_name BaseClass + +@abstract func simple_abstract() + +@abstract func abstract_with_params(param1: String, param2: int) + +@abstract func abstract_with_return_type() -> String + +@abstract func abstract_with_params_and_return(input: String) -> int + +func concrete_method(): + pass diff --git a/tests/formatter/input-output-pairs/abstract_functions.out.gd b/tests/formatter/input-output-pairs/abstract_functions.out.gd new file mode 100644 index 00000000..760d0ed0 --- /dev/null +++ b/tests/formatter/input-output-pairs/abstract_functions.out.gd @@ -0,0 +1,18 @@ +@abstract +class_name BaseClass + +@abstract +func simple_abstract() + +@abstract +func abstract_with_params(param1: String, param2: int) + +@abstract +func abstract_with_return_type() -> String + +@abstract +func abstract_with_params_and_return(input: String) -> int + + +func concrete_method(): + pass From 4a7c10ab78ae2a55458f8bc6164651de17c4813f Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 6 Jul 2025 15:49:53 -0700 Subject: [PATCH 2/5] Combine @abstract annotation with class/class_name/func line --- gdtoolkit/formatter/annotation.py | 33 +++++++++++++++++-- gdtoolkit/formatter/block.py | 14 ++++++-- .../abstract_functions.in.gd | 20 ++++++++--- .../abstract_functions.out.gd | 19 +++++------ 4 files changed, 65 insertions(+), 21 deletions(-) diff --git a/gdtoolkit/formatter/annotation.py b/gdtoolkit/formatter/annotation.py index 43cb4950..77c548ee 100644 --- a/gdtoolkit/formatter/annotation.py +++ b/gdtoolkit/formatter/annotation.py @@ -111,6 +111,16 @@ ] +def is_abstract_annotation_for_statement(statement: Tree, next_statement: Tree) -> bool: + """Check if this is an @abstract annotation that should be combined with the next statement.""" + if statement.data != "annotation": + return False + name = statement.children[0].value + if name != "abstract": + return False + return next_statement.data in ["abstract_func_def", "classname_stmt", "class_def"] + + def is_non_standalone_annotation(statement: Tree) -> bool: if statement.data != "annotation": return False @@ -136,9 +146,26 @@ def prepend_annotations_to_formatted_line( single_line_length = ( context.indent + len(annotations_string) + len(whitelineless_line) ) - standalone_formatting_enforced = whitelineless_line.startswith( - "func" - ) or whitelineless_line.startswith("static func") + # Check if this is an abstract function or class_name annotation + is_abstract_func = ( + len(context.annotations) == 1 and + context.annotations[0].children[0].value == "abstract" and + whitelineless_line.startswith("func") + ) + is_abstract_class_name = ( + len(context.annotations) == 1 and + context.annotations[0].children[0].value == "abstract" and + whitelineless_line.startswith("class_name") + ) + is_abstract_class = ( + len(context.annotations) == 1 and + context.annotations[0].children[0].value == "abstract" and + whitelineless_line.startswith("class ") + ) + standalone_formatting_enforced = ( + whitelineless_line.startswith("func") or + whitelineless_line.startswith("static func") + ) and not is_abstract_func and not is_abstract_class_name if ( not _annotations_have_standalone_comments( context.annotations, context.standalone_comments, line_to_prepend_to[0] diff --git a/gdtoolkit/formatter/block.py b/gdtoolkit/formatter/block.py index 38b75189..9ece9263 100644 --- a/gdtoolkit/formatter/block.py +++ b/gdtoolkit/formatter/block.py @@ -14,6 +14,7 @@ from .annotation import ( is_non_standalone_annotation, prepend_annotations_to_formatted_line, + is_abstract_annotation_for_statement, ) @@ -26,8 +27,15 @@ def format_block( previous_statement_name = None formatted_lines = [] # type: FormattedLines previously_processed_line_number = context.previously_processed_line_number - for statement in statements: - if is_non_standalone_annotation(statement): + for i, statement in enumerate(statements): + # Check if this is an abstract annotation followed by an abstract function or class_name + next_statement = statements[i + 1] if i + 1 < len(statements) else None + is_abstract_for_statement = ( + next_statement is not None and + is_abstract_annotation_for_statement(statement, next_statement) + ) + + if is_non_standalone_annotation(statement) or is_abstract_for_statement: context.annotations.append(statement) is_first_annotation = len(context.annotations) == 1 if not is_first_annotation: @@ -47,7 +55,7 @@ def format_block( blank_lines, statement.data, surrounding_empty_lines_table ) is_first_annotation = len(context.annotations) == 1 - if is_non_standalone_annotation(statement) and is_first_annotation: + if (is_non_standalone_annotation(statement) or is_abstract_for_statement) and is_first_annotation: formatted_lines += blank_lines continue if len(context.annotations) == 0: diff --git a/tests/formatter/input-output-pairs/abstract_functions.in.gd b/tests/formatter/input-output-pairs/abstract_functions.in.gd index 7438eb13..2c2b9b6e 100644 --- a/tests/formatter/input-output-pairs/abstract_functions.in.gd +++ b/tests/formatter/input-output-pairs/abstract_functions.in.gd @@ -1,12 +1,22 @@ -@abstract class_name BaseClass +@abstract +class_name BaseClass -@abstract func simple_abstract() +@abstract +class TestClass: + @abstract + func test_func() -@abstract func abstract_with_params(param1: String, param2: int) +@abstract +func simple_abstract() -@abstract func abstract_with_return_type() -> String +@abstract +func abstract_with_params(param1: String, param2: int) -@abstract func abstract_with_params_and_return(input: String) -> int +@abstract +func abstract_with_return_type() -> String + +@abstract +func abstract_with_params_and_return(input: String) -> int func concrete_method(): pass diff --git a/tests/formatter/input-output-pairs/abstract_functions.out.gd b/tests/formatter/input-output-pairs/abstract_functions.out.gd index 760d0ed0..0995cd67 100644 --- a/tests/formatter/input-output-pairs/abstract_functions.out.gd +++ b/tests/formatter/input-output-pairs/abstract_functions.out.gd @@ -1,17 +1,16 @@ -@abstract -class_name BaseClass +@abstract class_name BaseClass -@abstract -func simple_abstract() +@abstract class TestClass: + @abstract func test_func() -@abstract -func abstract_with_params(param1: String, param2: int) -@abstract -func abstract_with_return_type() -> String +@abstract func simple_abstract() -@abstract -func abstract_with_params_and_return(input: String) -> int +@abstract func abstract_with_params(param1: String, param2: int) + +@abstract func abstract_with_return_type() -> String + +@abstract func abstract_with_params_and_return(input: String) -> int func concrete_method(): From 6577c68e80004a45d04e25c8b90361153dab836a Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 6 Jul 2025 15:56:45 -0700 Subject: [PATCH 3/5] Add abstract function to gd2py --- gdtoolkit/gd2py/__init__.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/gdtoolkit/gd2py/__init__.py b/gdtoolkit/gd2py/__init__.py index e2e19541..3294a89b 100644 --- a/gdtoolkit/gd2py/__init__.py +++ b/gdtoolkit/gd2py/__init__.py @@ -64,6 +64,7 @@ def _convert_statement(statement: Tree, context: Context) -> List[str]: ) ], "static_func_def": _convert_first_child_as_statement, + "abstract_func_def": _convert_abstract_func_def, "docstr_stmt": _pass, # func statements: "func_var_stmt": _convert_first_child_as_statement, @@ -164,6 +165,16 @@ def _convert_func_def(statement: Tree, context: Context) -> List[str]: ] + _convert_block(statement.children[1:], context.create_child_context(-1)) +def _convert_abstract_func_def(statement: Tree, context: Context) -> List[str]: + # Abstract functions don't have a body, so we create a function that raises NotImplementedError + func_header = statement.children[0] + func_name = func_header.children[0].value + return [ + f"{context.indent_string}def {func_name}():", + f"{context.indent_string} raise NotImplementedError('Abstract method not implemented')", + ] + + def _convert_branch_with_expression( prefix: str, statement: Tree, context: Context ) -> List[str]: From 738e93fd47b32020e7cbef0a29031a67e397995e Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 6 Jul 2025 16:16:53 -0700 Subject: [PATCH 4/5] Fix formatting --- gdtoolkit/formatter/annotation.py | 28 ++++++++++++++++------------ gdtoolkit/formatter/block.py | 10 ++++++---- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/gdtoolkit/formatter/annotation.py b/gdtoolkit/formatter/annotation.py index 77c548ee..bcbcc31a 100644 --- a/gdtoolkit/formatter/annotation.py +++ b/gdtoolkit/formatter/annotation.py @@ -148,24 +148,28 @@ def prepend_annotations_to_formatted_line( ) # Check if this is an abstract function or class_name annotation is_abstract_func = ( - len(context.annotations) == 1 and - context.annotations[0].children[0].value == "abstract" and - whitelineless_line.startswith("func") + len(context.annotations) == 1 + and context.annotations[0].children[0].value == "abstract" + and whitelineless_line.startswith("func") ) is_abstract_class_name = ( - len(context.annotations) == 1 and - context.annotations[0].children[0].value == "abstract" and - whitelineless_line.startswith("class_name") + len(context.annotations) == 1 + and context.annotations[0].children[0].value == "abstract" + and whitelineless_line.startswith("class_name") ) is_abstract_class = ( - len(context.annotations) == 1 and - context.annotations[0].children[0].value == "abstract" and - whitelineless_line.startswith("class ") + len(context.annotations) == 1 + and context.annotations[0].children[0].value == "abstract" + and whitelineless_line.startswith("class ") ) standalone_formatting_enforced = ( - whitelineless_line.startswith("func") or - whitelineless_line.startswith("static func") - ) and not is_abstract_func and not is_abstract_class_name + ( + whitelineless_line.startswith("func") + or whitelineless_line.startswith("static func") + ) + and not is_abstract_func + and not is_abstract_class_name + ) if ( not _annotations_have_standalone_comments( context.annotations, context.standalone_comments, line_to_prepend_to[0] diff --git a/gdtoolkit/formatter/block.py b/gdtoolkit/formatter/block.py index 9ece9263..57368993 100644 --- a/gdtoolkit/formatter/block.py +++ b/gdtoolkit/formatter/block.py @@ -31,10 +31,10 @@ def format_block( # Check if this is an abstract annotation followed by an abstract function or class_name next_statement = statements[i + 1] if i + 1 < len(statements) else None is_abstract_for_statement = ( - next_statement is not None and - is_abstract_annotation_for_statement(statement, next_statement) + next_statement is not None + and is_abstract_annotation_for_statement(statement, next_statement) ) - + if is_non_standalone_annotation(statement) or is_abstract_for_statement: context.annotations.append(statement) is_first_annotation = len(context.annotations) == 1 @@ -55,7 +55,9 @@ def format_block( blank_lines, statement.data, surrounding_empty_lines_table ) is_first_annotation = len(context.annotations) == 1 - if (is_non_standalone_annotation(statement) or is_abstract_for_statement) and is_first_annotation: + if ( + is_non_standalone_annotation(statement) or is_abstract_for_statement + ) and is_first_annotation: formatted_lines += blank_lines continue if len(context.annotations) == 0: From bca99add24c5c0705ff9450cf2ed7dd178d0ec39 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 6 Jul 2025 16:21:12 -0700 Subject: [PATCH 5/5] Lint --- gdtoolkit/formatter/annotation.py | 5 ----- gdtoolkit/formatter/block.py | 25 +++++++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/gdtoolkit/formatter/annotation.py b/gdtoolkit/formatter/annotation.py index bcbcc31a..3975565f 100644 --- a/gdtoolkit/formatter/annotation.py +++ b/gdtoolkit/formatter/annotation.py @@ -157,11 +157,6 @@ def prepend_annotations_to_formatted_line( and context.annotations[0].children[0].value == "abstract" and whitelineless_line.startswith("class_name") ) - is_abstract_class = ( - len(context.annotations) == 1 - and context.annotations[0].children[0].value == "abstract" - and whitelineless_line.startswith("class ") - ) standalone_formatting_enforced = ( ( whitelineless_line.startswith("func") diff --git a/gdtoolkit/formatter/block.py b/gdtoolkit/formatter/block.py index 57368993..e23a6c8f 100644 --- a/gdtoolkit/formatter/block.py +++ b/gdtoolkit/formatter/block.py @@ -27,6 +27,7 @@ def format_block( previous_statement_name = None formatted_lines = [] # type: FormattedLines previously_processed_line_number = context.previously_processed_line_number + for i, statement in enumerate(statements): # Check if this is an abstract annotation followed by an abstract function or class_name next_statement = statements[i + 1] if i + 1 < len(statements) else None @@ -37,9 +38,9 @@ def format_block( if is_non_standalone_annotation(statement) or is_abstract_for_statement: context.annotations.append(statement) - is_first_annotation = len(context.annotations) == 1 - if not is_first_annotation: + if len(context.annotations) > 1: continue + blank_lines = reconstruct_blank_lines_in_range( previously_processed_line_number, get_line(statement), context ) @@ -54,14 +55,17 @@ def format_block( blank_lines = _add_extra_blanks_due_to_next_statement( blank_lines, statement.data, surrounding_empty_lines_table ) - is_first_annotation = len(context.annotations) == 1 + + # Handle first annotation case if ( is_non_standalone_annotation(statement) or is_abstract_for_statement - ) and is_first_annotation: + ) and len(context.annotations) == 1: formatted_lines += blank_lines continue + if len(context.annotations) == 0: formatted_lines += blank_lines + lines, previously_processed_line_number = statement_formatter( statement, context ) @@ -69,16 +73,17 @@ def format_block( lines = prepend_annotations_to_formatted_line(lines[0], context) + lines[1:] formatted_lines += lines previous_statement_name = statement.data + + # Handle end of block dedent_line_number = _find_dedent_line_number( previously_processed_line_number, context ) - lines_at_the_end = reconstruct_blank_lines_in_range( - previously_processed_line_number, dedent_line_number, context + formatted_lines += _remove_empty_strings_from_end( + reconstruct_blank_lines_in_range( + previously_processed_line_number, dedent_line_number, context + ) ) - lines_at_the_end = _remove_empty_strings_from_end(lines_at_the_end) - formatted_lines += lines_at_the_end - previously_processed_line_number = dedent_line_number - 1 - return (formatted_lines, previously_processed_line_number) + return (formatted_lines, dedent_line_number - 1) def reconstruct_blank_lines_in_range(