Advertisement
alex290

Variable Buddy.lua

Feb 24th, 2025 (edited)
206
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Lua 18.02 KB | None | 0 0
  1. --!native
  2. --!optimize 2
  3.  
  4. local ScriptEditorService = game:GetService("ScriptEditorService")
  5. local TextService = game:GetService("TextService")
  6. local Selection = game:GetService("Selection")
  7.  
  8. local root = script.Parent
  9.  
  10. if not require(root.VersionCheck)(plugin) then return end
  11.  
  12. local AutocompleteName = "Variable Buddy"
  13. local AutocompletePriority = 1000
  14.  
  15. type Request = {
  16.     position: {
  17.         line: number,
  18.         character: number,
  19.     },
  20.     textDocument: {
  21.         document: ScriptDocument?,
  22.         script: LuaSourceContainer?,
  23.     },
  24. }
  25.  
  26. type ResponseItem = {
  27.     label: string,
  28.     kind: Enum.CompletionItemKind?,
  29.     tags: {Enum.CompletionItemTag}?,
  30.     detail: string?,
  31.     documentation: {
  32.         value: string,
  33.     }?,
  34.     overloads: number?,
  35.     learnMoreLink: string?,
  36.     codeSample: string?,
  37.     preselect: boolean?,
  38.     textEdit: {
  39.         newText: string,
  40.         replace: {
  41.             start: { line: number?, character: number? },
  42.             ["end"]: { line: number?, character: number? },
  43.         },
  44.     }?,
  45. }
  46.  
  47. type Response = {
  48.     items: { ResponseItem },
  49. }
  50.  
  51. type ScriptChange = {
  52.     range: {
  53.         start: { line: number, character: number },
  54.         ["end"]: { line: number, character: number }
  55.     },
  56.     text: string
  57. }
  58.  
  59. local fastFzy = require(root.FastFzy)
  60.  
  61. local fastFzyConfig = fastFzy.CreateConfiguration({
  62.     CaseSensitive = false
  63. })
  64.  
  65. local services = table.freeze({
  66.     "AnalyticsService",
  67.     "AssetService",
  68.     "BadgeService",
  69.     "ChangeHistoryService",
  70.     "Chat",
  71.     "CollectionService",
  72.     "ContentProvider",
  73.     "ContextActionService",
  74.     "CoreGui",
  75.     "DataStoreService",
  76.     "Debris",
  77.     "DraggerService",
  78.     "FriendService",
  79.     "GamePassService",
  80.     "GamepadService",
  81.     "GeometryService",
  82.     "GroupService",
  83.     "GuiService",
  84.     "HapticService",
  85.     "HttpService",
  86.     "InsertService",
  87.     "Lighting",
  88.     "LocalizationService",
  89.     "LogService",
  90.     "MarketplaceService",
  91.     "MaterialService",
  92.     "MemoryStoreService",
  93.     "PathfindingService",
  94.     "PhysicsService",
  95.     "Players",
  96.     "PluginDebugService",
  97.     "PluginGuiService",
  98.     "PolicyService",
  99.     "ProximityPromptService",
  100.     "ReplicatedFirst",
  101.     "ReplicatedStorage",
  102.     "RunService",
  103.     "ScriptContext",
  104.     "ScriptEditorService",
  105.     "ScriptService",
  106.     "Selection",
  107.     "ServerScriptService",
  108.     "ServerStorage",
  109.     "SharedTableRegistry",
  110.     "SoundService",
  111.     "StarterGui",
  112.     "StarterPack",
  113.     "StarterPlayer",
  114.     "Stats",
  115.     "StudioService",
  116.     "Teams",
  117.     "TeleportService",
  118.     "TestService",
  119.     "TextChatService",
  120.     "TextService",
  121.     "TweenService",
  122.     "UserInputService",
  123.     "VRService"
  124. })
  125.  
  126. local moduleAncestors = table.freeze({
  127.     workspace,
  128.     game:GetService("ReplicatedFirst"),
  129.     game:GetService("ReplicatedStorage"),
  130.     game:GetService("ServerScriptService"),
  131.     game:GetService("ServerStorage"),
  132.     game:GetService("StarterGui"),
  133.     game:GetService("StarterPack"),
  134.     game:GetService("StarterPlayer")
  135. })
  136.  
  137. local modules = {}
  138. local connections = {}
  139.  
  140. local items: {ResponseItem} = {}
  141.  
  142. local function wrapPath(path)
  143.     local parts = {}
  144.     local index = 1
  145.     while index <= #path do
  146.         local start, stop, match = path:find("(%b[])", index)
  147.         if start then
  148.             if start > index then
  149.                 for part in path:sub(index, start - 1):gmatch("[^%.]+") do
  150.                     parts[#parts + 1] = part
  151.                 end
  152.             end
  153.             parts[#parts + 1] = match
  154.             index = stop + 1
  155.         else
  156.             for part in path:sub(index):gmatch("[^%.]+") do
  157.                 parts[#parts + 1] = part
  158.             end
  159.             break
  160.         end
  161.     end
  162.    
  163.     local function needsWrapping(part)
  164.         if part:match('^%[".*"%]$') then
  165.             return false
  166.         end
  167.         return not part:match("^[_%a][_%w]*$")
  168.     end
  169.    
  170.     for index, part in ipairs(parts) do
  171.         if needsWrapping(part) then
  172.             part = part:gsub('^%["(.*)"%]$', '%1')
  173.             parts[index] = string.format('["%s"]', part)
  174.         end
  175.     end
  176.    
  177.     local result = {}
  178.     for _, part in ipairs(parts) do
  179.         if part:match('^%[".*"%]$') then
  180.             result[#result + 1] = part
  181.         else
  182.             result[#result + 1] = part
  183.         end
  184.     end
  185.     return table.concat(result, "."):gsub('%.%["', '["')
  186. end
  187. local function unwrapPath(path)
  188.     local parts = {}
  189.     local index = 1
  190.     while index <= #path do
  191.         local start, stop, match = path:find("(%b[])", index)
  192.         if start ~=  nil then
  193.             if start > index then
  194.                 for part in path:sub(index, start - 1):gmatch("[^%.]+") do
  195.                     parts[#parts + 1] = part
  196.                 end
  197.             end
  198.             parts[#parts + 1] = match
  199.             index = stop + 1
  200.         else
  201.             for part in path:sub(index):gmatch("[^%.]+") do
  202.                 parts[#parts + 1] = part
  203.             end
  204.             break
  205.         end
  206.     end
  207.    
  208.     local function needsUnwrapping(part)
  209.         if part:match('^%["(.*)"%]$') then
  210.             local unwrapped = part:match('^%["(.*)"%]$')
  211.             return unwrapped:match("^[_%a][_%w]*$")
  212.         end
  213.         return false
  214.     end
  215.    
  216.     for index, part in ipairs(parts) do
  217.         if needsUnwrapping(part) then
  218.             parts[index] = part:match('^%["(.*)"%]$')
  219.         end
  220.     end
  221.    
  222.     return table.concat(parts, "."):gsub('%.%["', '["')
  223. end
  224. local function splitPath(reference)
  225.     local segments = {}
  226.     local index = 1
  227.     local len = #reference
  228.     while index <= len do
  229.         local char = reference:sub(index, index)
  230.         if char == '.' then
  231.             index += 1
  232.         elseif char == '[' then
  233.             if reference:sub(index, index + 1) == '[\"' then
  234.                 local endBracket = reference:find('"]', index)
  235.                 if endBracket then
  236.                     segments[#segments + 1] = reference:sub(index + 2, endBracket - 1)
  237.                     index = endBracket + 2
  238.                 end
  239.             end
  240.         else
  241.             local nextDot = reference:find('%.', index)
  242.             local nextBracket = reference:find('%[', index)
  243.             local endPos = nextDot or len + 1
  244.             if nextBracket and nextBracket < endPos then
  245.                 endPos = nextBracket
  246.             end
  247.            
  248.             local segment = reference:sub(index, endPos - 1)
  249.             segments[#segments + 1] = segment
  250.             index = endPos
  251.         end
  252.     end
  253.     return segments
  254. end
  255.  
  256. local function getVariablePath(path, lines)
  257.     path = splitPath(path)
  258.     local newPath = ""
  259.     for index = #path, 1, -1 do
  260.         newPath = wrapPath(path[index].."."..newPath)
  261.         for _, line in ipairs(lines) do
  262.             local variablePath = line:match("local%s+[%w_]+%s*=%s*(.*)")
  263.             if variablePath and unwrapPath(variablePath) == wrapPath(unwrapPath(line)) then
  264.                 return line:match("local%s+(%a+)%s*=%s*(.*)")
  265.             end
  266.         end
  267.     end
  268.     return newPath
  269. end
  270. local function getRelativePath(src, instance)
  271.     local path = nil
  272.     if src then
  273.         if src:IsDescendantOf(instance) then
  274.             path = "script.Parent"
  275.             local parent = src
  276.             while parent ~= instance and parent do
  277.                 parent = parent.Parent
  278.                 path ..= ".Parent"
  279.             end
  280.         elseif instance:IsDescendantOf(src) then
  281.             local trace = {}
  282.             local parent = instance
  283.             while parent ~= src and parent do
  284.                 table.insert(trace, 1, parent.Name)
  285.                 parent = parent.Parent
  286.             end
  287.             path = `script.{table.concat(trace, ".")}`
  288.         else
  289.             path = instance:GetFullName():gsub('^Workspace.', "workspace.")
  290.         end
  291.     else
  292.         path = instance:GetFullName():gsub('^Workspace.', "workspace.")
  293.     end
  294.     return wrapPath(path)
  295. end
  296.  
  297. local currentScript = nil
  298.  
  299. local function trackModule(module)
  300.     if modules[module] then return end
  301.    
  302.     local connections = {}
  303.     modules[module] = connections
  304.    
  305.     local moduleName = module.Name
  306.     moduleName = moduleName:sub(1, 1):lower()..moduleName:sub(2, -1)
  307.    
  308.     local item = {
  309.         label = moduleName,
  310.         preselect = true,
  311.         kind = Enum.CompletionItemKind.Module,
  312.         tags = {Enum.CompletionItemTag.TypeCorrect},
  313.         detail = getRelativePath(currentScript, module),
  314.         module = module,
  315.         textEdit = {
  316.             newText = "",
  317.             replace = {
  318.                 start = {},
  319.                 ["end"] = {},
  320.             },
  321.         },
  322.     }
  323.     items[#items + 1] = item
  324.    
  325.     connections[#connections + 1] = module:GetPropertyChangedSignal("Name"):Connect(function()
  326.         item.label = module.Name
  327.         item.detail = getRelativePath(currentScript, module)
  328.     end)
  329.     connections[#connections + 1] = module:GetPropertyChangedSignal("Parent"):Connect(function()
  330.         item.detail = getRelativePath(currentScript, module)
  331.     end)
  332.     connections[#connections + 1] = module.Destroying:Connect(function()
  333.         for _, connection in ipairs(connections) do
  334.             connection:Disconnect()
  335.         end
  336.         modules[module] = nil
  337.         table.remove(items, table.find(items, item))
  338.     end)
  339. end
  340.  
  341. local function handleInstance(instance)
  342.     if not instance:IsA("ModuleScript") then return end
  343.     trackModule(instance)
  344. end
  345. for _, instance in ipairs(moduleAncestors) do
  346.     for _, instance in ipairs(instance:GetDescendants()) do
  347.         handleInstance(instance)
  348.     end
  349.     connections[#connections + 1] = instance.DescendantAdded:Connect(handleInstance)
  350. end
  351.  
  352. local function nameFormat(str)
  353.     str = str:gsub("(%s+)(%a)", function(_, letter)
  354.         return letter:upper()
  355.     end):gsub("%s+", "")
  356.     return str:sub(1, 1):lower()..str:sub(2, -1)
  357. end
  358.  
  359. local function updateAutocompleteDetails()
  360.     for _, item in ipairs(items) do
  361.         if item.module == nil then continue end
  362.         item.detail = getRelativePath(currentScript, item.module)
  363.     end
  364. end
  365.  
  366. ScriptEditorService:RegisterAutocompleteCallback(AutocompleteName, AutocompletePriority, function(request: Request, response: Response)
  367.     local doc = request.textDocument.document :: ScriptDocument
  368.     if doc == nil or request.textDocument.script == nil or doc:IsCommandBar() then
  369.         currentScript = nil
  370.         updateAutocompleteDetails()
  371.         return response
  372.     end
  373.     currentScript = request.textDocument.script
  374.     updateAutocompleteDetails()
  375.    
  376.     local line = doc:GetLine(request.position.line)
  377.    
  378.     if line:match("^%s*:") == nil then return response end
  379.    
  380.     local name = line:match("^:([^:]+)")
  381.     local lines = request.textDocument.document:GetText(1, 1):split("\n")
  382.     if not name then
  383.         name = ""
  384.     else
  385.         name = name:lower()
  386.     end
  387.     local result = fastFzy.BetterFilter(fastFzyConfig, name, items :: FastFzy.HaystackItem)
  388.     table.sort(result, function(a, b)
  389.         return a.Score > b.Score
  390.     end)
  391.    
  392.     for index, resultItem in ipairs(result) do
  393.         local item = resultItem.HaystackItem
  394.         if item.module then
  395.             local module = item.module
  396.             local relativePath = getRelativePath(currentScript, module)
  397.             local finalPath = getVariablePath(relativePath, lines)
  398.             item.path = relativePath
  399.             local newText = `local {nameFormat(module.Name)} = require({finalPath})`
  400.            
  401.             local textEdit = item.textEdit
  402.             textEdit.newText = newText
  403.             textEdit.replace.start.line = request.position.line
  404.             textEdit.replace.start.character = 1
  405.             textEdit.replace["end"].line = request.position.line
  406.             textEdit.replace["end"].character = #newText
  407.            
  408.             response.items[#response.items + 1] = item
  409.         elseif item.serviceClass then
  410.             local serviceClass = item.serviceClass
  411.             local newText = `local {serviceClass} = game:GetService("{serviceClass}")`
  412.  
  413.             local textEdit = item.textEdit
  414.             textEdit.newText = newText
  415.             textEdit.replace.start.line = request.position.line
  416.             textEdit.replace.start.character = 1
  417.             textEdit.replace["end"].line = request.position.line
  418.             textEdit.replace["end"].character = #newText
  419.  
  420.             response.items[#response.items + 1] = item
  421.         end
  422.     end
  423.    
  424.     return response
  425. end)
  426.  
  427. local function findIndexSortingByLength(lines, newLine)
  428.     local newLineWidth = TextService:GetTextSize(newLine, 14, Enum.Font.Code, Vector2.new(math.huge, math.huge)).X
  429.     for index, line in ipairs(lines) do
  430.         if newLineWidth > TextService:GetTextSize(line, 14, Enum.Font.Code, Vector2.new(math.huge, math.huge)).X then
  431.             return index
  432.         end
  433.     end
  434.     return #lines + 1
  435. end
  436. local function ensureService(doc: ScriptDocument, name)
  437.     if table.find(services, name) == nil then return end
  438.     local line = `local {name} = game:GetService("{name}")`
  439.     local lines = doc:GetText():split("\n")
  440.     if table.find(lines, line) then return end
  441.    
  442.     local services = {}
  443.     for index, line in ipairs(lines) do
  444.         if line:match('^%s*local%s+[%w_]+%s*=%s*game:GetService%("([%w_]+)"%);?$') then
  445.             services[#services + 1] = line
  446.         end
  447.     end
  448.    
  449.     local index = findIndexSortingByLength(services, line)
  450.     if index > #lines then index = #lines end
  451.     doc:EditTextAsync(line.."\n", index, 1, index, 1)
  452. end
  453.  
  454. local function findLongestSharedPath(lines)
  455.     local paths = {}
  456.     local minLength = math.huge
  457.     local sharedLines = {}
  458.    
  459.     for index, line in ipairs(lines) do
  460.         if line:find("game:GetService") then continue end
  461.         local path = line:match("^%s*local%s+[%w_]+%s*=%s*require%((.*)%)") or line:match("^%s*local%s+[%w_]+%s*=%s*(.*)")
  462.         if path then
  463.             local split = splitPath(path)
  464.             paths[#paths + 1] = split
  465.             sharedLines[index] = line
  466.             minLength = math.min(minLength, #split)
  467.         end
  468.     end
  469.    
  470.     if #paths < 2 then return end
  471.    
  472.     local commonPrefix = {}
  473.     for index = 1, minLength, 1 do
  474.         local currentPart = paths[1][index]
  475.         local allMatch = true
  476.         for _, path in pairs(paths) do
  477.             if path[index] ~= currentPart then
  478.                 allMatch = false
  479.                 break
  480.             end
  481.         end
  482.         if allMatch then
  483.             commonPrefix[index] = currentPart
  484.         else
  485.             break
  486.         end
  487.     end
  488.    
  489.     if #commonPrefix == 0 then return end
  490.    
  491.     return wrapPath(table.concat(commonPrefix, ".")), sharedLines
  492. end
  493.  
  494. local function escapePattern(text)
  495.     local specialCharacters = {"%", ".", "(", ")", "[", "]", "+", "-", "*", "?", "^", "$"}
  496.     for _, char in ipairs(specialCharacters) do
  497.         text = text:gsub("%"..char, "%%"..char)
  498.     end
  499.     return text
  500. end
  501.  
  502. local function getPathDestination(path)
  503.     local bracketIndex = path:match("%[\"[^\"]*\"%]$")
  504.    
  505.     if bracketIndex then
  506.         return bracketIndex:sub(3, -3)
  507.     else
  508.         local segments = {}
  509.         for segment in string.gmatch(path, "[^%.]+") do
  510.             table.insert(segments, segment)
  511.         end
  512.         local lastSegment = segments[#segments]
  513.         local lastBracketed = lastSegment:match("%[\"[^\"]*\"%]$")
  514.         if lastBracketed then
  515.             return lastBracketed:sub(3, -3)
  516.         else
  517.             return lastSegment
  518.         end
  519.     end
  520. end
  521. local function optimizePathReferences(doc: ScriptDocument)
  522.     local lastSharedPath = nil
  523.     while true do
  524.         local lines = doc:GetText():split("\n")
  525.         local sharedPath, lines = findLongestSharedPath(lines)
  526.         if not sharedPath then break end
  527.         lastSharedPath = sharedPath
  528.        
  529.         local variable = nil
  530.         for index, line in pairs(lines) do
  531.             local path = line:match("local%s+[%w_]+%s*=%s*require%((.*)%)") or line:match("local%s+[%w_]+%s*=%s*(.*)")
  532.             if path and unwrapPath(path) == sharedPath then
  533.                 variable = line:match("^local%s+([%a_][%w_]*)%s*=%s*.+$")
  534.             end
  535.         end
  536.         local addition = false
  537.         if not variable then
  538.             local name = nameFormat(getPathDestination(sharedPath):gsub("%s", ""))
  539.             local firstIndex = math.huge
  540.             for index in pairs(lines) do
  541.                 if index < firstIndex then
  542.                     firstIndex = index
  543.                 end
  544.             end
  545.             doc:EditTextAsync(`local {name} = {sharedPath}\n`, firstIndex, 1, firstIndex, 1)
  546.             variable = name
  547.             addition = true
  548.         end
  549.        
  550.         if addition then
  551.             for index, line in pairs(lines) do
  552.                 doc:EditTextAsync(line:gsub(escapePattern(sharedPath), variable), index + 1, 1, index + 1, #line + 1)
  553.             end
  554.         else
  555.             for index, line in pairs(lines) do
  556.                 doc:EditTextAsync(line:gsub(escapePattern(sharedPath), variable), index, 1, index, #line + 1)
  557.             end
  558.         end
  559.     end
  560. end
  561.  
  562. connections[#connections + 1] = ScriptEditorService.TextDocumentDidChange:Connect(function(doc: ScriptDocument, changes: {ScriptChange})
  563.     for _, change in ipairs(changes) do
  564.         local lineIndex = changes[#changes].range.start.line
  565.         local line = doc:GetLine(lineIndex)
  566.         if line:match("^%s*;;") then
  567.             local selection = Selection:Get()
  568.             if #selection == 1 then
  569.                 selection = selection[1]
  570.                
  571.                 local src = doc:GetScript()
  572.                 if selection == src then return end
  573.                
  574.                 if game:FindService(selection.ClassName) then
  575.                     if selection:IsA("Workspace") then return end
  576.                    
  577.                     local name = selection.ClassName
  578.                     doc:EditTextAsync(`local {name} = game:GetService("{name}")`, lineIndex, 1, lineIndex, #line + 1)
  579.                 else
  580.                     local lines = doc:GetText():split("\n")
  581.                     local path = getVariablePath(getRelativePath(src, selection), lines)
  582.                    
  583.                     if selection:IsA("ModuleScript") then
  584.                         doc:EditTextAsync(`local {nameFormat(selection.Name)} = require({path})`, lineIndex, 1, lineIndex, #line + 1)
  585.                     else
  586.                         doc:EditTextAsync(`local {nameFormat(selection.Name)} = {path}`, lineIndex, 1, lineIndex, #line + 1)
  587.                     end
  588.                     ensureService(doc, path:match('^([%a_][%w_]*)'))
  589.                     optimizePathReferences(doc)
  590.                 end
  591.             end
  592.         elseif line:match("^%s*local%s+%w[%w_]*%s*=%s*;;$") then
  593.             local selection = Selection:Get()
  594.             if #selection == 1 then
  595.                 selection = selection[1]
  596.                
  597.                 local src = doc:GetScript()
  598.                 if selection == src then return end
  599.                
  600.                 if game:FindService(selection.ClassName) then
  601.                     if selection:IsA("Workspace") then return end
  602.                    
  603.                     local name = selection.ClassName
  604.                     doc:EditTextAsync(`game:GetService("{name}")`, lineIndex, #line - 1, lineIndex, #line + 1)
  605.                 else
  606.                     local lines = doc:GetText():split("\n")
  607.                     local path = getVariablePath(getRelativePath(src, selection), lines)
  608.                    
  609.                     if selection:IsA("ModuleScript") then
  610.                         doc:EditTextAsync(`require({path})`, lineIndex, #line - 1, lineIndex, #line + 1)
  611.                     else
  612.                         doc:EditTextAsync(path, lineIndex, #line - 1, lineIndex, #line + 1)
  613.                     end
  614.                     ensureService(doc, path:match('^([%a_][%w_]*)'))
  615.                     optimizePathReferences(doc)
  616.                 end
  617.             end
  618.         else
  619.             local text = change.text
  620.             if text:match("^%s*local%s+%w[%w_]*%s*=%s*") then
  621.                 for _, item in ipairs(items) do
  622.                     if item.textEdit.newText == text then
  623.                         if item.module == nil then
  624.                             doc:EditTextAsync("", lineIndex, 1, lineIndex, #line + 1)
  625.                             ensureService(doc, item.serviceClass)
  626.                         else
  627.                             local path = item.path
  628.                             if path then
  629.                                 ensureService(doc, path:match('^([%a_][%w_]*)'))
  630.                                 optimizePathReferences(doc)
  631.                             end
  632.                             break
  633.                         end
  634.                     end
  635.                 end
  636.             end
  637.         end
  638.     end
  639. end)
  640.  
  641. for _, service in ipairs(services) do
  642.     local item = {
  643.         label = service,
  644.         preselect = true,
  645.         kind = Enum.CompletionItemKind.Module,
  646.         tags = {Enum.CompletionItemTag.TypeCorrect},
  647.         detail = `GetService("{service}")`,
  648.         serviceClass = service,
  649.         textEdit = {
  650.             newText = "",
  651.             replace = {
  652.                 start = {},
  653.                 ["end"] = {},
  654.             },
  655.         },
  656.     }
  657.     items[#items + 1] = item
  658. end
  659.  
  660. plugin.Unloading:Connect(function()
  661.     for _, connection in ipairs(connections) do
  662.         connection:Disconnect()
  663.     end
  664.     for _, connections in ipairs(modules) do
  665.         for _, connection in ipairs(connections) do
  666.             connection:Disconnect()
  667.         end
  668.     end
  669.     table.clear(modules)
  670.     table.clear(items)
  671.     ScriptEditorService:DeregisterAutocompleteCallback(AutocompleteName)
  672. end)
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement