-
-
Notifications
You must be signed in to change notification settings - Fork 21.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add script formatter, comment parsing in parser, and editor settings #76211
base: master
Are you sure you want to change the base?
Conversation
Thanks for looking into this!
This PR contains only one commit. |
Just tested it on a number of files, here are snippets causing unexpected results. I'll post one code pattern/snippet/group of related snippets with before/after comparisons per comment. I'm testing 2 things:
The first case involves signal connections with a function literal (it may just be the inner block with a parenthesis at the end of the last line) func _ready() -> void:
energy_slider.value_changed.connect(func(value):
light.energy = energy_slider.value) Gets formatted into this: func _ready() -> void:
energy_slider.value_changed.connect(func(value):
light.energy = energy_slider.value
) This happens differently as you duplicate the two lines with the function literal. This: func _ready() -> void:
energy_slider.value_changed.connect(func(value):
light.energy = energy_slider.value)
height_slider.value_changed.connect(func(value):
light.height = height_slider.value)
height_slider.value_changed.connect(func(value):
light.height = height_slider.value) Alternates between the following and the starting state when running the formatter. func _ready() -> void:
energy_slider.value_changed.connect(func(value):
light.energy = energy_slider.value
)
height_slider.value_changed.connect(func(value):
light.height = height_slider.value
)
height_slider.value_changed.connect(func(value):
light.height = height_slider.value) |
Longer annotations get placed on their own lines, unlike shorter ones. Not a bug per say, but I'm not 100% sure if it's intentional. @export_multiline var text := ""
@export var logo_visible := false Becomes: @export_multiline
var text := ""
@export var logo_visible := false |
Comment inside a function gets indented based on previous block. func popup(immediate := false) -> void:
if not is_inside_tree():
await ready
# Force container to be its smallest size possibles
size = Vector2.ZERO Becomes: func popup(immediate := false) -> void:
if not is_inside_tree():
await ready
# Force container to be its smallest size possibles
size = Vector2.ZERO |
Last line in a setter function attached to a property definition gets pushed down up to two times if following an ending code block. var text := "":
set(value):
text = value
if not is_inside_tree():
await ready
rich_text_label.text = text First becomes var text := "":
set(value):
text = value
if not is_inside_tree():
await ready
rich_text_label.text = text And after a second run of the formatter, it becomes var text := "":
set(value):
text = value
if not is_inside_tree():
await ready
rich_text_label.text = text After that it's stable with subsequent formatter runs. |
Arrays struggle to wrap when combined with method calls currently. Here's a very long line to generate a random nickname: var nickname: String = ["Brave", "Horrific", "Courageous", "Terrific", "Fair", "Conqueror", "Victorious", "Glorious", "Invicible"].pick_random() + ["Leopard", "Cheetah", "Bear", "Turtle", "Rabbit", "Porcupine", "Hare", "Pigeon", "Albatross", "Crow" ].pick_random() The formatter doesn't manage to wrap it to stay below the max line length. It produces this: var nickname: String = (
["Brave", "Horrific", "Courageous", "Terrific", "Fair", "Conqueror", "Victorious", "Glorious", "Invicible"].pick_random(
)
+ ["Leopard", "Cheetah", "Bear", "Turtle", "Rabbit", "Porcupine", "Hare", "Pigeon", "Albatross", "Crow"].pick_random(
)
) Not a super important case, but each array is longer than max line length, so I'd expect them to be wrapped vertically, as the formatter does well when all you have is an array (without the method call). E.g. this var adjectives := ["Brave", "Horrific", "Courageous", "Terrific", "Fair", "Conqueror", "Victorious", "Glorious", "Invicible"] Becomes the following, as expected: var adjectives := [
"Brave",
"Horrific",
"Courageous",
"Terrific",
"Fair",
"Conqueror",
"Victorious",
"Glorious",
"Invicible",
] |
Overall it's starting to work really well on our code at least. Good job! |
I've been working with GDScript docstrings lately, and from a super quick look through the code, that feels relatively similar to this idea that comments get assigned to relevant members. Is the purpose of that assignment to be able to format comments associated with members? Because if it is, I wonder whether there's some refactoring possible there. Docstrings can be considered a special case of comment (they use |
Will it be possible to call the formatter via the CMD line? Main use case would be if working with an external editor. |
For external editors, the first goal should be to add formatting to the language server, which will instantly provide support for most editors (vscode, neovim, jetbrain, emacs, ...). That's definitely something to add once the formatter gets merged. Command line support can and should probably be added for use in continuous integration and other use cases outside of an editor (e.g. batch formatting an existing project that didn´t have the formatter). |
I've tested it a bit on a few of my codes. It feels really good but there are still some hiccups here are what I found. I'm not sure the formatter should change annotation placement, I feel it should keep how you had it, especially since there is no explicit guideline on whether to put it in its separate line or not. large number are wrongly formatted, if they are not formatted they do not change and if they are formatted every Adam:
when formatting
it produces this
which is missing the trailing coma (only tested with function but the trailing comma might be misiing in arrays and dictionaries too ) Moreover when separating into multiple line as in the last example if the line is just after a unindent than it will put two empty lines instead of the one needed. reformating will then reduce to one line. I also had a similar issue to the one reported here #76211 (comment) but it was myself with nested functions when having multiple unindent you will have as much empty line as the number of unindent which might result in the next line being a 4 line after (strangely this does not happen in the last function of the script) moreover on reformat it will add one more empty line. |
@anvilfolk Thank you very much for the feedback! For the annotations, Aside from that, yes, there are still some bugs. Thank you so much for the screenshots, I will make test cases out of them and fix them ASAP 👍 |
The exact formatting of the literals is lost: - print(0xFFFF)
- print(1.2e-5)
- print(0.1111111111111133) # <- Out of precision.
- print("\t \n \uFFFD")
- print(""" key="value" """)
+ print(65535)
+ print(0.000012)
+ print(0.11111111111111) # <- Out of precision.
+ print("
+ �")
+ print(' key="value" ') Adam edit:
Possible solution: when allocating a I'm a bit worried about this approach (restoring source code from AST) but overall the code looks good to me, excellent work! Now we need to make sure that the parser has all the necessary information, including the order of the elements. For example, now the formatter moves the setter before the getter, but we do not have this recommendation in the style guide (and in the GDScript reference examples, the getter is placed before the setter). I think the formatter should not change anything that is not regulated by the style guide. 1. Class doc comment moves down. extends Node
-## Class doc comment.
+## Class doc comment.
## Method doc comment.
func test():
pass Adam edit:
2. A space is added before commented code. # Comment.
- #print("code")
+ # print("code") Adam edit:
3. Regression: comment now acts like statement ( Adam edit:
4. The formatter removes unnecessary parentheses added for clarity. As far as I know, this is difficult to solve, since the parser uses parentheses for grouping, but does not create grouping nodes or otherwise store them in the AST. -if not (x is Node) or (y >= 0 and y < 10):
+if not x is Node or y >= 0 and y < 10: -_flags[flag / 8] &= ~(1 << (flag % 8))
+_flags[flag / 8] &= ~1 << flag % 8 Adam edit:
|
@dalexeev We should consider changing the recommended order of the class doc comment to be like this: ## Class doc comment.
class_name MyClass
extends Node Every other doc comment, and all annotations, appear above/before what they describe, not after. To avoid breaking compat, we could do this in steps, first support both orders for one Godot release (like 4.1), then enforce it being above for the next release (like 4.2). |
@aaronfranke You are probably right, although both |
bd0472c
to
09e247f
Compare
69dbfc6
to
11cdcd9
Compare
fddfc28
to
2b1654d
Compare
I'm kind of wondering if there's an update? It seems the last commit was two months ago. Have been craving such an essential feature for the built-in editor for a long time. Thank you for your efforts and work, and really looking forward to having it as a part of the official deliverables! |
We are currently in feature freeze until 4.2 is released so no changes can be merged like this, only then can this be considered and merged, so come back and see when 4.2 has been released 🙂 |
Thank you for the clarification! |
Tested locally with https://github.com/godotengine/godot-demo-projects/tree/2d/platformer, it mostly works as expected. Great work so far 🙂 All files format without errors in that 2D platformer demo, and the demo keeps working after having all its scripts formatted. Questions
Bugs
No formatterif mode == DisplayServer.WINDOW_MODE_FULLSCREEN or \
mode == DisplayServer.WINDOW_MODE_EXCLUSIVE_FULLSCREEN: Formatted once if (
mode
== DisplayServer.WINDOW_MODE_FULLSCREEN
or mode
== DisplayServer.WINDOW_MODE_EXCLUSIVE_FULLSCREEN
): Formatted twice if (
(
mode
== DisplayServer.WINDOW_MODE_FULLSCREEN
or mode
== DisplayServer.WINDOW_MODE_EXCLUSIVE_FULLSCREEN
)
):
if not floor_detector_left.is_colliding():
velocity.x = WALK_SPEED
elif not floor_detector_right.is_colliding():
velocity.x = -WALK_SPEED
if is_on_wall():
velocity.x = -velocity.x
move_and_slide() # ^ Two blank lines above ^
if velocity.x > 0.0:
sprite.scale.x = 1.0
elif velocity.x < 0.0:
sprite.scale.x = -1.0
facing_mesh = (facing_mesh - Vector3.UP * facing_mesh.dot(Vector3.UP)).normalized() This is because the formatter gets rid of parentheses too aggressively in this situation: facing_mesh = facing_mesh - Vector3.UP * facing_mesh.dot(Vector3.UP).normalized() Suggestions for improving formatting
if (
mode
== DisplayServer.WINDOW_MODE_FULLSCREEN
or mode
== DisplayServer.WINDOW_MODE_EXCLUSIVE_FULLSCREEN
): I'd prefer the following: if (
mode == DisplayServer.WINDOW_MODE_FULLSCREEN
or mode == DisplayServer.WINDOW_MODE_EXCLUSIVE_FULLSCREEN
):
class_name Game
extends Node
@onready var _pause_menu := $InterfaceLayer/PauseMenu as PauseMenu
func _unhandled_input(event: InputEvent) -> void:
pass
func other_func():
pass I'd expect this instead: class_name Game
extends Node
@onready var _pause_menu := $InterfaceLayer/PauseMenu as PauseMenu
func _unhandled_input(event: InputEvent) -> void:
pass
func other_func():
pass
...
elif col_left.is_empty() and not col_right.is_empty():
# If only right ray is occluded, turn the camera around to the left.
difference = Basis(Vector3.UP, deg_to_rad(delta * autoturn_speed)) * difference
# Do nothing otherwise, left and right are occluded but center is not, so do not autoturn.
# Apply lookat.
if difference.is_zero_approx():
difference = (pos - target).normalized() * 0.0001 Becomes:
Other suggestions
|
If I may just add one thing, the shortcut for formatting documents in VSC on macOS is |
Is this feature still being actively developed? I'd love to see it get into 4.3, but I don't know how realistic that is. Either way, I really appreciate the effort! |
@emgast It is not being actively developed, the last time changes were pushed was 4 months ago. |
It needs a rebase, might need some new fixes |
I can confirm it is currently not in active development. Adam, the last developer we sponsored to work on this, was rehired by the Godot foundation, and he's now working on higher priority features and fixes for the community. The groundwork is here, with many automated tests. We ran out of budget for this, now we'll need a new contributor or sponsor willing to pick it up and complete this. |
I will take a look over this when I have the time and might be able to salvage it, but will see if I can get my head around all the details Edit: Haven't found the time or energy so not planning to dig into it now |
If anyone gives it a stab, don't hesitate to ask Adam, he can surely give you a quick overview of how the thing works and the areas that caused issues. Mainly, from what I understood, the current implementation faces the same challenges as any formatter that tries to output nice-looking code (like Python's black, the reference for this one): line splitting is complicated, and the algorithm might need a rework. Then, there are lots of small edge cases to address, many of which were listed by Calinou above. |
Not sure what was the reasoning behind implementing this feature as a single PR, but IMO it's virtually impossible to finish it this way - this is basically because GDScript internals are likely changing much faster than this PR can be aligned by a single person. What I would suggest instead is implementing formatter in small steps (PRs up to 200-300LOC of code + tests) - one part at a time. The first PR would basically:
Every consecutive PR would add one small functionality along with tests covering it. The benefit of such "baby steps" approach would be that:
So, in other words, working in the |
@Scony initially, the development was staged like that with a simpler formatter that would focus on the foundations. It couldn't get merged because to preserve user comments, they need to be added to the parser somehow, and from that point on the formatter becomes a project that adds a change to the GDScript parser and needs to be thoroughly tested before merging. The brief was that it generally shouldn't break any code we know before being exposed to users. It's great if the team can find a way to split the work now. For instance, by keeping the formatter unexposed to users. Anyway, this code belongs to the project, we did what we could, and I'll let the GDScript team decide on where to take it from there. You're more than welcome to split it, change it, discard it, or anything else you need to do. |
I agree with @Scony's idea of doing it in small steps, but I don't know if I agree on what the steps should. IMO the first step would be a "noop" formatter that is able to read to the code and leave it exactly as it was found. Then start adding rules one by one, always backed by automated tests. Doing a noop is much harder than formatter that only does one rule and discards the rest, but we can get that into production faster even without all the rules. Not sure if @adamscott is still interested in this. He could work on this as part of his tasks within the Godot Foundation if his schedule allows for it. |
Sorry in advance for being new in these repos and be trying to quote stuff.... You can dismiss this message if it's garbage. I was thinking... If both the parser and the formatter need to follow "code rules"... isn't it a chance to make "code rules" an actual system that them both can connect to? I example: A code rule may be "the sign of a method should look like this", and both the parser and the formatter (even though they are pretty different functionalities in the user's eyes) can read the rule and then do what they need to do depending the tool selected and their configuration The benefit from this is that you can add and delete code rules when the GDScript specification changes without touching the tools that use them. |
@NathanLovato is it out of the question to simply expose IIUC doing so would only require only a small part of the code from this PR, and it would let add-on authors make their own "native" formatters without having to reimplement a GDScript parser outside of the engine. |
@khellste this is not something I couldn't answer. I wouldn't do this for the purpose of making formatters, however:
Existing GDScript parsers:
For making a new third-party parser, Adam Scott suggested looking into the latter and a library called topiary (https://github.com/tweag/topiary) which is designed to make creating a simple formatter easy based on an existing tree sitter parser. It would likely be the fastest route right now to get something production-ready. |
@NathanLovato thank you for the quick reply and the pointers.
Isn't this issue addressed by the updates to In any case, what I'm looking for is a path toward something that runs in Godot without requiring (for example) an external runtime to be installed alongside the engine. Unless I've misunderstood the third-party parsers you linked, it seem to me that right now there aren't any great options for what I want. |
@khellste I don't think the approach used in this PR will likely get merged. Someone could get a proposal up and see if it catches on but... I don't think it's gonna be a priority for anyone to look at something like this soon. It'd be way faster to fork and make the changes you need for yourself. Scony was looking to take over the project and salvage some of the code starting in #97383 (making the official formatter with small incremental PRs with the hope they'll get reviewed and merged more easily), and from what I understood, make the parsing bits that are unique to the needs of a formatter part of the formatter. To me, this is our best bet to get a GDScript formatter, especially as he's made the community one - he has experience specifically with making a GDScript formatter. But this depends on maintainers being available to review, confirm the direction, and merge his work. It'll probably take time. |
@NathanLovato good to know, I wasn't aware of #97383. Scony seems like a natural choice for contributing a formatter given his background. I'll redirect my attention to the new PR/project if I have any questions or ideas. Thanks for taking the time to reply to me in this thread! |
The #97383 is technically ready to merge but it looks like we must wait a bit to let that sink in. Anyway - once this is merged, a series of follow-up PRs will be done. I'll try to make them referring to #97383 so that you can use #97383 for tracking the progress. |
This PR builds upon and finalizes the work done in godotengine/godot/pull/55835.
This PR introduces a GDScript formatter in Godot's script editor. Users can access it through Edit/Format Code, Alt+Shift+F, or by enabling the new Format On Save editor setting while saving their work.
Integrating a formatter in Godot's script editor improves it as an IDE for GDScript. Additionally, the formatter will improve developer's adherence to the official GDScript style guide.
We encourage users to test the formatter on large code bases in order to detect any quirks or bugs that need to be addressed.
Additionally, this PR includes support for comments through a header and inline. During parsing, each comment of a comment block are added to a comment header vector. The next node (variable, function, annotation) has this vector set as the header comments. The following comment until a newline, if present, is set as the inline comment.
The commits have been kept separate for ease of review, but they will be squashed before merging. Lastly, there are no new dependencies included in this PR.
Production edit: Closes godotengine/godot-proposals#3630