@el4cteo/rbx-studio-mcp 0.6.8 → 0.7.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/README.md +2 -2
- package/dist/bridge/harness.js +39 -12
- package/dist/bridge/harness.js.map +1 -1
- package/dist/bridge/rpc.js +26 -6
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/bridge/server.js +12 -4
- package/dist/bridge/server.js.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/tools/exec.js +5 -4
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/scripts.js +4 -2
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/world.js +11 -3
- package/dist/tools/world.js.map +1 -1
- package/package.json +74 -74
- package/plugin/src/Config.luau +65 -65
- package/plugin/src/Serialize.luau +10 -0
- package/plugin/src/TextEdit.luau +6 -6
- package/plugin/src/handlers/Discover.luau +690 -685
- package/plugin/src/handlers/Exec.luau +5 -1
- package/plugin/src/handlers/Scripts.luau +677 -673
- package/plugin/src/handlers/Terrain.luau +371 -371
- package/scripts/test-bridge.mjs +328 -267
- package/scripts/test-console.mjs +592 -560
- package/scripts/test-server.mjs +60 -0
|
@@ -1,673 +1,677 @@
|
|
|
1
|
-
--!strict
|
|
2
|
-
--[[
|
|
3
|
-
Script reading, searching, editing and creation.
|
|
4
|
-
|
|
5
|
-
Everything here that writes goes through `ScriptEdit`, which routes the change
|
|
6
|
-
through `ScriptEditorService:UpdateSourceAsync` rather than assigning
|
|
7
|
-
`script.Source`. That is the difference between an edit the Studio editor
|
|
8
|
-
agrees with and one that silently loses whatever the user had typed but not
|
|
9
|
-
saved. Reads use the editor buffer for the same reason: handing an agent stale
|
|
10
|
-
source makes it "fix" changes the user just made.
|
|
11
|
-
|
|
12
|
-
Edits are also wrapped in a single `Undo` recording, so a batch across ten
|
|
13
|
-
scripts is one Ctrl+Z, and a batch that fails half way is rolled back rather
|
|
14
|
-
than left half applied.
|
|
15
|
-
|
|
16
|
-
The text manipulation itself lives in `TextEdit`, which has no Roblox
|
|
17
|
-
dependencies and is unit tested.
|
|
18
|
-
]]
|
|
19
|
-
|
|
20
|
-
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
21
|
-
local Paths = require(script.Parent.Parent.Paths)
|
|
22
|
-
local Scope = require(script.Parent.Parent.Scope)
|
|
23
|
-
local ScriptEdit = require(script.Parent.Parent.ScriptEdit)
|
|
24
|
-
local TextEdit = require(script.Parent.Parent.TextEdit)
|
|
25
|
-
local Undo = require(script.Parent.Parent.Undo)
|
|
26
|
-
|
|
27
|
-
-- Reading every script's editor buffer is a service call each. A place with more
|
|
28
|
-
-- scripts than this is better served by narrowing `path` than by a slow grep
|
|
29
|
-
-- that blocks Studio's main thread.
|
|
30
|
-
local MAX_SCRIPTS = 3_000
|
|
31
|
-
local MAX_MATCHES = 500
|
|
32
|
-
local DEFAULT_CONTEXT = 0
|
|
33
|
-
|
|
34
|
-
local CREATABLE = {
|
|
35
|
-
Script = true,
|
|
36
|
-
LocalScript = true,
|
|
37
|
-
ModuleScript = true,
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
local Scripts = {}
|
|
41
|
-
|
|
42
|
-
--[[
|
|
43
|
-
Resolves a path and insists it holds Luau. Pointing a script tool at an
|
|
44
|
-
ordinary instance otherwise fails later with a confusing property error.
|
|
45
|
-
]]
|
|
46
|
-
local function resolveScript(path: string): LuaSourceContainer
|
|
47
|
-
local instance = Paths.resolve(path)
|
|
48
|
-
if not ScriptEdit.isScript(instance) then
|
|
49
|
-
Dispatch.fail(
|
|
50
|
-
"NOT_A_SCRIPT",
|
|
51
|
-
string.format('"%s" is a %s, not a script.', path, instance.ClassName),
|
|
52
|
-
"Script tools accept Script, LocalScript and ModuleScript. Use `inspect` "
|
|
53
|
-
.. "for other instances, or `find` with className LuaSourceContainer to locate scripts."
|
|
54
|
-
)
|
|
55
|
-
end
|
|
56
|
-
return instance :: LuaSourceContainer
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
--[[
|
|
60
|
-
Reads source, optionally a line window. `startLine`/`endLine` are 1-based and
|
|
61
|
-
inclusive, matching the numbers script_edit takes back, so a read and a write
|
|
62
|
-
need no off-by-one conversion between them.
|
|
63
|
-
]]
|
|
64
|
-
--[[
|
|
65
|
-
A short fingerprint of a script's source, for detecting that it moved.
|
|
66
|
-
|
|
67
|
-
Handed out by `read` and passed back to `edit`, which refuses to write when
|
|
68
|
-
the live source no longer matches. That is the only thing standing between
|
|
69
|
-
two agents on one place and a silent overwrite: a line-range edit computed
|
|
70
|
-
against source somebody has since changed still applies cleanly, it just
|
|
71
|
-
applies to the wrong lines, and nothing anywhere reports it.
|
|
72
|
-
|
|
73
|
-
FNV-1a over the whole string, with the length appended. Not a security
|
|
74
|
-
hash and does not need to be -- it is guarding against ordinary concurrent
|
|
75
|
-
editing, not against someone constructing a collision. The length is there
|
|
76
|
-
because it is free and rules out the whole class of same-length accidents.
|
|
77
|
-
|
|
78
|
-
The multiply is split into 16-bit halves on purpose. `hash * 16777619` with
|
|
79
|
-
a 32-bit hash reaches 2^56, past the 2^53 where doubles stop being exact,
|
|
80
|
-
so the low bits -- the ones that carry the mixing -- would be quietly
|
|
81
|
-
rounded away.
|
|
82
|
-
]]
|
|
83
|
-
local function fingerprint(source: string): string
|
|
84
|
-
local hash = 2166136261
|
|
85
|
-
local length = #source
|
|
86
|
-
local index = 1
|
|
87
|
-
while index <= length do
|
|
88
|
-
local last = math.min(index + 511, length)
|
|
89
|
-
local chunk = { string.byte(source, index, last) }
|
|
90
|
-
for _, byte in chunk do
|
|
91
|
-
hash = bit32.bxor(hash, byte)
|
|
92
|
-
local low = bit32.band(hash, 0xFFFF)
|
|
93
|
-
local high = bit32.rshift(hash, 16)
|
|
94
|
-
-- 16777619 == 0x01000193, so 0x0193 is 403 and 0x0100 is 256.
|
|
95
|
-
hash = bit32.band(low * 403 + bit32.lshift(bit32.band(high * 403 + low * 256, 0xFFFF), 16), 0xFFFFFFFF)
|
|
96
|
-
end
|
|
97
|
-
index = last + 1
|
|
98
|
-
end
|
|
99
|
-
return string.format("%08x-%x", hash, length)
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
--[[
|
|
103
|
-
Whether a script is really the source of truth, or a copy of a file on disk.
|
|
104
|
-
|
|
105
|
-
Studio can bind a script to a file outside it -- the external-editor
|
|
106
|
-
workflows, and the Auto-Reimport beta. When that binding exists, whatever is
|
|
107
|
-
written here is overwritten the next time the file changes, and the write
|
|
108
|
-
looks like it worked right up until it silently does not. That failure is
|
|
109
|
-
invisible from inside the data model: the script has a Source property like
|
|
110
|
-
any other.
|
|
111
|
-
|
|
112
|
-
`InstanceFileSyncService` knows, and says so at plugin identity. It is read
|
|
113
|
-
only -- there is no call here that starts, stops or redirects a sync -- so
|
|
114
|
-
the worst this can do is add a sentence to a reply.
|
|
115
|
-
|
|
116
|
-
Guarded end to end, and deliberately silent when unavailable: a Studio
|
|
117
|
-
without the service, or a place with no sync set up, should cost this note
|
|
118
|
-
and nothing else.
|
|
119
|
-
]]
|
|
120
|
-
local function syncedFile(target: Instance): string?
|
|
121
|
-
local service = game:FindService("InstanceFileSyncService")
|
|
122
|
-
if service == nil then
|
|
123
|
-
return nil
|
|
124
|
-
end
|
|
125
|
-
local ok, status = pcall(function()
|
|
126
|
-
return (service :: any):GetStatus(target)
|
|
127
|
-
end)
|
|
128
|
-
if not ok or status == nil then
|
|
129
|
-
return nil
|
|
130
|
-
end
|
|
131
|
-
local name = tostring(status):gsub("Enum%.InstanceFileSyncStatus%.", "")
|
|
132
|
-
-- "NotSynced" is the ordinary case and saying it every time would be noise.
|
|
133
|
-
if name == "NotSynced" or name == "Unknown" or name == "nil" then
|
|
134
|
-
return nil
|
|
135
|
-
end
|
|
136
|
-
return name
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
function Scripts.read(params: { [string]: any }): { [string]: any }
|
|
140
|
-
local paths = params.paths
|
|
141
|
-
if typeof(paths) ~= "table" or #paths == 0 then
|
|
142
|
-
Dispatch.fail(
|
|
143
|
-
"BAD_PARAMS",
|
|
144
|
-
"script_read requires a non-empty `paths` array.",
|
|
145
|
-
"Use `find` with className LuaSourceContainer to locate scripts."
|
|
146
|
-
)
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
local items: { { [string]: any } } = {}
|
|
150
|
-
local failures: { string } = {}
|
|
151
|
-
local memo: Paths.NameIndex = {}
|
|
152
|
-
|
|
153
|
-
--[[
|
|
154
|
-
Each entry may carry its own line window, because the shape that actually
|
|
155
|
-
comes up is "line 40 of this one, line 300 of that one" -- and a single
|
|
156
|
-
range shared across the whole batch forced one call per script, which is
|
|
157
|
-
what batching this tool was for in the first place.
|
|
158
|
-
|
|
159
|
-
A plain string still means the whole file, or the batch-wide range when
|
|
160
|
-
one was given.
|
|
161
|
-
]]
|
|
162
|
-
for _, entry in paths do
|
|
163
|
-
local windowed = typeof(entry) == "table"
|
|
164
|
-
local path = if windowed then entry.path else entry
|
|
165
|
-
local ok, resolved = pcall(resolveScript, path)
|
|
166
|
-
if not ok then
|
|
167
|
-
local err = resolved :: any
|
|
168
|
-
local reason = if typeof(err) == "table"
|
|
169
|
-
then (if err.hint then err.message .. " " .. err.hint else err.message)
|
|
170
|
-
else tostring(err)
|
|
171
|
-
table.insert(failures, string.format("%s: %s", tostring(path), reason))
|
|
172
|
-
continue
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
local target = resolved :: LuaSourceContainer
|
|
176
|
-
local whole = ScriptEdit.read(target)
|
|
177
|
-
local lines = TextEdit.toLines(whole)
|
|
178
|
-
local askedStart = if windowed and entry.startLine ~= nil
|
|
179
|
-
then tonumber(entry.startLine)
|
|
180
|
-
else tonumber(params.startLine)
|
|
181
|
-
local askedEnd = if windowed and entry.endLine ~= nil
|
|
182
|
-
then tonumber(entry.endLine)
|
|
183
|
-
else tonumber(params.endLine)
|
|
184
|
-
local startLine = math.max(askedStart or 1, 1)
|
|
185
|
-
local endLine = math.min(askedEnd or #lines, #lines)
|
|
186
|
-
|
|
187
|
-
local window: { string } = {}
|
|
188
|
-
table.move(lines, startLine, endLine, 1, window)
|
|
189
|
-
|
|
190
|
-
table.insert(items, {
|
|
191
|
-
path = Paths.of(target, memo),
|
|
192
|
-
className = target.ClassName,
|
|
193
|
-
lineCount = #lines,
|
|
194
|
-
startLine = startLine,
|
|
195
|
-
endLine = askedEnd,
|
|
196
|
-
source = table.concat(window, "\n"),
|
|
197
|
-
-- Of the whole file, never of the window: `edit` compares it against
|
|
198
|
-
-- the live source, and a fingerprint of forty lines out of four
|
|
199
|
-
-- hundred would call an edit safe that it is not.
|
|
200
|
-
revision = fingerprint(whole),
|
|
201
|
-
-- Absent unless the script is bound to a file on disk, in which case
|
|
202
|
-
-- editing it here is a race against whatever writes that file.
|
|
203
|
-
fileSync = syncedFile(target),
|
|
204
|
-
})
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
return { items = items, failures = failures }
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
--[[
|
|
211
|
-
Applies every edit in a batch, or none of them.
|
|
212
|
-
|
|
213
|
-
Atomicity here cannot come from ChangeHistoryService. A recording captures
|
|
214
|
-
instance changes, but `UpdateSourceAsync` goes through the script editor's own
|
|
215
|
-
per-document history, so cancelling a recording leaves an already-written
|
|
216
|
-
script edited -- measured, not assumed. Wrapping this in `Undo.record` would
|
|
217
|
-
therefore promise a rollback that never happens.
|
|
218
|
-
|
|
219
|
-
So the batch is a two-phase commit instead. Phase one reads and transforms
|
|
220
|
-
every script without writing anything, which is where essentially all failures
|
|
221
|
-
live: a missing `find`, an ambiguous one, a bad line range, conflicting edits.
|
|
222
|
-
Phase two writes the finished text. If a write fails there -- realistically
|
|
223
|
-
only a locked or package-owned script -- the scripts already written are
|
|
224
|
-
restored from the source captured in phase one.
|
|
225
|
-
]]
|
|
226
|
-
function Scripts.edit(params: { [string]: any }): { [string]: any }
|
|
227
|
-
local edits = params.edits
|
|
228
|
-
if typeof(edits) ~= "table" or #edits == 0 then
|
|
229
|
-
Dispatch.fail(
|
|
230
|
-
"BAD_PARAMS",
|
|
231
|
-
"script_edit requires a non-empty `edits` array.",
|
|
232
|
-
"Each edit needs a `path` plus one of `find`/`replace`, "
|
|
233
|
-
.. "`startLine`/`replacement`, or `source`."
|
|
234
|
-
)
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
local order: { LuaSourceContainer } = {}
|
|
238
|
-
local grouped: { [Instance]: { TextEdit.Edit } } = {}
|
|
239
|
-
for position, edit in edits do
|
|
240
|
-
TextEdit.validate(edit, position)
|
|
241
|
-
local target = resolveScript(edit.path)
|
|
242
|
-
local bucket = grouped[target]
|
|
243
|
-
if not bucket then
|
|
244
|
-
bucket = {}
|
|
245
|
-
grouped[target] = bucket
|
|
246
|
-
table.insert(order, target)
|
|
247
|
-
end
|
|
248
|
-
table.insert(bucket :: { TextEdit.Edit }, edit)
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
-- Phase one: transform everything in memory. Any failure raises here, with
|
|
252
|
-
-- nothing written and the place untouched.
|
|
253
|
-
type Pending = { target: LuaSourceContainer, before: string, after: string }
|
|
254
|
-
local pending: { Pending } = {}
|
|
255
|
-
for _, target in order do
|
|
256
|
-
local before = ScriptEdit.read(target)
|
|
257
|
-
--[[
|
|
258
|
-
Refuse before transforming, not after.
|
|
259
|
-
|
|
260
|
-
An edit that names the revision it was written against is asking to
|
|
261
|
-
be applied to that exact text. If the file has moved on, the honest
|
|
262
|
-
answer is to stop: a line range still applies cleanly to changed
|
|
263
|
-
source, it just lands on the wrong lines, and a `source` edit throws
|
|
264
|
-
away everything written since it was read. Both look like success.
|
|
265
|
-
|
|
266
|
-
Checked here so the whole batch fails with nothing written, which is
|
|
267
|
-
the promise every other phase-one failure already makes.
|
|
268
|
-
]]
|
|
269
|
-
local live: string? = nil
|
|
270
|
-
for _, edit in grouped[target] :: { TextEdit.Edit } do
|
|
271
|
-
local stated = (edit :: any).revision
|
|
272
|
-
if typeof(stated) ~= "string" or stated == "" then
|
|
273
|
-
continue
|
|
274
|
-
end
|
|
275
|
-
live = live or fingerprint(before)
|
|
276
|
-
if stated ~= live then
|
|
277
|
-
Dispatch.fail(
|
|
278
|
-
"STALE_SCRIPT",
|
|
279
|
-
string.format(
|
|
280
|
-
"%s changed since it was read (expected %s, found %s).",
|
|
281
|
-
target:GetFullName(),
|
|
282
|
-
stated,
|
|
283
|
-
live :: string
|
|
284
|
-
),
|
|
285
|
-
"Somebody else edited it -- another agent, or the user typing in the "
|
|
286
|
-
.. "editor. Read it again with script_read and rebuild the edit "
|
|
287
|
-
.. "against what is there now."
|
|
288
|
-
)
|
|
289
|
-
end
|
|
290
|
-
end
|
|
291
|
-
|
|
292
|
-
local after = TextEdit.apply(target:GetFullName(), before, grouped[target] :: { TextEdit.Edit })
|
|
293
|
-
table.insert(pending, { target = target, before = before, after = after })
|
|
294
|
-
end
|
|
295
|
-
|
|
296
|
-
-- Phase two: write. `written` is the compensation log for a mid-batch failure.
|
|
297
|
-
local written: { Pending } = {}
|
|
298
|
-
local memo: Paths.NameIndex = {}
|
|
299
|
-
local results: { { [string]: any } } = {}
|
|
300
|
-
|
|
301
|
-
for _, entry in pending do
|
|
302
|
-
-- Wrapped in a closure rather than passed to pcall directly: `write`
|
|
303
|
-
-- returns nothing, and pcall's typed signature expects a value back.
|
|
304
|
-
local ok, err = pcall(function()
|
|
305
|
-
ScriptEdit.write(entry.target, function()
|
|
306
|
-
return entry.after
|
|
307
|
-
end)
|
|
308
|
-
end)
|
|
309
|
-
|
|
310
|
-
if not ok then
|
|
311
|
-
for index = #written, 1, -1 do
|
|
312
|
-
local done = written[index]
|
|
313
|
-
-- Best effort: a restore that fails leaves that script edited, and
|
|
314
|
-
-- the original error still describes what actually went wrong.
|
|
315
|
-
pcall(function()
|
|
316
|
-
ScriptEdit.write(done.target, function()
|
|
317
|
-
return done.before
|
|
318
|
-
end)
|
|
319
|
-
end)
|
|
320
|
-
end
|
|
321
|
-
error(err, 0)
|
|
322
|
-
end
|
|
323
|
-
|
|
324
|
-
table.insert(written, entry)
|
|
325
|
-
table.insert(results, {
|
|
326
|
-
path = Paths.of(entry.target, memo),
|
|
327
|
-
className = entry.target.ClassName,
|
|
328
|
-
edits = #(grouped[entry.target] :: { TextEdit.Edit }),
|
|
329
|
-
lineCount = #TextEdit.toLines(entry.after),
|
|
330
|
-
lineDelta = #TextEdit.toLines(entry.after) - #TextEdit.toLines(entry.before),
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
local
|
|
353
|
-
local
|
|
354
|
-
local
|
|
355
|
-
|
|
356
|
-
local
|
|
357
|
-
|
|
358
|
-
local
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
if
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
if
|
|
411
|
-
|
|
412
|
-
end
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
path =
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
"
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
if typeof(request.
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
is
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
walk
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
.. "
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
.. "
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
local
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Script reading, searching, editing and creation.
|
|
4
|
+
|
|
5
|
+
Everything here that writes goes through `ScriptEdit`, which routes the change
|
|
6
|
+
through `ScriptEditorService:UpdateSourceAsync` rather than assigning
|
|
7
|
+
`script.Source`. That is the difference between an edit the Studio editor
|
|
8
|
+
agrees with and one that silently loses whatever the user had typed but not
|
|
9
|
+
saved. Reads use the editor buffer for the same reason: handing an agent stale
|
|
10
|
+
source makes it "fix" changes the user just made.
|
|
11
|
+
|
|
12
|
+
Edits are also wrapped in a single `Undo` recording, so a batch across ten
|
|
13
|
+
scripts is one Ctrl+Z, and a batch that fails half way is rolled back rather
|
|
14
|
+
than left half applied.
|
|
15
|
+
|
|
16
|
+
The text manipulation itself lives in `TextEdit`, which has no Roblox
|
|
17
|
+
dependencies and is unit tested.
|
|
18
|
+
]]
|
|
19
|
+
|
|
20
|
+
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
21
|
+
local Paths = require(script.Parent.Parent.Paths)
|
|
22
|
+
local Scope = require(script.Parent.Parent.Scope)
|
|
23
|
+
local ScriptEdit = require(script.Parent.Parent.ScriptEdit)
|
|
24
|
+
local TextEdit = require(script.Parent.Parent.TextEdit)
|
|
25
|
+
local Undo = require(script.Parent.Parent.Undo)
|
|
26
|
+
|
|
27
|
+
-- Reading every script's editor buffer is a service call each. A place with more
|
|
28
|
+
-- scripts than this is better served by narrowing `path` than by a slow grep
|
|
29
|
+
-- that blocks Studio's main thread.
|
|
30
|
+
local MAX_SCRIPTS = 3_000
|
|
31
|
+
local MAX_MATCHES = 500
|
|
32
|
+
local DEFAULT_CONTEXT = 0
|
|
33
|
+
|
|
34
|
+
local CREATABLE = {
|
|
35
|
+
Script = true,
|
|
36
|
+
LocalScript = true,
|
|
37
|
+
ModuleScript = true,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
local Scripts = {}
|
|
41
|
+
|
|
42
|
+
--[[
|
|
43
|
+
Resolves a path and insists it holds Luau. Pointing a script tool at an
|
|
44
|
+
ordinary instance otherwise fails later with a confusing property error.
|
|
45
|
+
]]
|
|
46
|
+
local function resolveScript(path: string): LuaSourceContainer
|
|
47
|
+
local instance = Paths.resolve(path)
|
|
48
|
+
if not ScriptEdit.isScript(instance) then
|
|
49
|
+
Dispatch.fail(
|
|
50
|
+
"NOT_A_SCRIPT",
|
|
51
|
+
string.format('"%s" is a %s, not a script.', path, instance.ClassName),
|
|
52
|
+
"Script tools accept Script, LocalScript and ModuleScript. Use `inspect` "
|
|
53
|
+
.. "for other instances, or `find` with className LuaSourceContainer to locate scripts."
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
return instance :: LuaSourceContainer
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
--[[
|
|
60
|
+
Reads source, optionally a line window. `startLine`/`endLine` are 1-based and
|
|
61
|
+
inclusive, matching the numbers script_edit takes back, so a read and a write
|
|
62
|
+
need no off-by-one conversion between them.
|
|
63
|
+
]]
|
|
64
|
+
--[[
|
|
65
|
+
A short fingerprint of a script's source, for detecting that it moved.
|
|
66
|
+
|
|
67
|
+
Handed out by `read` and passed back to `edit`, which refuses to write when
|
|
68
|
+
the live source no longer matches. That is the only thing standing between
|
|
69
|
+
two agents on one place and a silent overwrite: a line-range edit computed
|
|
70
|
+
against source somebody has since changed still applies cleanly, it just
|
|
71
|
+
applies to the wrong lines, and nothing anywhere reports it.
|
|
72
|
+
|
|
73
|
+
FNV-1a over the whole string, with the length appended. Not a security
|
|
74
|
+
hash and does not need to be -- it is guarding against ordinary concurrent
|
|
75
|
+
editing, not against someone constructing a collision. The length is there
|
|
76
|
+
because it is free and rules out the whole class of same-length accidents.
|
|
77
|
+
|
|
78
|
+
The multiply is split into 16-bit halves on purpose. `hash * 16777619` with
|
|
79
|
+
a 32-bit hash reaches 2^56, past the 2^53 where doubles stop being exact,
|
|
80
|
+
so the low bits -- the ones that carry the mixing -- would be quietly
|
|
81
|
+
rounded away.
|
|
82
|
+
]]
|
|
83
|
+
local function fingerprint(source: string): string
|
|
84
|
+
local hash = 2166136261
|
|
85
|
+
local length = #source
|
|
86
|
+
local index = 1
|
|
87
|
+
while index <= length do
|
|
88
|
+
local last = math.min(index + 511, length)
|
|
89
|
+
local chunk = { string.byte(source, index, last) }
|
|
90
|
+
for _, byte in chunk do
|
|
91
|
+
hash = bit32.bxor(hash, byte)
|
|
92
|
+
local low = bit32.band(hash, 0xFFFF)
|
|
93
|
+
local high = bit32.rshift(hash, 16)
|
|
94
|
+
-- 16777619 == 0x01000193, so 0x0193 is 403 and 0x0100 is 256.
|
|
95
|
+
hash = bit32.band(low * 403 + bit32.lshift(bit32.band(high * 403 + low * 256, 0xFFFF), 16), 0xFFFFFFFF)
|
|
96
|
+
end
|
|
97
|
+
index = last + 1
|
|
98
|
+
end
|
|
99
|
+
return string.format("%08x-%x", hash, length)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
--[[
|
|
103
|
+
Whether a script is really the source of truth, or a copy of a file on disk.
|
|
104
|
+
|
|
105
|
+
Studio can bind a script to a file outside it -- the external-editor
|
|
106
|
+
workflows, and the Auto-Reimport beta. When that binding exists, whatever is
|
|
107
|
+
written here is overwritten the next time the file changes, and the write
|
|
108
|
+
looks like it worked right up until it silently does not. That failure is
|
|
109
|
+
invisible from inside the data model: the script has a Source property like
|
|
110
|
+
any other.
|
|
111
|
+
|
|
112
|
+
`InstanceFileSyncService` knows, and says so at plugin identity. It is read
|
|
113
|
+
only -- there is no call here that starts, stops or redirects a sync -- so
|
|
114
|
+
the worst this can do is add a sentence to a reply.
|
|
115
|
+
|
|
116
|
+
Guarded end to end, and deliberately silent when unavailable: a Studio
|
|
117
|
+
without the service, or a place with no sync set up, should cost this note
|
|
118
|
+
and nothing else.
|
|
119
|
+
]]
|
|
120
|
+
local function syncedFile(target: Instance): string?
|
|
121
|
+
local service = game:FindService("InstanceFileSyncService")
|
|
122
|
+
if service == nil then
|
|
123
|
+
return nil
|
|
124
|
+
end
|
|
125
|
+
local ok, status = pcall(function()
|
|
126
|
+
return (service :: any):GetStatus(target)
|
|
127
|
+
end)
|
|
128
|
+
if not ok or status == nil then
|
|
129
|
+
return nil
|
|
130
|
+
end
|
|
131
|
+
local name = tostring(status):gsub("Enum%.InstanceFileSyncStatus%.", "")
|
|
132
|
+
-- "NotSynced" is the ordinary case and saying it every time would be noise.
|
|
133
|
+
if name == "NotSynced" or name == "Unknown" or name == "nil" then
|
|
134
|
+
return nil
|
|
135
|
+
end
|
|
136
|
+
return name
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
function Scripts.read(params: { [string]: any }): { [string]: any }
|
|
140
|
+
local paths = params.paths
|
|
141
|
+
if typeof(paths) ~= "table" or #paths == 0 then
|
|
142
|
+
Dispatch.fail(
|
|
143
|
+
"BAD_PARAMS",
|
|
144
|
+
"script_read requires a non-empty `paths` array.",
|
|
145
|
+
"Use `find` with className LuaSourceContainer to locate scripts."
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
local items: { { [string]: any } } = {}
|
|
150
|
+
local failures: { string } = {}
|
|
151
|
+
local memo: Paths.NameIndex = {}
|
|
152
|
+
|
|
153
|
+
--[[
|
|
154
|
+
Each entry may carry its own line window, because the shape that actually
|
|
155
|
+
comes up is "line 40 of this one, line 300 of that one" -- and a single
|
|
156
|
+
range shared across the whole batch forced one call per script, which is
|
|
157
|
+
what batching this tool was for in the first place.
|
|
158
|
+
|
|
159
|
+
A plain string still means the whole file, or the batch-wide range when
|
|
160
|
+
one was given.
|
|
161
|
+
]]
|
|
162
|
+
for _, entry in paths do
|
|
163
|
+
local windowed = typeof(entry) == "table"
|
|
164
|
+
local path = if windowed then entry.path else entry
|
|
165
|
+
local ok, resolved = pcall(resolveScript, path)
|
|
166
|
+
if not ok then
|
|
167
|
+
local err = resolved :: any
|
|
168
|
+
local reason = if typeof(err) == "table"
|
|
169
|
+
then (if err.hint then err.message .. " " .. err.hint else err.message)
|
|
170
|
+
else tostring(err)
|
|
171
|
+
table.insert(failures, string.format("%s: %s", tostring(path), reason))
|
|
172
|
+
continue
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
local target = resolved :: LuaSourceContainer
|
|
176
|
+
local whole = ScriptEdit.read(target)
|
|
177
|
+
local lines = TextEdit.toLines(whole)
|
|
178
|
+
local askedStart = if windowed and entry.startLine ~= nil
|
|
179
|
+
then tonumber(entry.startLine)
|
|
180
|
+
else tonumber(params.startLine)
|
|
181
|
+
local askedEnd = if windowed and entry.endLine ~= nil
|
|
182
|
+
then tonumber(entry.endLine)
|
|
183
|
+
else tonumber(params.endLine)
|
|
184
|
+
local startLine = math.max(askedStart or 1, 1)
|
|
185
|
+
local endLine = math.min(askedEnd or #lines, #lines)
|
|
186
|
+
|
|
187
|
+
local window: { string } = {}
|
|
188
|
+
table.move(lines, startLine, endLine, 1, window)
|
|
189
|
+
|
|
190
|
+
table.insert(items, {
|
|
191
|
+
path = Paths.of(target, memo),
|
|
192
|
+
className = target.ClassName,
|
|
193
|
+
lineCount = #lines,
|
|
194
|
+
startLine = startLine,
|
|
195
|
+
endLine = askedEnd,
|
|
196
|
+
source = table.concat(window, "\n"),
|
|
197
|
+
-- Of the whole file, never of the window: `edit` compares it against
|
|
198
|
+
-- the live source, and a fingerprint of forty lines out of four
|
|
199
|
+
-- hundred would call an edit safe that it is not.
|
|
200
|
+
revision = fingerprint(whole),
|
|
201
|
+
-- Absent unless the script is bound to a file on disk, in which case
|
|
202
|
+
-- editing it here is a race against whatever writes that file.
|
|
203
|
+
fileSync = syncedFile(target),
|
|
204
|
+
})
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
return { items = items, failures = failures }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
--[[
|
|
211
|
+
Applies every edit in a batch, or none of them.
|
|
212
|
+
|
|
213
|
+
Atomicity here cannot come from ChangeHistoryService. A recording captures
|
|
214
|
+
instance changes, but `UpdateSourceAsync` goes through the script editor's own
|
|
215
|
+
per-document history, so cancelling a recording leaves an already-written
|
|
216
|
+
script edited -- measured, not assumed. Wrapping this in `Undo.record` would
|
|
217
|
+
therefore promise a rollback that never happens.
|
|
218
|
+
|
|
219
|
+
So the batch is a two-phase commit instead. Phase one reads and transforms
|
|
220
|
+
every script without writing anything, which is where essentially all failures
|
|
221
|
+
live: a missing `find`, an ambiguous one, a bad line range, conflicting edits.
|
|
222
|
+
Phase two writes the finished text. If a write fails there -- realistically
|
|
223
|
+
only a locked or package-owned script -- the scripts already written are
|
|
224
|
+
restored from the source captured in phase one.
|
|
225
|
+
]]
|
|
226
|
+
function Scripts.edit(params: { [string]: any }): { [string]: any }
|
|
227
|
+
local edits = params.edits
|
|
228
|
+
if typeof(edits) ~= "table" or #edits == 0 then
|
|
229
|
+
Dispatch.fail(
|
|
230
|
+
"BAD_PARAMS",
|
|
231
|
+
"script_edit requires a non-empty `edits` array.",
|
|
232
|
+
"Each edit needs a `path` plus one of `find`/`replace`, "
|
|
233
|
+
.. "`startLine`/`replacement`, or `source`."
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
local order: { LuaSourceContainer } = {}
|
|
238
|
+
local grouped: { [Instance]: { TextEdit.Edit } } = {}
|
|
239
|
+
for position, edit in edits do
|
|
240
|
+
TextEdit.validate(edit, position)
|
|
241
|
+
local target = resolveScript(edit.path)
|
|
242
|
+
local bucket = grouped[target]
|
|
243
|
+
if not bucket then
|
|
244
|
+
bucket = {}
|
|
245
|
+
grouped[target] = bucket
|
|
246
|
+
table.insert(order, target)
|
|
247
|
+
end
|
|
248
|
+
table.insert(bucket :: { TextEdit.Edit }, edit)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
-- Phase one: transform everything in memory. Any failure raises here, with
|
|
252
|
+
-- nothing written and the place untouched.
|
|
253
|
+
type Pending = { target: LuaSourceContainer, before: string, after: string }
|
|
254
|
+
local pending: { Pending } = {}
|
|
255
|
+
for _, target in order do
|
|
256
|
+
local before = ScriptEdit.read(target)
|
|
257
|
+
--[[
|
|
258
|
+
Refuse before transforming, not after.
|
|
259
|
+
|
|
260
|
+
An edit that names the revision it was written against is asking to
|
|
261
|
+
be applied to that exact text. If the file has moved on, the honest
|
|
262
|
+
answer is to stop: a line range still applies cleanly to changed
|
|
263
|
+
source, it just lands on the wrong lines, and a `source` edit throws
|
|
264
|
+
away everything written since it was read. Both look like success.
|
|
265
|
+
|
|
266
|
+
Checked here so the whole batch fails with nothing written, which is
|
|
267
|
+
the promise every other phase-one failure already makes.
|
|
268
|
+
]]
|
|
269
|
+
local live: string? = nil
|
|
270
|
+
for _, edit in grouped[target] :: { TextEdit.Edit } do
|
|
271
|
+
local stated = (edit :: any).revision
|
|
272
|
+
if typeof(stated) ~= "string" or stated == "" then
|
|
273
|
+
continue
|
|
274
|
+
end
|
|
275
|
+
live = live or fingerprint(before)
|
|
276
|
+
if stated ~= live then
|
|
277
|
+
Dispatch.fail(
|
|
278
|
+
"STALE_SCRIPT",
|
|
279
|
+
string.format(
|
|
280
|
+
"%s changed since it was read (expected %s, found %s).",
|
|
281
|
+
target:GetFullName(),
|
|
282
|
+
stated,
|
|
283
|
+
live :: string
|
|
284
|
+
),
|
|
285
|
+
"Somebody else edited it -- another agent, or the user typing in the "
|
|
286
|
+
.. "editor. Read it again with script_read and rebuild the edit "
|
|
287
|
+
.. "against what is there now."
|
|
288
|
+
)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
local after = TextEdit.apply(target:GetFullName(), before, grouped[target] :: { TextEdit.Edit })
|
|
293
|
+
table.insert(pending, { target = target, before = before, after = after })
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
-- Phase two: write. `written` is the compensation log for a mid-batch failure.
|
|
297
|
+
local written: { Pending } = {}
|
|
298
|
+
local memo: Paths.NameIndex = {}
|
|
299
|
+
local results: { { [string]: any } } = {}
|
|
300
|
+
|
|
301
|
+
for _, entry in pending do
|
|
302
|
+
-- Wrapped in a closure rather than passed to pcall directly: `write`
|
|
303
|
+
-- returns nothing, and pcall's typed signature expects a value back.
|
|
304
|
+
local ok, err = pcall(function()
|
|
305
|
+
ScriptEdit.write(entry.target, function()
|
|
306
|
+
return entry.after
|
|
307
|
+
end)
|
|
308
|
+
end)
|
|
309
|
+
|
|
310
|
+
if not ok then
|
|
311
|
+
for index = #written, 1, -1 do
|
|
312
|
+
local done = written[index]
|
|
313
|
+
-- Best effort: a restore that fails leaves that script edited, and
|
|
314
|
+
-- the original error still describes what actually went wrong.
|
|
315
|
+
pcall(function()
|
|
316
|
+
ScriptEdit.write(done.target, function()
|
|
317
|
+
return done.before
|
|
318
|
+
end)
|
|
319
|
+
end)
|
|
320
|
+
end
|
|
321
|
+
error(err, 0)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
table.insert(written, entry)
|
|
325
|
+
table.insert(results, {
|
|
326
|
+
path = Paths.of(entry.target, memo),
|
|
327
|
+
className = entry.target.ClassName,
|
|
328
|
+
edits = #(grouped[entry.target] :: { TextEdit.Edit }),
|
|
329
|
+
lineCount = #TextEdit.toLines(entry.after),
|
|
330
|
+
lineDelta = #TextEdit.toLines(entry.after) - #TextEdit.toLines(entry.before),
|
|
331
|
+
-- Of what is there now, not of what was asked for: the editor can
|
|
332
|
+
-- normalise the text it is handed, and a rev that does not match the
|
|
333
|
+
-- live buffer would refuse the very next edit as stale.
|
|
334
|
+
rev = fingerprint(ScriptEdit.read(entry.target)),
|
|
335
|
+
})
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
return { items = results }
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
--[[
|
|
342
|
+
Searches script source. Matches come from the editor buffer, so text the user
|
|
343
|
+
has typed but not saved is found too -- which is the state an agent about to
|
|
344
|
+
edit the file actually needs to see.
|
|
345
|
+
]]
|
|
346
|
+
function Scripts.grep(params: { [string]: any }): { [string]: any }
|
|
347
|
+
local pattern = params.pattern
|
|
348
|
+
if typeof(pattern) ~= "string" or pattern == "" then
|
|
349
|
+
Dispatch.fail("BAD_PARAMS", "script_grep requires a `pattern`.")
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
local root = if params.path then Paths.resolve(params.path) else game
|
|
353
|
+
local literal = params.literal == true
|
|
354
|
+
local ignoreCase = params.ignoreCase == true
|
|
355
|
+
local contextLines = math.clamp(tonumber(params.contextLines) or DEFAULT_CONTEXT, 0, 10)
|
|
356
|
+
local limit = math.min(tonumber(params.limit) or 100, MAX_MATCHES)
|
|
357
|
+
local offset = tonumber(params.offset) or 0
|
|
358
|
+
local classFilter = params.className
|
|
359
|
+
|
|
360
|
+
local needle = if ignoreCase then string.lower(pattern) else pattern
|
|
361
|
+
|
|
362
|
+
local targets: { LuaSourceContainer } = {}
|
|
363
|
+
for _, instance in root:GetDescendants() do
|
|
364
|
+
if not instance:IsA("LuaSourceContainer") then
|
|
365
|
+
continue
|
|
366
|
+
end
|
|
367
|
+
if root == game and Scope.isNoisy(instance) then
|
|
368
|
+
continue
|
|
369
|
+
end
|
|
370
|
+
if classFilter and not instance:IsA(classFilter) then
|
|
371
|
+
continue
|
|
372
|
+
end
|
|
373
|
+
table.insert(targets, instance)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
if #targets > MAX_SCRIPTS then
|
|
377
|
+
Dispatch.fail(
|
|
378
|
+
"TOO_BROAD",
|
|
379
|
+
string.format("That search covers %d scripts, over the %d limit.", #targets, MAX_SCRIPTS),
|
|
380
|
+
"Narrow it with `path` to search one service or folder instead of the whole place."
|
|
381
|
+
)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
local matches: { { [string]: any } } = {}
|
|
385
|
+
local total = 0
|
|
386
|
+
local memo: Paths.NameIndex = {}
|
|
387
|
+
|
|
388
|
+
for _, target in targets do
|
|
389
|
+
local lines = TextEdit.toLines(ScriptEdit.read(target))
|
|
390
|
+
local path: string? = nil
|
|
391
|
+
|
|
392
|
+
for number, line in lines do
|
|
393
|
+
local haystack = if ignoreCase then string.lower(line) else line
|
|
394
|
+
-- An invalid Lua pattern raises rather than simply not matching, so it
|
|
395
|
+
-- has to be caught and reported as a pattern problem, not a no-match.
|
|
396
|
+
local ok, from = pcall(string.find, haystack, needle, 1, literal)
|
|
397
|
+
if not ok then
|
|
398
|
+
Dispatch.fail(
|
|
399
|
+
"BAD_PATTERN",
|
|
400
|
+
string.format("%s is not a valid Lua pattern: %s", pattern, tostring(from)),
|
|
401
|
+
"Lua patterns escape with %, not backslash, and have no alternation. "
|
|
402
|
+
.. "Set `literal` to search for the text exactly as written."
|
|
403
|
+
)
|
|
404
|
+
end
|
|
405
|
+
if not from then
|
|
406
|
+
continue
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
total += 1
|
|
410
|
+
if total <= offset or #matches >= limit then
|
|
411
|
+
continue
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
if not path then
|
|
415
|
+
path = Paths.of(target, memo)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
local entry: { [string]: any } = {
|
|
419
|
+
path = path,
|
|
420
|
+
line = number,
|
|
421
|
+
text = line,
|
|
422
|
+
}
|
|
423
|
+
if contextLines > 0 then
|
|
424
|
+
local before: { string } = {}
|
|
425
|
+
local after: { string } = {}
|
|
426
|
+
table.move(lines, math.max(number - contextLines, 1), number - 1, 1, before)
|
|
427
|
+
table.move(lines, number + 1, math.min(number + contextLines, #lines), 1, after)
|
|
428
|
+
entry.before = before
|
|
429
|
+
entry.after = after
|
|
430
|
+
end
|
|
431
|
+
table.insert(matches, entry)
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
items = matches,
|
|
437
|
+
total = total,
|
|
438
|
+
offset = offset,
|
|
439
|
+
searched = #targets,
|
|
440
|
+
}
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
--[[
|
|
444
|
+
Creates scripts. Source is assigned directly here rather than through
|
|
445
|
+
`UpdateSourceAsync`: the instance does not exist yet, so nothing can have it
|
|
446
|
+
open in the editor and there is no buffer to conflict with. Every later edit
|
|
447
|
+
goes through the editor path.
|
|
448
|
+
]]
|
|
449
|
+
--[[
|
|
450
|
+
Names the starter container a script was just parented into, or nil.
|
|
451
|
+
|
|
452
|
+
These four are copied into the player rather than run where they sit, so a
|
|
453
|
+
`Script` with a non-Legacy RunContext inside one runs BOTH in the original
|
|
454
|
+
and in every copy. Roblox does warn about it -- "will cause it to run
|
|
455
|
+
multiple times" -- but that warning is emitted by Studio itself and never
|
|
456
|
+
reaches `console`, so an agent following the "prefer Script with runContext
|
|
457
|
+
Client over LocalScript" advice writes a double-running script and is given
|
|
458
|
+
no way to find out.
|
|
459
|
+
]]
|
|
460
|
+
local STARTER_CONTAINERS = {
|
|
461
|
+
"StarterGui",
|
|
462
|
+
"StarterPack",
|
|
463
|
+
"StarterPlayerScripts",
|
|
464
|
+
"StarterCharacterScripts",
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
local function starterContainer(instance: Instance): string?
|
|
468
|
+
for _, className in STARTER_CONTAINERS do
|
|
469
|
+
if instance:FindFirstAncestorOfClass(className :: any) then
|
|
470
|
+
return className
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
return nil
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
function Scripts.create(params: { [string]: any }): { [string]: any }
|
|
477
|
+
local requests = params.scripts
|
|
478
|
+
if typeof(requests) ~= "table" or #requests == 0 then
|
|
479
|
+
Dispatch.fail(
|
|
480
|
+
"BAD_PARAMS",
|
|
481
|
+
"script_create requires a non-empty `scripts` array.",
|
|
482
|
+
"Each entry needs `parent`, `name` and `className`."
|
|
483
|
+
)
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
for position, request in requests do
|
|
487
|
+
if typeof(request.name) ~= "string" or request.name == "" then
|
|
488
|
+
Dispatch.fail("BAD_PARAMS", string.format("scripts[%d] has no `name`.", position - 1))
|
|
489
|
+
end
|
|
490
|
+
if not CREATABLE[request.className] then
|
|
491
|
+
Dispatch.fail(
|
|
492
|
+
"BAD_PARAMS",
|
|
493
|
+
string.format('scripts[%d] has className "%s".', position - 1, tostring(request.className)),
|
|
494
|
+
"Use Script, LocalScript or ModuleScript. Prefer a Script with "
|
|
495
|
+
.. "runContext Client over LocalScript in new work -- except inside "
|
|
496
|
+
.. "StarterGui, StarterPack, StarterPlayerScripts or "
|
|
497
|
+
.. "StarterCharacterScripts, where LocalScript is still the right "
|
|
498
|
+
.. "class."
|
|
499
|
+
)
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
-- No shared path memo here: each creation changes its parent's children, so a
|
|
504
|
+
-- cached sibling grouping would go stale mid-batch and mis-number the paths.
|
|
505
|
+
local warnings: { string } = {}
|
|
506
|
+
|
|
507
|
+
local created, recorded = Undo.record("StudioMCP.ScriptCreate", "MCP create script", function()
|
|
508
|
+
local created: { { [string]: any } } = {}
|
|
509
|
+
|
|
510
|
+
for _, request in requests do
|
|
511
|
+
local parent = Paths.resolve(request.parent)
|
|
512
|
+
local instance = Instance.new(request.className) :: LuaSourceContainer
|
|
513
|
+
|
|
514
|
+
instance.Name = request.name
|
|
515
|
+
if typeof(request.source) == "string" then
|
|
516
|
+
(instance :: ScriptEdit.SourceContainer).Source = request.source
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
if typeof(request.runContext) == "string" and instance:IsA("Script") then
|
|
520
|
+
local ok, runContext = pcall(function()
|
|
521
|
+
return (Enum.RunContext :: any)[request.runContext]
|
|
522
|
+
end)
|
|
523
|
+
if not ok or runContext == nil then
|
|
524
|
+
Dispatch.fail(
|
|
525
|
+
"BAD_PARAMS",
|
|
526
|
+
string.format('"%s" is not a RunContext.', tostring(request.runContext)),
|
|
527
|
+
"Use Legacy, Server or Client."
|
|
528
|
+
)
|
|
529
|
+
end
|
|
530
|
+
instance.RunContext = runContext
|
|
531
|
+
end
|
|
532
|
+
if request.disabled == true and instance:IsA("BaseScript") then
|
|
533
|
+
instance.Disabled = true
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
instance.Parent = parent
|
|
537
|
+
|
|
538
|
+
if instance:IsA("Script") and instance.RunContext ~= Enum.RunContext.Legacy then
|
|
539
|
+
local container = starterContainer(instance)
|
|
540
|
+
if container then
|
|
541
|
+
--[[
|
|
542
|
+
A Tool is the exception, and a common enough one to be
|
|
543
|
+
worth separating.
|
|
544
|
+
|
|
545
|
+
The general warning ends "make it a LocalScript", which
|
|
546
|
+
is right for a bare script in StarterGui or StarterPack
|
|
547
|
+
and WRONG for the inside of a weapon: damage, ammo and
|
|
548
|
+
hit detection belong on the server, and a LocalScript
|
|
549
|
+
there hands all three to the client. The duplicate the
|
|
550
|
+
warning is about is harmless here too -- the copy left in
|
|
551
|
+
StarterPack is never held by anyone, so nothing it
|
|
552
|
+
listens for ever fires.
|
|
553
|
+
]]
|
|
554
|
+
local insideTool = false
|
|
555
|
+
local walk: Instance? = instance.Parent
|
|
556
|
+
while walk ~= nil and walk ~= game do
|
|
557
|
+
if walk:IsA("Tool") then
|
|
558
|
+
insideTool = true
|
|
559
|
+
break
|
|
560
|
+
end
|
|
561
|
+
walk = walk.Parent
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
if insideTool then
|
|
565
|
+
table.insert(
|
|
566
|
+
warnings,
|
|
567
|
+
string.format(
|
|
568
|
+
"%s is a Script with RunContext %s inside a Tool in %s. That is "
|
|
569
|
+
.. "usually right -- the Tool is copied into each player's "
|
|
570
|
+
.. "Backpack and the script runs there, on the server, which "
|
|
571
|
+
.. "is where damage and ammo belong. The copy left behind in "
|
|
572
|
+
.. "%s also runs, but nobody holds it, so nothing it waits for "
|
|
573
|
+
.. "happens. Keep it a Script, not a LocalScript.",
|
|
574
|
+
instance.Name,
|
|
575
|
+
instance.RunContext.Name,
|
|
576
|
+
container,
|
|
577
|
+
container
|
|
578
|
+
)
|
|
579
|
+
)
|
|
580
|
+
else
|
|
581
|
+
table.insert(
|
|
582
|
+
warnings,
|
|
583
|
+
string.format(
|
|
584
|
+
'%s is a Script with RunContext %s inside %s. That container is '
|
|
585
|
+
.. "COPIED into each player, so the script runs once where it "
|
|
586
|
+
.. "sits and again in every copy. Make it a LocalScript "
|
|
587
|
+
.. "instead -- a Legacy Script there would not run at all. "
|
|
588
|
+
.. "Studio warns about this in its own Output, which `console` "
|
|
589
|
+
.. "cannot read.",
|
|
590
|
+
instance.Name,
|
|
591
|
+
instance.RunContext.Name,
|
|
592
|
+
container
|
|
593
|
+
)
|
|
594
|
+
)
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
table.insert(created, {
|
|
600
|
+
path = Paths.of(instance),
|
|
601
|
+
className = instance.ClassName,
|
|
602
|
+
})
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
return created
|
|
606
|
+
end)
|
|
607
|
+
|
|
608
|
+
return {
|
|
609
|
+
items = created,
|
|
610
|
+
undoStep = if recorded then "MCP create script" else nil,
|
|
611
|
+
warnings = if #warnings > 0 then warnings else nil,
|
|
612
|
+
}
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
--[[
|
|
616
|
+
Opens a script in the user's editor, at a line.
|
|
617
|
+
|
|
618
|
+
The gap this closes is a conversational one. An agent that has found the bug
|
|
619
|
+
says "it is line 214 of Combat" and the user then has to go and find Combat,
|
|
620
|
+
open it, and scroll -- every time, for every finding. `ScriptEditorService`
|
|
621
|
+
can just put it on their screen.
|
|
622
|
+
|
|
623
|
+
Deliberately not automatic. Nothing else in this server opens windows, and a
|
|
624
|
+
tool that rearranged the user's editor as a side effect of reading a file
|
|
625
|
+
would be intolerable on a batch of twenty. It happens when it is asked for.
|
|
626
|
+
]]
|
|
627
|
+
function Scripts.open(params: { [string]: any }): { [string]: any }
|
|
628
|
+
local target = resolveScript(params.path)
|
|
629
|
+
local line = math.max(tonumber(params.line) or 1, 1)
|
|
630
|
+
|
|
631
|
+
local service = game:GetService("ScriptEditorService")
|
|
632
|
+
local ok, err = pcall(function()
|
|
633
|
+
(service :: any):OpenScriptDocumentAsync(target)
|
|
634
|
+
end)
|
|
635
|
+
if not ok then
|
|
636
|
+
Dispatch.fail(
|
|
637
|
+
"OPEN_FAILED",
|
|
638
|
+
string.format("Could not open %s: %s", target:GetFullName(), tostring(err))
|
|
639
|
+
)
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
--[[
|
|
643
|
+
Moving the cursor is a second, separate operation, and a failure to move
|
|
644
|
+
it is not a failure to open. A document that opened but did not scroll is
|
|
645
|
+
still in front of the user; raising here would report the whole thing as
|
|
646
|
+
broken over the smaller half.
|
|
647
|
+
]]
|
|
648
|
+
local movedTo: number? = nil
|
|
649
|
+
pcall(function()
|
|
650
|
+
for _, document in (service :: any):GetScriptDocuments() do
|
|
651
|
+
if document:GetScript() == target then
|
|
652
|
+
document:RequestSetSelectionAsync(line, 1, line, 1)
|
|
653
|
+
movedTo = line
|
|
654
|
+
break
|
|
655
|
+
end
|
|
656
|
+
end
|
|
657
|
+
end)
|
|
658
|
+
|
|
659
|
+
return {
|
|
660
|
+
path = Paths.of(target),
|
|
661
|
+
className = target.ClassName,
|
|
662
|
+
opened = true,
|
|
663
|
+
line = movedTo,
|
|
664
|
+
}
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
function Scripts.register()
|
|
668
|
+
Dispatch.registerAll("script", {
|
|
669
|
+
open = Scripts.open,
|
|
670
|
+
read = Scripts.read,
|
|
671
|
+
edit = Scripts.edit,
|
|
672
|
+
grep = Scripts.grep,
|
|
673
|
+
create = Scripts.create,
|
|
674
|
+
})
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
return Scripts
|