@el4cteo/rbx-studio-mcp 0.8.4 → 0.8.6
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 +20 -3
- package/dist/bridge/rpc.js +1 -1
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/apidump.js +75 -0
- package/dist/lib/apidump.js.map +1 -1
- package/dist/lib/errors.js +5 -4
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/format.js +92 -25
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/notices.js +29 -0
- package/dist/lib/notices.js.map +1 -0
- package/dist/lib/protocol.js.map +1 -1
- package/dist/lib/sync.js +1141 -0
- package/dist/lib/sync.js.map +1 -0
- package/dist/lib/syncplan.js +338 -0
- package/dist/lib/syncplan.js.map +1 -0
- package/dist/lib/tool.js +9 -2
- package/dist/lib/tool.js.map +1 -1
- package/dist/tools/discover.js +6 -1
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/exec.js +5 -0
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/instances.js +2 -2
- package/dist/tools/instances.js.map +1 -1
- package/dist/tools/perf.js +28 -3
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/screenshot.js +7 -3
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/scripts.js +110 -28
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/sync.js +169 -0
- package/dist/tools/sync.js.map +1 -0
- package/package.json +4 -4
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Dispatch.luau +131 -90
- package/plugin/src/ExecRuntime.luau +190 -168
- package/plugin/src/LogBuffer.luau +38 -9
- package/plugin/src/Paths.luau +42 -0
- package/plugin/src/Phrase.luau +41 -0
- package/plugin/src/ScriptEdit.luau +94 -8
- package/plugin/src/Serialize.luau +16 -1
- package/plugin/src/Transport.luau +5 -2
- package/plugin/src/Undo.luau +74 -10
- package/plugin/src/handlers/Capture.luau +818 -809
- package/plugin/src/handlers/Debug.luau +19 -16
- package/plugin/src/handlers/Discover.luau +31 -1
- package/plugin/src/handlers/Perf.luau +100 -21
- package/plugin/src/handlers/Scripts.luau +274 -90
- package/plugin/src/handlers/Sync.luau +968 -0
- package/plugin/src/init.server.luau +33 -2
- package/scripts/build.mjs +14 -0
- package/scripts/sync-fake.mjs +191 -0
- package/scripts/test-live-sync-scale.mjs +150 -0
- package/scripts/test-live-sync.mjs +232 -0
- package/scripts/test-live-tools.mjs +6 -1
- package/scripts/test-plugin.mjs +16 -0
- package/scripts/test-results.mjs +41 -0
- package/scripts/test-sync-more.mjs +228 -0
- package/scripts/test-sync.mjs +245 -0
- package/dist/tools/spatial.js +0 -135
- package/dist/tools/spatial.js.map +0 -1
- package/dist/tools/upload.js +0 -294
- package/dist/tools/upload.js.map +0 -1
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
saved. Reads use the editor buffer for the same reason: handing an agent stale
|
|
10
10
|
source makes it "fix" changes the user just made.
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
Source undo is the script editor's own, per script -- a ChangeHistoryService
|
|
13
|
+
recording cannot capture an editor write. What a batch gets instead is
|
|
14
|
+
all-or-nothing application (see `edit`) and the shared mutation lock, so two
|
|
15
|
+
batches never interleave their writes.
|
|
15
16
|
|
|
16
17
|
The text manipulation itself lives in `TextEdit`, which has no Roblox
|
|
17
18
|
dependencies and is unit tested.
|
|
@@ -56,43 +57,6 @@ local function resolveScript(path: string): LuaSourceContainer
|
|
|
56
57
|
return instance :: LuaSourceContainer
|
|
57
58
|
end
|
|
58
59
|
|
|
59
|
-
--[[
|
|
60
|
-
A short fingerprint of a script's source, for detecting that it moved.
|
|
61
|
-
|
|
62
|
-
Handed out by `read` and passed back to `edit`, which refuses to write when
|
|
63
|
-
the live source no longer matches. That is the only thing standing between
|
|
64
|
-
two agents on one place and a silent overwrite: a line-range edit computed
|
|
65
|
-
against source somebody has since changed still applies cleanly, it just
|
|
66
|
-
applies to the wrong lines, and nothing anywhere reports it.
|
|
67
|
-
|
|
68
|
-
FNV-1a over the whole string, with the length appended. Not a security
|
|
69
|
-
hash and does not need to be -- it is guarding against ordinary concurrent
|
|
70
|
-
editing, not against someone constructing a collision. The length is there
|
|
71
|
-
because it is free and rules out the whole class of same-length accidents.
|
|
72
|
-
|
|
73
|
-
The multiply is split into 16-bit halves on purpose. `hash * 16777619` with
|
|
74
|
-
a 32-bit hash reaches 2^56, past the 2^53 where doubles stop being exact,
|
|
75
|
-
so the low bits -- the ones that carry the mixing -- would be quietly
|
|
76
|
-
rounded away.
|
|
77
|
-
]]
|
|
78
|
-
local function fingerprint(source: string): string
|
|
79
|
-
local hash = 2166136261
|
|
80
|
-
local length = #source
|
|
81
|
-
local index = 1
|
|
82
|
-
while index <= length do
|
|
83
|
-
local last = math.min(index + 511, length)
|
|
84
|
-
local chunk = { string.byte(source, index, last) }
|
|
85
|
-
for _, byte in chunk do
|
|
86
|
-
hash = bit32.bxor(hash, byte)
|
|
87
|
-
local low = bit32.band(hash, 0xFFFF)
|
|
88
|
-
local high = bit32.rshift(hash, 16)
|
|
89
|
-
-- 16777619 == 0x01000193, so 0x0193 is 403 and 0x0100 is 256.
|
|
90
|
-
hash = bit32.band(low * 403 + bit32.lshift(bit32.band(high * 403 + low * 256, 0xFFFF), 16), 0xFFFFFFFF)
|
|
91
|
-
end
|
|
92
|
-
index = last + 1
|
|
93
|
-
end
|
|
94
|
-
return string.format("%08x-%x", hash, length)
|
|
95
|
-
end
|
|
96
60
|
|
|
97
61
|
--[[
|
|
98
62
|
Whether a script is really the source of truth, or a copy of a file on disk.
|
|
@@ -197,7 +161,7 @@ function Scripts.read(params: { [string]: any }): { [string]: any }
|
|
|
197
161
|
-- Of the whole file, never of the window: `edit` compares it against
|
|
198
162
|
-- the live source, and a fingerprint of forty lines out of four
|
|
199
163
|
-- hundred would call an edit safe that it is not.
|
|
200
|
-
revision =
|
|
164
|
+
revision = ScriptEdit.revisionOf(target, whole),
|
|
201
165
|
-- Absent unless the script is bound to a file on disk, in which case
|
|
202
166
|
-- editing it here is a race against whatever writes that file.
|
|
203
167
|
fileSync = syncedFile(target),
|
|
@@ -222,8 +186,14 @@ end
|
|
|
222
186
|
Phase two writes the finished text. If a write fails there -- realistically
|
|
223
187
|
only a locked or package-owned script -- the scripts already written are
|
|
224
188
|
restored from the source captured in phase one.
|
|
189
|
+
|
|
190
|
+
Phase one reads, phase two writes, and the editor yields in between, so the
|
|
191
|
+
user can type into a script after it was read. Each write therefore checks,
|
|
192
|
+
inside the editor's own callback, that the text is still what phase one
|
|
193
|
+
transformed, and a restore only puts back text that is still ours. Anything
|
|
194
|
+
newer is kept and named, rather than silently overwritten on either path.
|
|
225
195
|
]]
|
|
226
|
-
function
|
|
196
|
+
local function editBatch(params: { [string]: any }): { [string]: any }
|
|
227
197
|
local edits = params.edits
|
|
228
198
|
if typeof(edits) ~= "table" or #edits == 0 then
|
|
229
199
|
Dispatch.fail(
|
|
@@ -272,7 +242,7 @@ function Scripts.edit(params: { [string]: any }): { [string]: any }
|
|
|
272
242
|
if typeof(stated) ~= "string" or stated == "" then
|
|
273
243
|
continue
|
|
274
244
|
end
|
|
275
|
-
live = live or
|
|
245
|
+
live = live or ScriptEdit.revisionOf(target, before)
|
|
276
246
|
if stated ~= live then
|
|
277
247
|
Dispatch.fail(
|
|
278
248
|
"STALE_SCRIPT",
|
|
@@ -293,35 +263,79 @@ function Scripts.edit(params: { [string]: any }): { [string]: any }
|
|
|
293
263
|
table.insert(pending, { target = target, before = before, after = after })
|
|
294
264
|
end
|
|
295
265
|
|
|
296
|
-
-- Phase two: write. `written` is the compensation log for a mid-batch
|
|
266
|
+
-- Phase two: write. `written` is the compensation log for a mid-batch
|
|
267
|
+
-- failure, and holds every script whose write reached the editor -- a write
|
|
268
|
+
-- whose confirmation failed afterwards has still changed the script.
|
|
297
269
|
local written: { Pending } = {}
|
|
298
270
|
local memo: Paths.NameIndex = {}
|
|
299
271
|
local results: { { [string]: any } } = {}
|
|
300
272
|
|
|
301
273
|
for _, entry in pending do
|
|
274
|
+
local committed = false
|
|
302
275
|
-- Wrapped in a closure rather than passed to pcall directly: `write`
|
|
303
276
|
-- returns nothing, and pcall's typed signature expects a value back.
|
|
304
277
|
local ok, err = pcall(function()
|
|
305
|
-
ScriptEdit.write(entry.target, function()
|
|
278
|
+
ScriptEdit.write(entry.target, function(current)
|
|
279
|
+
if current ~= entry.before then
|
|
280
|
+
Dispatch.fail(
|
|
281
|
+
"STALE_SCRIPT",
|
|
282
|
+
string.format("%s changed while this batch was being written.", entry.target:GetFullName()),
|
|
283
|
+
"Somebody typed into it after it was read. Read it again with "
|
|
284
|
+
.. "script_read and rebuild the edit against what is there now."
|
|
285
|
+
)
|
|
286
|
+
end
|
|
287
|
+
committed = entry.after ~= entry.before
|
|
306
288
|
return entry.after
|
|
307
289
|
end)
|
|
308
290
|
end)
|
|
291
|
+
if committed then
|
|
292
|
+
table.insert(written, entry)
|
|
293
|
+
end
|
|
309
294
|
|
|
310
295
|
if not ok then
|
|
296
|
+
local kept: { string } = {}
|
|
311
297
|
for index = #written, 1, -1 do
|
|
312
298
|
local done = written[index]
|
|
313
|
-
--
|
|
314
|
-
--
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
299
|
+
-- Nothing to undo when the editor never took the write -- and a
|
|
300
|
+
-- write it refused (a locked script) would only be refused again.
|
|
301
|
+
if ScriptEdit.read(done.target) == done.before then
|
|
302
|
+
continue
|
|
303
|
+
end
|
|
304
|
+
local restored, restoreError = pcall(function()
|
|
305
|
+
ScriptEdit.write(done.target, function(current)
|
|
306
|
+
if current == done.after then
|
|
307
|
+
return done.before
|
|
308
|
+
end
|
|
309
|
+
if current ~= done.before then
|
|
310
|
+
Dispatch.fail("ROLLBACK_CONFLICT", "edited again since this batch wrote it; that text was kept")
|
|
311
|
+
end
|
|
312
|
+
return current
|
|
318
313
|
end)
|
|
319
314
|
end)
|
|
315
|
+
if not restored then
|
|
316
|
+
local reason = if typeof(restoreError) == "table" then restoreError.message else tostring(restoreError)
|
|
317
|
+
table.insert(kept, string.format("%s (%s)", done.target:GetFullName(), tostring(reason)))
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
--[[
|
|
321
|
+
Said outright when the rollback could not finish.
|
|
322
|
+
|
|
323
|
+
The batch promises all-or-nothing, and a restore that fails leaves
|
|
324
|
+
part of it applied. Reporting only the original error would let the
|
|
325
|
+
agent believe nothing changed and retry on top of a half-applied edit.
|
|
326
|
+
]]
|
|
327
|
+
if #kept > 0 then
|
|
328
|
+
local reason = if typeof(err) == "table" then err.message else tostring(err)
|
|
329
|
+
Dispatch.fail(
|
|
330
|
+
"PARTIAL_EDIT",
|
|
331
|
+
string.format("%s The batch could not be fully rolled back.", tostring(reason)),
|
|
332
|
+
"Still edited, left as they are: " .. table.concat(kept, "; ")
|
|
333
|
+
.. ". Read these scripts before retrying."
|
|
334
|
+
)
|
|
320
335
|
end
|
|
321
336
|
error(err, 0)
|
|
322
337
|
end
|
|
323
338
|
|
|
324
|
-
table.insert(written, entry)
|
|
325
339
|
table.insert(results, {
|
|
326
340
|
path = Paths.of(entry.target, memo),
|
|
327
341
|
className = entry.target.ClassName,
|
|
@@ -331,46 +345,101 @@ function Scripts.edit(params: { [string]: any }): { [string]: any }
|
|
|
331
345
|
-- Of what is there now, not of what was asked for: the editor can
|
|
332
346
|
-- normalise the text it is handed, and a rev that does not match the
|
|
333
347
|
-- live buffer would refuse the very next edit as stale.
|
|
334
|
-
rev =
|
|
348
|
+
rev = ScriptEdit.revisionOf(entry.target),
|
|
335
349
|
})
|
|
336
350
|
end
|
|
337
351
|
|
|
338
352
|
return { items = results }
|
|
339
353
|
end
|
|
340
354
|
|
|
355
|
+
function Scripts.edit(params: { [string]: any }): { [string]: any }
|
|
356
|
+
return Undo.exclusive(function()
|
|
357
|
+
return editBatch(params)
|
|
358
|
+
end)
|
|
359
|
+
end
|
|
360
|
+
|
|
341
361
|
--[[
|
|
342
362
|
Searches script source. Matches come from the editor buffer, so text the user
|
|
343
363
|
has typed but not saved is found too -- which is the state an agent about to
|
|
344
364
|
edit the file actually needs to see.
|
|
365
|
+
|
|
366
|
+
`patterns` takes up to 16 literal needles searched in one pass. Looking for
|
|
367
|
+
every caller of three functions used to be three calls, each reading every
|
|
368
|
+
script's buffer again; the reads are the cost, not the matching.
|
|
369
|
+
|
|
370
|
+
`mode` picks what comes back: matching lines (the default), one row per
|
|
371
|
+
matching script, or only per-needle counts of matching lines -- the cheap
|
|
372
|
+
way to ask "how widespread is this" before deciding to read anything.
|
|
345
373
|
]]
|
|
374
|
+
local GREP_MAX_NEEDLES = 16
|
|
375
|
+
-- Characters of rows handed back, estimated per row. The server clips to its
|
|
376
|
+
-- own response limit as well; this keeps a search over long minified lines from
|
|
377
|
+
-- building a reply far past it only for most of it to be thrown away.
|
|
378
|
+
local GREP_BUDGET = 18_000
|
|
379
|
+
local GREP_LONG_LINE = 256
|
|
380
|
+
|
|
346
381
|
function Scripts.grep(params: { [string]: any }): { [string]: any }
|
|
347
|
-
local
|
|
348
|
-
|
|
349
|
-
|
|
382
|
+
local needles: { string } = {}
|
|
383
|
+
local literal = params.literal == true
|
|
384
|
+
local patterns = params.patterns
|
|
385
|
+
if patterns ~= nil then
|
|
386
|
+
if params.pattern ~= nil or typeof(patterns) ~= "table" or #patterns == 0 or #patterns > GREP_MAX_NEEDLES then
|
|
387
|
+
Dispatch.fail(
|
|
388
|
+
"BAD_PARAMS",
|
|
389
|
+
string.format("Pass either `pattern` or 1-%d `patterns`, not both.", GREP_MAX_NEEDLES)
|
|
390
|
+
)
|
|
391
|
+
end
|
|
392
|
+
-- Always literal: the point is identifiers, and mixing Lua patterns into a
|
|
393
|
+
-- batch makes one bad escape fail every needle with it.
|
|
394
|
+
literal = true
|
|
395
|
+
for _, needle in patterns do
|
|
396
|
+
if typeof(needle) ~= "string" or needle == "" then
|
|
397
|
+
Dispatch.fail("BAD_PARAMS", "Every entry in `patterns` must be a non-empty string.")
|
|
398
|
+
end
|
|
399
|
+
table.insert(needles, needle)
|
|
400
|
+
end
|
|
401
|
+
elseif typeof(params.pattern) == "string" and params.pattern ~= "" then
|
|
402
|
+
table.insert(needles, params.pattern)
|
|
403
|
+
else
|
|
404
|
+
Dispatch.fail("BAD_PARAMS", "script_grep requires a `pattern` or `patterns`.")
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
local mode = params.mode or "lines"
|
|
408
|
+
if mode ~= "lines" and mode ~= "files" and mode ~= "counts" then
|
|
409
|
+
Dispatch.fail("BAD_PARAMS", string.format("%q is not a grep mode.", tostring(mode)), "Use lines, files or counts.")
|
|
350
410
|
end
|
|
351
411
|
|
|
352
412
|
local root = if params.path then Paths.resolve(params.path) else game
|
|
353
|
-
local literal = params.literal == true
|
|
354
413
|
local ignoreCase = params.ignoreCase == true
|
|
355
414
|
local contextLines = math.clamp(tonumber(params.contextLines) or DEFAULT_CONTEXT, 0, 10)
|
|
356
|
-
local limit = math.
|
|
357
|
-
local offset = tonumber(params.offset) or 0
|
|
415
|
+
local limit = math.clamp(tonumber(params.limit) or 100, 1, MAX_MATCHES)
|
|
416
|
+
local offset = math.max(0, tonumber(params.offset) or 0)
|
|
358
417
|
local classFilter = params.className
|
|
359
418
|
|
|
360
|
-
|
|
419
|
+
if ignoreCase then
|
|
420
|
+
for index, needle in needles do
|
|
421
|
+
needles[index] = string.lower(needle)
|
|
422
|
+
end
|
|
423
|
+
end
|
|
361
424
|
|
|
362
425
|
local targets: { LuaSourceContainer } = {}
|
|
363
|
-
|
|
426
|
+
local function consider(instance: Instance)
|
|
364
427
|
if not instance:IsA("LuaSourceContainer") then
|
|
365
|
-
|
|
428
|
+
return
|
|
366
429
|
end
|
|
367
430
|
if root == game and Scope.isNoisy(instance) then
|
|
368
|
-
|
|
431
|
+
return
|
|
369
432
|
end
|
|
370
433
|
if classFilter and not instance:IsA(classFilter) then
|
|
371
|
-
|
|
434
|
+
return
|
|
372
435
|
end
|
|
373
|
-
table.insert(targets, instance)
|
|
436
|
+
table.insert(targets, instance :: LuaSourceContainer)
|
|
437
|
+
end
|
|
438
|
+
-- The root itself counts: `path` naming one script searches that script,
|
|
439
|
+
-- where it used to search only its (usually absent) children.
|
|
440
|
+
consider(root)
|
|
441
|
+
for _, instance in root:GetDescendants() do
|
|
442
|
+
consider(instance)
|
|
374
443
|
end
|
|
375
444
|
|
|
376
445
|
if #targets > MAX_SCRIPTS then
|
|
@@ -381,15 +450,51 @@ function Scripts.grep(params: { [string]: any }): { [string]: any }
|
|
|
381
450
|
)
|
|
382
451
|
end
|
|
383
452
|
|
|
384
|
-
|
|
453
|
+
--[[
|
|
454
|
+
A fixed order, so `offset` means the same thing on every page.
|
|
455
|
+
|
|
456
|
+
`GetDescendants` does not return one (see `stableOrder` in Discover): page
|
|
457
|
+
two came from a different ordering than page one, repeating some matches
|
|
458
|
+
and silently skipping others. By full name, with the debug id separating
|
|
459
|
+
same-named scripts.
|
|
460
|
+
]]
|
|
461
|
+
local keys: { [Instance]: string } = {}
|
|
462
|
+
for _, target in targets do
|
|
463
|
+
local ok, id = pcall(function()
|
|
464
|
+
return target:GetDebugId()
|
|
465
|
+
end)
|
|
466
|
+
keys[target] = target:GetFullName() .. "\0" .. (if ok then id else "")
|
|
467
|
+
end
|
|
468
|
+
table.sort(targets, function(a: Instance, b: Instance): boolean
|
|
469
|
+
return keys[a] < keys[b]
|
|
470
|
+
end)
|
|
471
|
+
|
|
472
|
+
local function badPattern(needle: string, reason: any): never
|
|
385
473
|
Dispatch.fail(
|
|
386
474
|
"BAD_PATTERN",
|
|
387
|
-
string.format("%s is not a valid Lua pattern: %s",
|
|
475
|
+
string.format("%s is not a valid Lua pattern: %s", needle, tostring(reason)),
|
|
388
476
|
"Lua patterns escape with %, not backslash, and have no alternation. "
|
|
389
477
|
.. "Set `literal` to search for the text exactly as written."
|
|
390
478
|
)
|
|
391
479
|
end
|
|
392
480
|
|
|
481
|
+
-- Which needles occur on `text`, or nil. An invalid Lua pattern raises rather
|
|
482
|
+
-- than simply not matching, so it is reported as a pattern problem.
|
|
483
|
+
local function hits(text: string): { number }?
|
|
484
|
+
local found: { number }? = nil
|
|
485
|
+
for index, needle in needles do
|
|
486
|
+
local ok, from = pcall(string.find, text, needle, 1, literal)
|
|
487
|
+
if not ok then
|
|
488
|
+
badPattern(needle, from)
|
|
489
|
+
end
|
|
490
|
+
if from then
|
|
491
|
+
found = found or {}
|
|
492
|
+
table.insert(found :: { number }, index)
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
return found
|
|
496
|
+
end
|
|
497
|
+
|
|
393
498
|
--[[
|
|
394
499
|
One search over the whole file before splitting it into lines.
|
|
395
500
|
|
|
@@ -401,53 +506,80 @@ function Scripts.grep(params: { [string]: any }): { [string]: any }
|
|
|
401
506
|
]]
|
|
402
507
|
-- Any trailing `$` counts, escaped or not: skipping the shortcut costs speed,
|
|
403
508
|
-- taking it wrongly would hide matches.
|
|
404
|
-
local anchored =
|
|
509
|
+
local anchored = false
|
|
510
|
+
if not literal then
|
|
511
|
+
for _, needle in needles do
|
|
512
|
+
if string.sub(needle, 1, 1) == "^" or string.sub(needle, -1) == "$" then
|
|
513
|
+
anchored = true
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
end
|
|
405
517
|
|
|
406
518
|
local matches: { { [string]: any } } = {}
|
|
519
|
+
local counts: { number } = table.create(#needles, 0)
|
|
407
520
|
local total = 0
|
|
521
|
+
local matchedFiles = 0
|
|
522
|
+
local budget = GREP_BUDGET
|
|
523
|
+
local full = false
|
|
408
524
|
local memo: Paths.NameIndex = {}
|
|
409
525
|
|
|
526
|
+
-- Rows stop at `limit` or the budget, whichever comes first; `total` keeps
|
|
527
|
+
-- counting either way, so the cursor the server builds from the rows actually
|
|
528
|
+
-- shown picks up exactly where this reply stopped.
|
|
529
|
+
local function emit(entry: { [string]: any }, cost: number): boolean
|
|
530
|
+
if full or #matches >= limit then
|
|
531
|
+
return false
|
|
532
|
+
end
|
|
533
|
+
if cost > budget and #matches > 0 then
|
|
534
|
+
full = true
|
|
535
|
+
return false
|
|
536
|
+
end
|
|
537
|
+
budget -= cost
|
|
538
|
+
table.insert(matches, entry)
|
|
539
|
+
return true
|
|
540
|
+
end
|
|
541
|
+
|
|
410
542
|
for _, target in targets do
|
|
411
543
|
local source = ScriptEdit.read(target)
|
|
412
|
-
if
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
badPattern(found)
|
|
416
|
-
end
|
|
417
|
-
if not found then
|
|
418
|
-
continue
|
|
419
|
-
end
|
|
544
|
+
local searchable = if ignoreCase then string.lower(source) else source
|
|
545
|
+
if not anchored and hits(searchable) == nil then
|
|
546
|
+
continue
|
|
420
547
|
end
|
|
421
548
|
|
|
422
549
|
local lines = TextEdit.toLines(source)
|
|
423
550
|
local path: string? = nil
|
|
551
|
+
local revision: string? = nil
|
|
552
|
+
local fileMatches = 0
|
|
424
553
|
|
|
425
554
|
for number, line in lines do
|
|
426
|
-
local
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
555
|
+
local found = hits(if ignoreCase then string.lower(line) else line)
|
|
556
|
+
if found == nil then
|
|
557
|
+
continue
|
|
558
|
+
end
|
|
559
|
+
fileMatches += 1
|
|
560
|
+
for _, index in found do
|
|
561
|
+
counts[index] += 1
|
|
432
562
|
end
|
|
433
|
-
if
|
|
563
|
+
if mode ~= "lines" then
|
|
434
564
|
continue
|
|
435
565
|
end
|
|
436
566
|
|
|
437
567
|
total += 1
|
|
438
|
-
if total <= offset or #matches >= limit then
|
|
568
|
+
if total <= offset or full or #matches >= limit then
|
|
439
569
|
continue
|
|
440
570
|
end
|
|
441
571
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
end
|
|
445
|
-
|
|
572
|
+
path = path or Paths.of(target, memo)
|
|
573
|
+
revision = revision or ScriptEdit.revisionOf(target, source)
|
|
446
574
|
local entry: { [string]: any } = {
|
|
447
575
|
path = path,
|
|
576
|
+
revision = revision,
|
|
448
577
|
line = number,
|
|
449
578
|
text = line,
|
|
579
|
+
-- Only when there is more than one needle to tell apart.
|
|
580
|
+
needles = if #needles > 1 then found else nil,
|
|
450
581
|
}
|
|
582
|
+
local cost = #line + 80
|
|
451
583
|
if contextLines > 0 then
|
|
452
584
|
local before: { string } = {}
|
|
453
585
|
local after: { string } = {}
|
|
@@ -455,8 +587,38 @@ function Scripts.grep(params: { [string]: any }): { [string]: any }
|
|
|
455
587
|
table.move(lines, number + 1, math.min(number + contextLines, #lines), 1, after)
|
|
456
588
|
entry.before = before
|
|
457
589
|
entry.after = after
|
|
590
|
+
for _, text in before do
|
|
591
|
+
cost += #text + 4
|
|
592
|
+
end
|
|
593
|
+
for _, text in after do
|
|
594
|
+
cost += #text + 4
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
-- A single line longer than the whole budget -- minified or generated
|
|
598
|
+
-- code -- is cut rather than never shown, which would stall the cursor.
|
|
599
|
+
if cost > budget and #matches == 0 then
|
|
600
|
+
entry.before, entry.after = nil, nil
|
|
601
|
+
entry.text = string.sub(line, 1, GREP_LONG_LINE)
|
|
602
|
+
entry.truncated = true
|
|
603
|
+
cost = GREP_LONG_LINE + 80
|
|
604
|
+
end
|
|
605
|
+
emit(entry, cost)
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
if fileMatches > 0 then
|
|
609
|
+
matchedFiles += 1
|
|
610
|
+
if mode == "files" then
|
|
611
|
+
total += 1
|
|
612
|
+
if total > offset then
|
|
613
|
+
path = path or Paths.of(target, memo)
|
|
614
|
+
emit({
|
|
615
|
+
path = path,
|
|
616
|
+
className = target.ClassName,
|
|
617
|
+
revision = ScriptEdit.revisionOf(target, source),
|
|
618
|
+
matches = fileMatches,
|
|
619
|
+
}, #(path :: string) + 60)
|
|
620
|
+
end
|
|
458
621
|
end
|
|
459
|
-
table.insert(matches, entry)
|
|
460
622
|
end
|
|
461
623
|
end
|
|
462
624
|
|
|
@@ -465,6 +627,8 @@ function Scripts.grep(params: { [string]: any }): { [string]: any }
|
|
|
465
627
|
total = total,
|
|
466
628
|
offset = offset,
|
|
467
629
|
searched = #targets,
|
|
630
|
+
counts = if mode == "counts" then counts else nil,
|
|
631
|
+
matchedFiles = if mode == "counts" then matchedFiles else nil,
|
|
468
632
|
}
|
|
469
633
|
end
|
|
470
634
|
|
|
@@ -648,10 +812,30 @@ function Scripts.create(params: { [string]: any }): { [string]: any }
|
|
|
648
812
|
|
|
649
813
|
-- After the recording, because the editor write yields. Undoing the create
|
|
650
814
|
-- still removes these scripts whole, source and all.
|
|
815
|
+
--
|
|
816
|
+
-- The scripts already exist at this point, so a failed write is reported as
|
|
817
|
+
-- what it is -- created, but without their source -- instead of as a failed
|
|
818
|
+
-- create an agent would retry into duplicates. And the write only fills a
|
|
819
|
+
-- script that is still empty: anything else is somebody else's text.
|
|
651
820
|
for _, entry in oversized do
|
|
652
|
-
|
|
653
|
-
|
|
821
|
+
local ok, err = pcall(function()
|
|
822
|
+
Undo.exclusive(function()
|
|
823
|
+
ScriptEdit.write(entry.target, function(current)
|
|
824
|
+
if current ~= "" then
|
|
825
|
+
Dispatch.fail("STALE_SCRIPT", "edited before its source could be written; that text was kept")
|
|
826
|
+
end
|
|
827
|
+
return entry.source
|
|
828
|
+
end)
|
|
829
|
+
end)
|
|
654
830
|
end)
|
|
831
|
+
if not ok then
|
|
832
|
+
local reason = if typeof(err) == "table" then err.message else tostring(err)
|
|
833
|
+
Dispatch.fail(
|
|
834
|
+
"PARTIAL_CREATE",
|
|
835
|
+
string.format("%s was created, but its source was not written: %s", Paths.of(entry.target), tostring(reason)),
|
|
836
|
+
"The script exists. Read it and write the source with script_edit rather than creating it again."
|
|
837
|
+
)
|
|
838
|
+
end
|
|
655
839
|
end
|
|
656
840
|
|
|
657
841
|
return {
|