Skip to content
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

GDScript: Implement @if_features annotation for feature-based code stripping #102083

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion core/os/os.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -441,12 +441,15 @@ bool OS::has_feature(const String &p_feature) {
}
if (p_feature == "editor_hint") {
return _in_editor;
} else if (p_feature == "editor_runtime") {
} else if (p_feature == "editor_runtime" || p_feature == "runtime") {
Copy link
Contributor

@MarianoGnu MarianoGnu Feb 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@RandomShaper i think this comparison is breaking this usecase:

@if_feature("editor_runtime")
func _process(delta: float) -> void:
>   # editor only process

@if_feature("runtime")
func _process(delta: float) -> void:
>   # runtime only process

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

editor_runtime means the game being run from a tools-enabled build.

For editor-only process, you'd use editor_hint.

Copy link
Contributor

@MarianoGnu MarianoGnu Feb 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i see, thanks for clarification :)

return !_in_editor;
} else if (p_feature == "embedded_in_editor") {
return _embedded_in_editor;
}
#else
if (p_feature == "runtime") {
return true;
}
if (p_feature == "template") {
return true;
}
Expand Down
1 change: 1 addition & 0 deletions editor/export/editor_export_platform.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,7 @@ HashSet<String> EditorExportPlatform::get_features(const Ref<EditorExportPreset>
result.insert(E);
}

