@satelliteoflove/godot-mcp 3.14.0 → 3.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/addon/command_router.gd +1 -0
- package/addon/commands/exec_commands.gd +117 -0
- package/addon/commands/exec_commands.gd.uid +1 -0
- package/addon/commands/runtime_state_commands.gd +2 -1
- package/addon/core/mcp_debugger_plugin.gd +24 -0
- package/addon/game_bridge/mcp_exec_guard.gd +225 -0
- package/addon/game_bridge/mcp_exec_guard.gd.uid +1 -0
- package/addon/game_bridge/mcp_game_bridge.gd +239 -4
- package/addon/game_bridge/mcp_runtime_state_sampler.gd +172 -5
- package/addon/plugin.cfg +1 -1
- package/dist/__tests__/connection/timeouts.test.js +4 -1
- package/dist/__tests__/connection/timeouts.test.js.map +1 -1
- package/dist/__tests__/tools/exec.test.d.ts +2 -0
- package/dist/__tests__/tools/exec.test.d.ts.map +1 -0
- package/dist/__tests__/tools/exec.test.js +124 -0
- package/dist/__tests__/tools/exec.test.js.map +1 -0
- package/dist/__tests__/tools/runtime-state.test.js +247 -5
- package/dist/__tests__/tools/runtime-state.test.js.map +1 -1
- package/dist/connection/timeouts.d.ts +1 -0
- package/dist/connection/timeouts.d.ts.map +1 -1
- package/dist/connection/timeouts.js +1 -0
- package/dist/connection/timeouts.js.map +1 -1
- package/dist/tools/exec.d.ts +16 -0
- package/dist/tools/exec.d.ts.map +1 -0
- package/dist/tools/exec.js +102 -0
- package/dist/tools/exec.js.map +1 -0
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +3 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/runtime-state.d.ts +34 -3
- package/dist/tools/runtime-state.d.ts.map +1 -1
- package/dist/tools/runtime-state.js +129 -17
- package/dist/tools/runtime-state.js.map +1 -1
- package/package.json +1 -1
package/addon/command_router.gd
CHANGED
|
@@ -23,6 +23,7 @@ func setup(plugin: EditorPlugin) -> void:
|
|
|
23
23
|
_register_handler(MCPProfilerCommands.new(), plugin)
|
|
24
24
|
_register_handler(MCPRuntimeStateCommands.new(), plugin)
|
|
25
25
|
_register_handler(MCPGameTimeCommands.new(), plugin)
|
|
26
|
+
_register_handler(MCPExecCommands.new(), plugin)
|
|
26
27
|
|
|
27
28
|
|
|
28
29
|
func _register_handler(handler: MCPBaseCommand, plugin: EditorPlugin) -> void:
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
@tool
|
|
2
|
+
extends MCPBaseCommand
|
|
3
|
+
class_name MCPExecCommands
|
|
4
|
+
|
|
5
|
+
# godot_exec relay (#243): run / list / remove / clear execute in the game
|
|
6
|
+
# bridge (mcp_game_bridge.gd); this side only forwards over the debugger
|
|
7
|
+
# channel and waits, exactly like game_time_commands.gd. The server derives the
|
|
8
|
+
# timeout cascade from the call's declared budget and pushes relay_timeout_ms
|
|
9
|
+
# in params; the constants are fallbacks for an older server that pushes none.
|
|
10
|
+
# RUN_TIMEOUT is generous because a synchronous user script cannot be aborted
|
|
11
|
+
# mid-flight — the relay waiting is what turns a hung script into a typed
|
|
12
|
+
# TIMEOUT instead of a socket kill.
|
|
13
|
+
const BASE_TIMEOUT := 10.0
|
|
14
|
+
const RUN_TIMEOUT := 28.0
|
|
15
|
+
|
|
16
|
+
var _last_error: Dictionary = {}
|
|
17
|
+
var _call_seq := 0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
func get_commands() -> Dictionary:
|
|
21
|
+
return {
|
|
22
|
+
"exec_run": exec_run,
|
|
23
|
+
"exec_list": exec_list,
|
|
24
|
+
"exec_remove": exec_remove,
|
|
25
|
+
"exec_clear": exec_clear,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
func exec_run(params: Dictionary) -> Dictionary:
|
|
30
|
+
return await _relay("exec_run", params, _relay_timeout(params, RUN_TIMEOUT))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
func exec_list(params: Dictionary) -> Dictionary:
|
|
34
|
+
return await _relay("exec_list", params, BASE_TIMEOUT)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
func exec_remove(params: Dictionary) -> Dictionary:
|
|
38
|
+
return await _relay("exec_remove", params, BASE_TIMEOUT)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
func exec_clear(params: Dictionary) -> Dictionary:
|
|
42
|
+
return await _relay("exec_clear", params, BASE_TIMEOUT)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
func _relay_timeout(params: Dictionary, fallback: float) -> float:
|
|
46
|
+
# Use the server-pushed relay budget when present (#276); the local constant
|
|
47
|
+
# is only a fallback for an older server that does not derive the cascade.
|
|
48
|
+
var ms: float = float(params.get("relay_timeout_ms", fallback * 1000.0))
|
|
49
|
+
return ms / 1000.0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
func _relay(msg_type: String, params: Dictionary, timeout: float) -> Dictionary:
|
|
53
|
+
# Explicit request/response correlation: the debugger plugin keys responses
|
|
54
|
+
# by msg_type alone, so a timed-out call's LATE response (a slow script that
|
|
55
|
+
# finished after we gave up) could otherwise be consumed as the answer to
|
|
56
|
+
# the next call of the same type — wrong result, silently, and every result
|
|
57
|
+
# after it shifted by one. The bridge echoes call_id; the wait loop discards
|
|
58
|
+
# mismatches. A response with no call_id (older addon) is accepted as-is.
|
|
59
|
+
_call_seq += 1
|
|
60
|
+
var call_id := _call_seq
|
|
61
|
+
params = params.duplicate()
|
|
62
|
+
params["call_id"] = call_id
|
|
63
|
+
var response = await _send_and_wait(msg_type, [params], timeout, call_id)
|
|
64
|
+
if response == null:
|
|
65
|
+
return _last_error
|
|
66
|
+
if response is Dictionary:
|
|
67
|
+
response.erase("call_id") # transport detail, not part of the result
|
|
68
|
+
if response.has("error"):
|
|
69
|
+
return _error("EXEC_ERROR", str(response["error"]))
|
|
70
|
+
return _success(response)
|
|
71
|
+
return _success({"data": response})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
func _send_and_wait(msg_type: String, args: Array, timeout: float, call_id: int):
|
|
75
|
+
if not EditorInterface.is_playing_scene():
|
|
76
|
+
_last_error = _error("NOT_RUNNING", "No game is currently running")
|
|
77
|
+
return null
|
|
78
|
+
|
|
79
|
+
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
|
|
80
|
+
if debugger_plugin == null or not debugger_plugin.has_active_session():
|
|
81
|
+
_last_error = _error("NO_SESSION", "No active debug session")
|
|
82
|
+
return null
|
|
83
|
+
|
|
84
|
+
var sent: bool = debugger_plugin.send_game_message(msg_type, args)
|
|
85
|
+
if not sent:
|
|
86
|
+
_last_error = _error("SEND_FAILED", "Failed to send message to game")
|
|
87
|
+
return null
|
|
88
|
+
|
|
89
|
+
var start_time := Time.get_ticks_msec()
|
|
90
|
+
while true:
|
|
91
|
+
await Engine.get_main_loop().process_frame
|
|
92
|
+
# A hard runtime error (or failed assert) in exec'd code breaks the game
|
|
93
|
+
# into the editor debugger, suspending the bridge handler mid-call —
|
|
94
|
+
# left alone, the game sits paused and this relay times out.
|
|
95
|
+
# Auto-continue: the handler resumes, the error lands in its logger
|
|
96
|
+
# window, and the response arrives with runtime_errors as designed.
|
|
97
|
+
# Honest limit: the break REASON is not queryable, so this resumes ANY
|
|
98
|
+
# break in the window — including a developer's own breakpoint hit by
|
|
99
|
+
# unrelated game code while an exec call is in flight. Accepted for the
|
|
100
|
+
# dev-tooling threat model and surfaced in the tool description.
|
|
101
|
+
if debugger_plugin.is_session_breaked():
|
|
102
|
+
debugger_plugin.continue_session()
|
|
103
|
+
if debugger_plugin.has_response(msg_type):
|
|
104
|
+
var response = debugger_plugin.get_response(msg_type)
|
|
105
|
+
debugger_plugin.clear_response(msg_type)
|
|
106
|
+
if response is Dictionary and response.has("call_id") \
|
|
107
|
+
and int(response["call_id"]) != call_id:
|
|
108
|
+
continue # a previous call's late response — discard, keep waiting for ours
|
|
109
|
+
return response
|
|
110
|
+
if (Time.get_ticks_msec() - start_time) / 1000.0 > timeout:
|
|
111
|
+
debugger_plugin.clear_response(msg_type)
|
|
112
|
+
var hint := ""
|
|
113
|
+
if debugger_plugin.is_session_breaked():
|
|
114
|
+
hint = " (the game is paused in the editor debugger and did not resume; press Continue in the editor or run godot_editor stop)"
|
|
115
|
+
_last_error = _error("TIMEOUT", "Timed out waiting for %s response%s" % [msg_type, hint])
|
|
116
|
+
return null
|
|
117
|
+
return null # unreachable; satisfies the parser
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://clbw4kvqpf2py
|
|
@@ -28,7 +28,8 @@ func watch_start(params: Dictionary) -> Dictionary:
|
|
|
28
28
|
var specs: Array = params.get("specs", [])
|
|
29
29
|
var hz: int = params.get("hz", 20)
|
|
30
30
|
var duration_ms: int = params.get("duration_ms", 1000)
|
|
31
|
-
var
|
|
31
|
+
var sigs: Array = params.get("signals", [])
|
|
32
|
+
var result = await _send_and_wait("watch_start", [specs, hz, duration_ms, sigs])
|
|
32
33
|
if result == null:
|
|
33
34
|
return _last_error
|
|
34
35
|
if result is Dictionary:
|
|
@@ -326,3 +326,27 @@ func toggle_frame_profiler(enable: bool) -> void:
|
|
|
326
326
|
var session := get_session(_active_session_id)
|
|
327
327
|
if session:
|
|
328
328
|
session.toggle_profiler("mcp_frame_profiler", enable)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# True when the debugger has paused the running game (script error, failed
|
|
332
|
+
# assert, or breakpoint). A break suspends whatever bridge handler was running
|
|
333
|
+
# mid-call, so relays that would otherwise time out can detect and recover.
|
|
334
|
+
func is_session_breaked() -> bool:
|
|
335
|
+
if _active_session_id < 0:
|
|
336
|
+
return false
|
|
337
|
+
var session := get_session(_active_session_id)
|
|
338
|
+
return session != null and session.is_breaked()
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# Resume a debugger-paused game. EditorDebuggerSession exposes no continue API,
|
|
342
|
+
# but the raw "continue" command is the same wire message the editor's Continue
|
|
343
|
+
# button sends (ScriptEditorDebugger::_put_msg), and the game's RemoteDebugger
|
|
344
|
+
# handles it in its break loop. Returns false when there is nothing to resume.
|
|
345
|
+
func continue_session() -> bool:
|
|
346
|
+
if _active_session_id < 0:
|
|
347
|
+
return false
|
|
348
|
+
var session := get_session(_active_session_id)
|
|
349
|
+
if session == null or not session.is_breaked():
|
|
350
|
+
return false
|
|
351
|
+
session.send_message("continue", [])
|
|
352
|
+
return true
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
extends RefCounted
|
|
2
|
+
class_name MCPExecGuard
|
|
3
|
+
|
|
4
|
+
## Pure helpers behind godot_exec (#243): the static denylist scan and the
|
|
5
|
+
## function-body wrapper that turn agent-provided GDScript into something the
|
|
6
|
+
## game bridge can compile and run. No engine-state access — everything here is
|
|
7
|
+
## headless-testable (test/exec_guard_headless_test.gd).
|
|
8
|
+
##
|
|
9
|
+
## The scan is an ACCIDENT GUARD, not a security boundary: GDScript cannot be
|
|
10
|
+
## sandboxed, and a determined author can trivially evade a token scan (string-
|
|
11
|
+
## built calls, indirection). Its job is to stop a well-meaning agent from
|
|
12
|
+
## casually reaching outside the game process (spawning processes, writing
|
|
13
|
+
## files, persisting project damage) — the threat model of local dev tooling
|
|
14
|
+
## where the agent already has shell access anyway.
|
|
15
|
+
|
|
16
|
+
# Tokens that reach outside the game process or persist damage to disk.
|
|
17
|
+
# Granularity rationale:
|
|
18
|
+
# - OS.* spawn/kill/shell methods individually (the rest of OS is harmless and
|
|
19
|
+
# frequently legitimate: get_ticks, environment reads, ...).
|
|
20
|
+
# - DirAccess whole-class: every directory mutation lives there; losing
|
|
21
|
+
# read-only listing is acceptable collateral.
|
|
22
|
+
# - FileAccess at the WRITE-constant level: any direct write-mode open must
|
|
23
|
+
# name one of these constants, while read access (loading a save file to
|
|
24
|
+
# inspect it) stays available — which is why FileAccess is not class-blocked.
|
|
25
|
+
# - ResourceSaver whole-class: writes res:// and user:// resources.
|
|
26
|
+
# - EditorInterface: belt-and-suspenders; not normally reachable from a game.
|
|
27
|
+
const DENYLIST: Array[String] = [
|
|
28
|
+
"OS.execute",
|
|
29
|
+
"OS.execute_with_pipe",
|
|
30
|
+
"OS.create_process",
|
|
31
|
+
"OS.create_instance",
|
|
32
|
+
"OS.kill",
|
|
33
|
+
"OS.shell_open",
|
|
34
|
+
"OS.shell_show_in_file_manager",
|
|
35
|
+
"OS.move_to_trash",
|
|
36
|
+
"DirAccess",
|
|
37
|
+
"FileAccess.WRITE",
|
|
38
|
+
"FileAccess.READ_WRITE",
|
|
39
|
+
"FileAccess.WRITE_READ",
|
|
40
|
+
"ResourceSaver",
|
|
41
|
+
"ProjectSettings.save",
|
|
42
|
+
"ProjectSettings.save_custom",
|
|
43
|
+
"EditorInterface",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# Wrapped user code must compile under projects that escalate warnings to
|
|
47
|
+
# errors, and snippets legitimately ignore parameters, shadow autoload names,
|
|
48
|
+
# discard return values, etc. An invalid warning name here is itself a parse
|
|
49
|
+
# error — pinned by the headless compile test on the Godot version in use.
|
|
50
|
+
const WRAPPER_WARNING_IGNORES := "@warning_ignore(\"unused_parameter\", \"shadowed_global_identifier\", \"shadowed_variable\", \"shadowed_variable_base_class\", \"standalone_expression\", \"return_value_discarded\", \"unused_variable\", \"unreachable_code\", \"integer_division\")"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
## Scan agent-provided source BEFORE compiling. Comments and string contents
|
|
54
|
+
## are stripped first, so `print("OS.execute")` passes while `OS.execute(...)`
|
|
55
|
+
## is caught. Returns {ok: true} or {ok: false, token, message}.
|
|
56
|
+
static func scan_source(source: String) -> Dictionary:
|
|
57
|
+
var lex := _lex(_normalize(source))
|
|
58
|
+
var stripped: String = lex["stripped"]
|
|
59
|
+
if stripped.strip_edges().is_empty():
|
|
60
|
+
return {
|
|
61
|
+
"ok": false,
|
|
62
|
+
"token": "",
|
|
63
|
+
"message": "NO_CODE: exec source contains no executable code (comments only or empty)",
|
|
64
|
+
}
|
|
65
|
+
# Normalize the SCAN copy (never the wrapper input) so the two formatter-
|
|
66
|
+
# plausible token splits — `OS . execute` and a line continuation after the
|
|
67
|
+
# dot — still match. Over-matching here only over-blocks, which is the safe
|
|
68
|
+
# direction for an accident guard.
|
|
69
|
+
stripped = stripped.replace("\\\n", "")
|
|
70
|
+
var dot_ws := RegEx.new()
|
|
71
|
+
dot_ws.compile("\\s*\\.\\s*")
|
|
72
|
+
stripped = dot_ws.sub(stripped, ".", true)
|
|
73
|
+
if _token_re("await").search(stripped) != null:
|
|
74
|
+
return {
|
|
75
|
+
"ok": false,
|
|
76
|
+
"token": "await",
|
|
77
|
+
"message": "SYNC_ONLY: exec source is synchronous-only; 'await' is not allowed. " +
|
|
78
|
+
"For waiting on game state, compose with godot_game_time step/step_until; " +
|
|
79
|
+
"for sustained behavior, attach a node under `holder`.",
|
|
80
|
+
}
|
|
81
|
+
for token in DENYLIST:
|
|
82
|
+
if _token_re(token).search(stripped) != null:
|
|
83
|
+
return {
|
|
84
|
+
"ok": false,
|
|
85
|
+
"token": token,
|
|
86
|
+
"message": "DENIED_TOKEN: source contains '%s' — blocked by the exec denylist " % token +
|
|
87
|
+
"(an accident guard against process/file-write escape, not a security boundary). " +
|
|
88
|
+
"Exec is for mutating the running game's state, not the system around it.",
|
|
89
|
+
}
|
|
90
|
+
return {"ok": true}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
## Wrap user source as the body of `_mcp_run(<binding_names>)` on a RefCounted,
|
|
94
|
+
## so bindings are bare names (`G.wave = 5` just works) and multi-statement
|
|
95
|
+
## code with control flow compiles. Lines inside multiline strings are left
|
|
96
|
+
## untouched (prefixing them would corrupt string content); everything else is
|
|
97
|
+
## prefixed with one indent unit matched to the user's own indent character, so
|
|
98
|
+
## the wrapper can never introduce a mixed-tabs-and-spaces parse error.
|
|
99
|
+
static func build_wrapper(source: String, binding_names: PackedStringArray) -> String:
|
|
100
|
+
var normalized := _normalize(source)
|
|
101
|
+
var lex := _lex(normalized)
|
|
102
|
+
var in_string_lines: Array = lex["in_string_lines"]
|
|
103
|
+
var lines := normalized.split("\n")
|
|
104
|
+
var indent := _detect_indent_unit(lines, in_string_lines, lex["in_bracket_lines"])
|
|
105
|
+
var body: Array = []
|
|
106
|
+
for i in lines.size():
|
|
107
|
+
var line: String = lines[i]
|
|
108
|
+
var starts_in_string: bool = in_string_lines[i] if i < in_string_lines.size() else false
|
|
109
|
+
if starts_in_string or line.strip_edges().is_empty():
|
|
110
|
+
body.append(line)
|
|
111
|
+
else:
|
|
112
|
+
body.append(indent + line)
|
|
113
|
+
return "extends RefCounted\n\n%s\nfunc _mcp_run(%s):\n%s\n" % [
|
|
114
|
+
WRAPPER_WARNING_IGNORES,
|
|
115
|
+
", ".join(binding_names),
|
|
116
|
+
"\n".join(body),
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
static func _normalize(source: String) -> String:
|
|
121
|
+
return source.replace("\r\n", "\n").replace("\r", "\n")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
static func _token_re(token: String) -> RegEx:
|
|
125
|
+
# Word-boundary match so `OS.execute` does not hit `MyOS.executed` — and,
|
|
126
|
+
# because `_` is a word character, `OS.execute` does NOT match
|
|
127
|
+
# `OS.execute_with_pipe` (which has its own entry).
|
|
128
|
+
var re := RegEx.new()
|
|
129
|
+
re.compile("\\b%s\\b" % token.replace(".", "\\."))
|
|
130
|
+
return re
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
## One lexical pass tracking string/comment/bracket state. Returns:
|
|
134
|
+
## stripped: source with comments removed and string CONTENTS removed
|
|
135
|
+
## (quotes kept), line structure preserved — the scan target
|
|
136
|
+
## in_string_lines: per line, whether it STARTS inside a (triple-quoted)
|
|
137
|
+
## string — those lines must not be indent-prefixed
|
|
138
|
+
## in_bracket_lines: per line, whether it STARTS inside an open ()/[]/{} —
|
|
139
|
+
## bracket interiors have free-form indentation, so these
|
|
140
|
+
## lines must not drive indent-unit detection
|
|
141
|
+
## Known accepted miss: raw strings (r"...") treat backslash as an escape here
|
|
142
|
+
## though GDScript does not — the failure mode is over-blocking or an honest
|
|
143
|
+
## compile error, never silent corruption.
|
|
144
|
+
static func _lex(source: String) -> Dictionary:
|
|
145
|
+
var stripped := ""
|
|
146
|
+
var in_string_lines: Array = [false]
|
|
147
|
+
var in_bracket_lines: Array = [false]
|
|
148
|
+
var quote := "" # active quote: '"', "'", '"""', or "'''"
|
|
149
|
+
var depth := 0 # open-bracket depth, tracked outside strings/comments
|
|
150
|
+
var i := 0
|
|
151
|
+
var n := source.length()
|
|
152
|
+
while i < n:
|
|
153
|
+
var c := source[i]
|
|
154
|
+
if quote != "":
|
|
155
|
+
if c == "\n":
|
|
156
|
+
stripped += "\n"
|
|
157
|
+
in_string_lines.append(true)
|
|
158
|
+
in_bracket_lines.append(depth > 0)
|
|
159
|
+
i += 1
|
|
160
|
+
elif c == "\\":
|
|
161
|
+
# An escaped char never ends the string — but an escaped
|
|
162
|
+
# NEWLINE (a line continuation inside the string) still starts
|
|
163
|
+
# a new physical line. Record it, or every following line would
|
|
164
|
+
# be mis-indexed and build_wrapper would prefix indent into the
|
|
165
|
+
# string's runtime value: silent corruption.
|
|
166
|
+
if i + 1 < n and source[i + 1] == "\n":
|
|
167
|
+
stripped += "\n"
|
|
168
|
+
in_string_lines.append(true)
|
|
169
|
+
in_bracket_lines.append(depth > 0)
|
|
170
|
+
i += 2
|
|
171
|
+
elif source.substr(i, quote.length()) == quote:
|
|
172
|
+
stripped += quote
|
|
173
|
+
i += quote.length()
|
|
174
|
+
quote = ""
|
|
175
|
+
else:
|
|
176
|
+
i += 1
|
|
177
|
+
continue
|
|
178
|
+
if c == "#":
|
|
179
|
+
while i < n and source[i] != "\n":
|
|
180
|
+
i += 1
|
|
181
|
+
continue
|
|
182
|
+
if c == "\"" or c == "'":
|
|
183
|
+
quote = c.repeat(3) if source.substr(i, 3) == c.repeat(3) else c
|
|
184
|
+
stripped += quote
|
|
185
|
+
i += quote.length()
|
|
186
|
+
continue
|
|
187
|
+
if c == "(" or c == "[" or c == "{":
|
|
188
|
+
depth += 1
|
|
189
|
+
elif c == ")" or c == "]" or c == "}":
|
|
190
|
+
depth = maxi(0, depth - 1)
|
|
191
|
+
if c == "\n":
|
|
192
|
+
stripped += "\n"
|
|
193
|
+
in_string_lines.append(false)
|
|
194
|
+
in_bracket_lines.append(depth > 0)
|
|
195
|
+
i += 1
|
|
196
|
+
continue
|
|
197
|
+
stripped += c
|
|
198
|
+
i += 1
|
|
199
|
+
return {
|
|
200
|
+
"stripped": stripped,
|
|
201
|
+
"in_string_lines": in_string_lines,
|
|
202
|
+
"in_bracket_lines": in_bracket_lines,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
static func _detect_indent_unit(lines: PackedStringArray, in_string_lines: Array, in_bracket_lines: Array) -> String:
|
|
207
|
+
# Match the user's indent character: GDScript rejects mixed tabs/spaces in
|
|
208
|
+
# one indentation run, so the wrapper prefix must agree with the body. Four
|
|
209
|
+
# spaces composes with any space width (nesting only has to increase).
|
|
210
|
+
# Bracket-continuation lines are skipped: their indentation is free-form
|
|
211
|
+
# (a space-aligned array literal in otherwise tab-indented code is legal),
|
|
212
|
+
# so they must not decide the prefix for real block lines.
|
|
213
|
+
for i in lines.size():
|
|
214
|
+
if i < in_string_lines.size() and in_string_lines[i]:
|
|
215
|
+
continue
|
|
216
|
+
if i < in_bracket_lines.size() and in_bracket_lines[i]:
|
|
217
|
+
continue
|
|
218
|
+
var line := lines[i]
|
|
219
|
+
if line.strip_edges().is_empty():
|
|
220
|
+
continue
|
|
221
|
+
if line.begins_with("\t"):
|
|
222
|
+
return "\t"
|
|
223
|
+
if line.begins_with(" "):
|
|
224
|
+
return " "
|
|
225
|
+
return "\t"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://cdscqjpsiv412
|