result.insert("runtime");
result.insert("template");
if (p_debug) {
result.insert("debug");
Expand Down
11 changes: 11 additions & 0 deletions modules/gdscript/doc_classes/@GDScript.xml
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,17 @@
[b]Note:[/b] Unlike most other annotations, the argument of the [annotation @icon] annotation must be a string literal (constant expressions are not supported).
</description>
</annotation>
<annotation name="@if_features" qualifiers="vararg">
<return type="void" />
<param index="0" name="feature" type="String" default="null" />
<description>
Marks the following function to be taken into account only if all the features passed as arguments are declared by the platform or export preset.
This is meant to be applied to a set of functions with the same name so at runtime you have a single implementation of some feature according to the features supported. All the functions in such a set must have the exact same signature.
If the annotation has no features specified, it's considered a default one as so it's only used if none of the other functions with the same name are proper fits.
At runtime from an editor build, the features are checked at the moment the script is parsed, against what the OS advertises.
At export time, the features are checked against the target platform's OS as well as the list of custom features specified in an export preset. Moreover, [b]the functions not matching the features, are removed from the source code[/b] so the exported project won't contain them at all.
</description>
</annotation>
<annotation name="@onready">
<return type="void" />
<description>
Expand Down
12 changes: 11 additions & 1 deletion modules/gdscript/gdscript_cache.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ String GDScriptParserRef::get_path() const {
return path;
}

void GDScriptParserRef::set_path(const String &p_path) {
path = p_path;
}

uint32_t GDScriptParserRef::get_source_hash() const {
return source_hash;
}
Expand Down Expand Up @@ -134,9 +138,15 @@ void GDScriptParserRef::clear() {
}

GDScriptParserRef::~GDScriptParserRef() {
#ifdef TOOLS_ENABLED
bool remove_from_map = !parser || !parser->is_for_export();
#else
bool remove_from_map = true;
#endif

clear();

if (!abandoned) {
if (remove_from_map && !abandoned) {
MutexLock lock(GDScriptCache::singleton->mutex);
GDScriptCache::singleton->parser_map.erase(path);
}
Expand Down
1 change: 1 addition & 0 deletions modules/gdscript/gdscript_cache.h
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ class GDScriptParserRef : public RefCounted {
public:
Status get_status() const;
String get_path() const;
void set_path(const String &p_path);
uint32_t get_source_hash() const;
GDScriptParser *get_parser();
GDScriptAnalyzer *get_analyzer();
Expand Down
6 changes: 6 additions & 0 deletions modules/gdscript/gdscript_compiler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2947,6 +2947,12 @@ Error GDScriptCompiler::_compile_class(GDScript *p_script, const GDScriptParser:
const GDScriptParser::ClassNode::Member &member = p_class->members[i];
if (member.type == member.FUNCTION) {
const GDScriptParser::FunctionNode *function = member.function;
#ifdef TOOLS_ENABLED
// Ignore unfitting @if_features-decorated functions.
if (function->if_features.potential_candidate_index != -1) {
continue;
}
#endif
Error err = OK;
_parse_function(err, p_script, p_class, function);
if (err) {
Expand Down
30 changes: 30 additions & 0 deletions modules/gdscript/gdscript_editor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
#include "gdscript_utility_functions.h"

#ifdef TOOLS_ENABLED
#include "editor/export/editor_export.h"
#include "editor/gdscript_docgen.h"
#include "editor/script_templates/templates.gen.h"
#endif
Expand Down Expand Up @@ -133,6 +134,9 @@ bool GDScriptLanguage::validate(const String &p_script, const String &p_path, Li
GDScriptParser parser;
GDScriptAnalyzer analyzer(&parser);

#ifdef TOOLS_ENABLED
parser.set_for_edition();
#endif
Error err = parser.parse(p_script, p_path, false);
if (err == OK) {
err = analyzer.analyze();
Expand Down Expand Up @@ -982,6 +986,26 @@ static void _find_annotation_arguments(const GDScriptParser::AnnotationNode *p_a
r_result.insert(option.display, option);
}
}
} else if (p_annotation->name == SNAME("@if_features")) {
#ifdef TOOLS_ENABLED
HashSet<String> features;
for (int i = 0; i < EditorExport::get_singleton()->get_export_preset_count(); i++) {
const Ref<EditorExportPreset> &preset = EditorExport::get_singleton()->get_export_preset(i);
for (const String &feature : preset->get_custom_features().split(",", false)) {
features.insert(feature.strip_edges());
}
List<String> platform_features;
preset->get_platform()->get_platform_features(&platform_features);
for (const String &feature : platform_features) {
features.insert(feature.strip_edges());
}
}
for (const String &feature : features) {
ScriptLanguage::CodeCompletionOption option(feature, ScriptLanguage::CODE_COMPLETION_KIND_PLAIN_TEXT);
option.insert_text = option.display.quote(p_quote_style);
r_result.insert(option.display, option);
}
#endif
}
}

Expand Down Expand Up @@ -3207,6 +3231,9 @@ ::Error GDScriptLanguage::complete_code(const String &p_code, const String &p_pa
GDScriptParser parser;
GDScriptAnalyzer analyzer(&parser);

#ifdef TOOLS_ENABLED
parser.set_for_edition();
#endif
parser.parse(p_code, p_path, true);
analyzer.analyze();

Expand Down Expand Up @@ -4023,6 +4050,9 @@ ::Error GDScriptLanguage::lookup_code(const String &p_code, const String &p_symb
}

GDScriptParser parser;
#ifdef TOOLS_ENABLED
parser.set_for_edition();
#endif
parser.parse(p_code, p_path, true);

GDScriptParser::CompletionContext context = parser.get_completion_context();
Expand Down
169 changes: 162 additions & 7 deletions modules/gdscript/gdscript_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ GDScriptParser::GDScriptParser() {
register_annotation(MethodInfo("@warning_ignore_restore", PropertyInfo(Variant::STRING, "warning")), AnnotationInfo::STANDALONE, &GDScriptParser::warning_ignore_region_annotations, varray(), true);
// Networking.
register_annotation(MethodInfo("@rpc", PropertyInfo(Variant::STRING, "mode"), PropertyInfo(Variant::STRING, "sync"), PropertyInfo(Variant::STRING, "transfer_mode"), PropertyInfo(Variant::INT, "transfer_channel")), AnnotationInfo::FUNCTION, &GDScriptParser::rpc_annotation, varray("authority", "call_remote", "unreliable", 0));
// Preprocessing.
register_annotation(MethodInfo("@if_features", PropertyInfo(Variant::STRING, "feature")), AnnotationInfo::FUNCTION, &GDScriptParser::if_features_annotation, varray(Variant()), true);
}

#ifdef DEBUG_ENABLED
Expand Down Expand Up @@ -944,11 +946,22 @@ void GDScriptParser::parse_class_member(T *(GDScriptParser::*p_parse_function)(b

// Consume annotations.
List<AnnotationNode *> annotations;
#ifdef TOOLS_ENABLED
constexpr bool parsing_function = std::is_same_v<T, FunctionNode>;
AnnotationNode *if_features = nullptr;
#endif
while (!annotation_stack.is_empty()) {
AnnotationNode *last_annotation = annotation_stack.back()->get();
if (last_annotation->applies_to(p_target)) {
annotations.push_front(last_annotation);
annotation_stack.pop_back();
#ifdef TOOLS_ENABLED
if constexpr (parsing_function) {
if (last_annotation->name == StringName("@if_features")) {
if_features = last_annotation;
}
}
#endif
} else {
push_error(vformat(R"(Annotation "%s" cannot be applied to a %s.)", last_annotation->name, p_member_kind));
clear_unused_annotations();
Expand Down Expand Up @@ -994,6 +1007,32 @@ void GDScriptParser::parse_class_member(T *(GDScriptParser::*p_parse_function)(b
}

min_member_doc_line = member->end_line + 1; // Prevent multiple members from using the same doc comment.

if constexpr (parsing_function) {
if (if_features) {
// Mark this one as a default implementation if the annotation provides no features.
member->if_features.is_default_impl = if_features->arguments.is_empty();

// Let the first default one with of same name fully in.
// The others are added so they are parsed, but not indexed so name clash error is avoided.
HashMap<StringName, int>::Iterator E = current_class->members_indices.find(member->identifier->name);
if (E) {
// Member with that name already exists.
const ClassNode::Member &existing = current_class->members[E->value];
if (existing.type == ClassNode::Member::FUNCTION) { // Otherwise, an error is raised anyway.
// HACK: Compare compatibility of functions via TreePrinter.
const String &existing_str = TreePrinter().strinfigy_function_declaration(existing.function);
const String &incoming_str = TreePrinter().strinfigy_function_declaration(member);
if (existing_str != incoming_str) {
push_error(vformat(R"(%s "%s" does not match the signature of a previously declared function of the same name.)", p_member_kind.capitalize(), member->identifier->name), member->identifier);
} else {
current_class->add_if_features_potential_candidate(member);
}
return;
}
}
}
}
#endif // TOOLS_ENABLED

if (member->identifier != nullptr) {
Expand Down Expand Up @@ -4975,6 +5014,108 @@ bool GDScriptParser::warning_ignore_region_annotations(AnnotationNode *p_annotat
#endif // DEBUG_ENABLED
}

bool GDScriptParser::if_features_annotation(AnnotationNode *p_annotation, Node *p_target, ClassNode *p_class) {
#if defined(TOOLS_ENABLED)
ERR_FAIL_COND_V_MSG(p_target->type != Node::FUNCTION, false, vformat(R"("%s" annotation can only be applied to functions.)", p_annotation->name));

FunctionNode *function = static_cast<FunctionNode *>(p_target);
if (function->if_features.used) {
push_error("The @if_features annotation can only be used once per function.");
return false;
}

function->if_features.used = true;

thread_local LocalVector<String> features;
features.clear();
for (const ExpressionNode *arg : p_annotation->arguments) {
DEV_ASSERT(arg->reduced);
if (arg->reduced_value.get_type() == Variant::STRING) {
features.push_back(arg->reduced_value);
} else {
push_error("The arguments to @if_features must be strings.");
return false;
}
}

// There's nothing to process unless we're exporting or running in the editor.
// Sadly, we can't tell for sure if the script is being run in the editor or just being edited.
// We only know for sure it's the latter if this parsing is done for completion reasons.
if (for_edition) {
return true;
}

// The idea is to keep the first one fitting, with only the default possibly overridden by another one coming later.

FunctionNode *target_function = static_cast<FunctionNode *>(p_target);
const StringName &function_name = target_function->identifier->name;

auto _check_incoming_fits = [&]() -> bool {
if (features.is_empty()) {
return false; // Defaults are considered non-fitting.
}
bool fitting = true;
for (const String &feature : features) {
if (for_export) {
// Export time in editor build.
if (!export_features.has(feature)) {
fitting = false;
break;
}
} else {
// Run-on-editor.
if (!OS::get_singleton()->has_feature(feature)) {
fitting = false;
break;
}
}
}
return fitting;
};

if (target_function->if_features.potential_candidate_index == -1) {
// Chosen one at parsing time because it was the first one found. Only keep if fitting.
if (!_check_incoming_fits()) {
HashMap<StringName, int>::Iterator E = p_class->members_indices.find(function_name);
int64_t current_match_index = E->value;
target_function->if_features.potential_candidate_index = current_match_index;
p_class->members_indices.remove(E);
}
} else {
HashMap<StringName, int>::Iterator E = p_class->members_indices.find(function_name);
if ((bool)E) {
// There's a current one. Override if current is default and incoming fits.
bool current_match_is_default = p_class->members[E->value].function->if_features.is_default_impl;
if (current_match_is_default && _check_incoming_fits()) {
E->value = target_function->if_features.potential_candidate_index;
target_function->if_features.potential_candidate_index = -1;
}
} else {
// Incoming fits and there's no current chosen. Pick if it's default or fits.
if (target_function->if_features.is_default_impl || _check_incoming_fits()) {
p_class->members_indices.insert(function_name, target_function->if_features.potential_candidate_index);
target_function->if_features.potential_candidate_index = -1;
}
}
}
#endif

return true;
}

#ifdef TOOLS_ENABLED
void GDScriptParser::collect_unfitting_functions(ClassNode *p_class, LocalVector<Pair<ClassNode *, FunctionNode *>> &r_functions) {
for (const ClassNode::Member &member : p_class->members) {
if (member.type == ClassNode::Member::CLASS) {
collect_unfitting_functions(member.m_class, r_functions);
}
if (member.type == ClassNode::Member::FUNCTION && member.function->if_features.potential_candidate_index != -1) {
r_functions.push_back(Pair(p_class, member.function));
}
}
}
#endif

bool GDScriptParser::rpc_annotation(AnnotationNode *p_annotation, Node *p_target, ClassNode *p_class) {
ERR_FAIL_COND_V_MSG(p_target->type != Node::FUNCTION, false, vformat(R"("%s" annotation can only be applied to functions.)", p_annotation->name));

Expand Down Expand Up @@ -5833,9 +5974,11 @@ void GDScriptParser::TreePrinter::print_for(ForNode *p_for) {
decrease_indent();
}

void GDScriptParser::TreePrinter::print_function(FunctionNode *p_function, const String &p_context) {
for (const AnnotationNode *E : p_function->annotations) {
print_annotation(E);
void GDScriptParser::TreePrinter::print_function(FunctionNode *p_function, const String &p_context, bool p_signature_only) {
if (!p_signature_only) {
for (const AnnotationNode *E : p_function->annotations) {
print_annotation(E);
}
}
if (p_function->is_static) {
push_text("Static ");
Expand All @@ -5854,10 +5997,17 @@ void GDScriptParser::TreePrinter::print_function(FunctionNode *p_function, const
}
print_parameter(p_function->parameters[i]);
}
push_line(" ) :");
increase_indent();
print_suite(p_function->body);
decrease_indent();
push_text(" ) ");
if (p_function->return_type) {
push_text("-> ");
print_type(p_function->return_type);
}
if (!p_signature_only) {
push_line(" :");
increase_indent();
print_suite(p_function->body);
decrease_indent();
}
}

void GDScriptParser::TreePrinter::print_get_node(GetNodeNode *p_get_node) {
Expand Down Expand Up @@ -6278,4 +6428,9 @@ void GDScriptParser::TreePrinter::print_tree(const GDScriptParser &p_parser) {
print_line(String(printed));
}

String GDScriptParser::TreePrinter::strinfigy_function_declaration(FunctionNode *p_function) {
print_function(p_function, "Function", true);
return String(printed);
}

#endif // DEBUG_ENABLED
Loading