diff --git a/CHANGELOG.md b/CHANGELOG.md index 16a34117..77bee15b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Deprecated + +- Deprecated `luau-lsp.types.roblox` setting in favour of `luau-lsp.platform.type` + +### Added + +- Added `luau-lsp.platform.type` to separate platform-specific functionality from the main LSP +- Added option `--platform` to analyze CLI to make configuring `luau-lsp.platform.type` more convenient + ## [1.29.1] - 2024-05-19 ### Changed diff --git a/CMakeLists.txt b/CMakeLists.txt index d2347bfe..07043f9e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,15 +15,23 @@ target_sources(Luau.LanguageServer PRIVATE src/Uri.cpp src/WorkspaceFileResolver.cpp src/Workspace.cpp - src/Sourcemap.cpp src/TextDocument.cpp src/Client.cpp src/DocumentationParser.cpp src/LuauExt.cpp src/IostreamHelpers.cpp src/Utils.cpp - src/StudioPlugin.cpp src/CliConfigurationParser.cpp + src/platform/LSPPlatform.cpp + src/platform/roblox/RobloxCodeAction.cpp + src/platform/roblox/RobloxColorProvider.cpp + src/platform/roblox/RobloxCompletion.cpp + src/platform/roblox/RobloxFileResolver.cpp + src/platform/roblox/RobloxLanguageServer.cpp + src/platform/roblox/RobloxLuauExt.cpp + src/platform/roblox/RobloxSourcemap.cpp + src/platform/roblox/RobloxSourceNode.cpp + src/platform/roblox/RobloxStudioPlugin.cpp src/operations/Diagnostics.cpp src/operations/Completion.cpp src/operations/DocumentSymbol.cpp diff --git a/README.md b/README.md index 135fdd64..834c1dc2 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ looking for any specific features, please get in touch! ### For Rojo Users (requires `v7.3.0+`) By default, the latest Roblox type definitions and documentation are preloaded out of the box. -This can be disabled by configuring `luau-lsp.types.roblox`. +This can be disabled by configuring `luau-lsp.platform.type`. The language server uses Rojo-style sourcemaps to resolve DataModel instance trees for intellisense. This is done by running `rojo sourcemap --watch default.project.json --output sourcemap.json`. diff --git a/editors/code/package.json b/editors/code/package.json index 336fbd92..58bc3478 100644 --- a/editors/code/package.json +++ b/editors/code/package.json @@ -138,6 +138,16 @@ ], "scope": "resource" }, + "luau-lsp.platform.type": { + "markdownDescription": "Platform-specific support features", + "type": "string", + "enum": [ + "standard", + "roblox" + ], + "scope": "window", + "default": "roblox" + }, "luau-lsp.sourcemap.enabled": { "markdownDescription": "Whether Rojo sourcemap parsing is enabled", "type": "boolean", @@ -235,7 +245,9 @@ "scope": "window", "tags": [ "usesOnlineServices" - ] + ], + "markdownDeprecationMessage": "**Deprecated**: Please use `#luau-lsp.platform.type#` instead.", + "deprecationMessage": "Deprecated: Please use luau-lsp.platform.type instead." }, "luau-lsp.types.robloxSecurityLevel": { "markdownDescription": "Security Level to use in the Roblox API definitions", diff --git a/editors/code/src/extension.ts b/editors/code/src/extension.ts index 512304c7..27057788 100644 --- a/editors/code/src/extension.ts +++ b/editors/code/src/extension.ts @@ -1,6 +1,5 @@ import * as vscode from "vscode"; import * as os from "os"; -import * as path from "path"; import fetch from "node-fetch"; import { Executable, @@ -9,144 +8,30 @@ import { LanguageClientOptions, } from "vscode-languageclient/node"; -import { Server } from "http"; -import express from "express"; -import { spawn, ChildProcess } from "child_process"; import { registerComputeBytecode, registerComputeCompilerRemarks, } from "./bytecode"; +import * as roblox from "./roblox"; +import * as utils from "./utils"; + +export type PlatformContext = { client: LanguageClient | undefined }; +export type AddArgCallback = ( + argument: string, + mode?: "All" | "Prod" | "Debug" +) => void; + let client: LanguageClient | undefined = undefined; -let pluginServer: Server | undefined = undefined; +let platformContext: PlatformContext = { client: undefined }; const clientDisposables: vscode.Disposable[] = []; -const CURRENT_VERSION_TXT = - "https://raw.githubusercontent.com/CloneTrooper1019/Roblox-Client-Tracker/roblox/version.txt"; -const API_DOCS = - "https://raw.githubusercontent.com/MaximumADHD/Roblox-Client-Tracker/roblox/api-docs/en-us.json"; const CURRENT_FFLAGS = "https://clientsettingscdn.roblox.com/v1/settings/application?applicationName=PCDesktopClient"; type FFlags = Record; type FFlagsEndpoint = { applicationSettings: FFlags }; -const SECURITY_LEVELS = [ - "None", - "LocalUserSecurity", - "PluginSecurity", - "RobloxScriptSecurity", -]; -const globalTypesEndpointForSecurityLevel = (securityLevel: string) => { - return `https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.${securityLevel}.d.luau`; -}; - -const globalTypesUri = ( - context: vscode.ExtensionContext, - securityLevel: string, - mode: "Prod" | "Debug" -) => { - if (mode === "Prod") { - return vscode.Uri.joinPath( - context.globalStorageUri, - `globalTypes.${securityLevel}.d.luau` - ); - } else { - return vscode.Uri.joinPath( - context.extensionUri, - "..", - "..", - `scripts/globalTypes.${securityLevel}.d.luau` - ); - } -}; - -const apiDocsUri = (context: vscode.ExtensionContext) => { - return vscode.Uri.joinPath(context.globalStorageUri, "api-docs.json"); -}; - -const exists = (uri: vscode.Uri): Thenable => { - return vscode.workspace.fs.stat(uri).then( - () => true, - () => false - ); -}; - -const basenameUri = (uri: vscode.Uri): string => { - return path.basename(uri.fsPath); -}; - -const resolveUri = (uri: vscode.Uri, ...paths: string[]): vscode.Uri => { - return vscode.Uri.file(path.resolve(uri.fsPath, ...paths)); -}; - -const downloadApiDefinitions = async (context: vscode.ExtensionContext) => { - try { - return vscode.window.withProgress( - { - location: vscode.ProgressLocation.Window, - title: "Luau: Updating API Definitions", - cancellable: false, - }, - async () => { - return Promise.all([ - ...SECURITY_LEVELS.map((level) => - fetch(globalTypesEndpointForSecurityLevel(level)) - .then((r) => r.arrayBuffer()) - .then((data) => - vscode.workspace.fs.writeFile( - globalTypesUri(context, level, "Prod"), - new Uint8Array(data) - ) - ) - ), - fetch(API_DOCS) - .then((r) => r.arrayBuffer()) - .then((data) => - vscode.workspace.fs.writeFile( - apiDocsUri(context), - new Uint8Array(data) - ) - ), - ]); - } - ); - } catch (err) { - vscode.window.showErrorMessage( - "Failed to retrieve API information: " + err - ); - } -}; - -const updateApiInfo = async (context: vscode.ExtensionContext) => { - try { - const latestVersion = await fetch(CURRENT_VERSION_TXT).then((r) => - r.text() - ); - const currentVersion = context.globalState.get( - "current-api-version" - ); - const mustUpdate = - ( - await Promise.all( - SECURITY_LEVELS.map( - async (level) => - await exists(globalTypesUri(context, level, "Prod")) - ) - ) - ).some((doesExist) => !doesExist) || !(await exists(apiDocsUri(context))); - - if (!currentVersion || currentVersion !== latestVersion || mustUpdate) { - context.globalState.update("current-api-version", latestVersion); - return downloadApiDefinitions(context); - } - } catch (err) { - vscode.window.showWarningMessage( - "Failed to retrieve API information: " + err - ); - } -}; - const getFFlags = async () => { return vscode.window.withProgress( { @@ -161,224 +46,6 @@ const getFFlags = async () => { ); }; -const getRojoProjectFile = async ( - workspaceFolder: vscode.WorkspaceFolder, - config: vscode.WorkspaceConfiguration -) => { - let projectFile = - config.get("rojoProjectFile") ?? "default.project.json"; - const projectFileUri = resolveUri(workspaceFolder.uri, projectFile); - - if (await exists(projectFileUri)) { - return projectFile; - } - - // Search if there is a *.project.json file present in this workspace. - const foundProjectFiles = await vscode.workspace.findFiles( - new vscode.RelativePattern(workspaceFolder.uri, "*.project.json") - ); - - if (foundProjectFiles.length === 0) { - vscode.window.showWarningMessage( - `Unable to find project file ${projectFile}. Please configure a file in settings` - ); - return undefined; - } else if (foundProjectFiles.length === 1) { - const fileName = basenameUri(foundProjectFiles[0]); - const option = await vscode.window.showWarningMessage( - `Unable to find project file ${projectFile}. We found ${fileName} available`, - `Set project file to ${fileName}`, - "Cancel" - ); - - if (option === `Set project file to ${fileName}`) { - config.update("rojoProjectFile", fileName); - return fileName; - } else { - return undefined; - } - } else { - const option = await vscode.window.showWarningMessage( - `Unable to find project file ${projectFile}. We found ${foundProjectFiles.length} files available`, - "Select project file", - "Cancel" - ); - if (option === "Select project file") { - const files = foundProjectFiles.map((file) => basenameUri(file)); - const selectedFile = await vscode.window.showQuickPick(files); - if (selectedFile) { - config.update("rojoProjectFile", selectedFile); - selectedFile; - } else { - return undefined; - } - } else { - return undefined; - } - } - - return undefined; -}; - -const sourcemapGeneratorProcesses: Map = - new Map(); - -const stopSourcemapGeneration = async ( - workspaceFolder: vscode.WorkspaceFolder -) => { - const process = sourcemapGeneratorProcesses.get(workspaceFolder); - if (process) { - process.kill(); - } - sourcemapGeneratorProcesses.delete(workspaceFolder); -}; - -const startSourcemapGeneration = async ( - workspaceFolder: vscode.WorkspaceFolder -) => { - stopSourcemapGeneration(workspaceFolder); - const config = vscode.workspace.getConfiguration( - "luau-lsp.sourcemap", - workspaceFolder - ); - if (!config.get("enabled") || !config.get("autogenerate")) { - return; - } - - // Check if the project file exists - const projectFile = await getRojoProjectFile(workspaceFolder, config); - if (!projectFile) { - return; - } - - const loggingFunc = client ? client.info.bind(client) : console.log; - loggingFunc( - `Starting sourcemap generation for ${ - workspaceFolder.name - } (${workspaceFolder.uri.toString(true)})` - ); - - const workspacePath = workspaceFolder.uri.fsPath; - const rojoPath = config.get("rojoPath") ?? "rojo"; - const args = [ - "sourcemap", - projectFile, - "--watch", - "--output", - "sourcemap.json", - ]; - if (config.get("includeNonScripts")) { - args.push("--include-non-scripts"); - } - - const childProcess = spawn(rojoPath, args, { - cwd: workspacePath, - }); - - sourcemapGeneratorProcesses.set(workspaceFolder, childProcess); - - let stderr = ""; - childProcess.stderr.on("data", (data) => { - stderr += data; - }); - - childProcess.on("close", (code, signal) => { - sourcemapGeneratorProcesses.delete(workspaceFolder); - if (childProcess.killed) { - return; - } - if (code !== 0) { - let output = `Failed to update sourcemap for ${workspaceFolder.name}: `; - let options = ["Retry"]; - - if (stderr.includes("Found argument 'sourcemap' which wasn't expected")) { - output += - "Your Rojo version doesn't have sourcemap support. Upgrade to Rojo v7.3.0+"; - } else if ( - stderr.includes("Found argument '--watch' which wasn't expected") - ) { - output += - "Your Rojo version doesn't have sourcemap watching support. Upgrade to Rojo v7.3.0+"; - } else if ( - stderr.includes("is not recognized") || - stderr.includes("ENOENT") - ) { - output += - "Rojo not found. Please install Rojo to your PATH or disable sourcemap autogeneration"; - options.push("Configure Settings"); - } else { - output += stderr; - } - - vscode.window.showWarningMessage(output, ...options).then((value) => { - if (value === "Retry") { - startSourcemapGeneration(workspaceFolder); - } else if (value === "Configure Settings") { - vscode.commands.executeCommand( - "workbench.action.openSettings", - "luau-lsp.sourcemap" - ); - } - }); - } - }); -}; - -const startPluginServer = async () => { - if (pluginServer) { - return; - } - - const app = express(); - app.use( - express.json({ - limit: "3mb", - }) - ); - - app.post("/full", (req, res) => { - if (!client) { - return res.sendStatus(500); - } - - if (req.body.tree) { - client.sendNotification("$/plugin/full", req.body.tree); - res.sendStatus(200); - } else { - res.sendStatus(400); - } - }); - - app.post("/clear", (_req, res) => { - if (!client) { - return res.sendStatus(500); - } - - client.sendNotification("$/plugin/clear"); - res.sendStatus(200); - }); - - const port = vscode.workspace.getConfiguration("luau-lsp.plugin").get("port"); - pluginServer = app.listen(port); - - vscode.window.showInformationMessage( - `Luau Language Server Studio Plugin is now listening on port ${port}` - ); -}; - -const stopPluginServer = async (isDeactivating = false) => { - if (pluginServer) { - pluginServer.close(); - pluginServer = undefined; - - if (!isDeactivating) { - vscode.window.showInformationMessage( - `Luau Language Server Studio Plugin has disconnected` - ); - } - } -}; - const startLanguageServer = async (context: vscode.ExtensionContext) => { for (const disposable of clientDisposables) { disposable.dispose(); @@ -402,22 +69,9 @@ const startLanguageServer = async (context: vscode.ExtensionContext) => { } }; - // Load roblox type definitions + await roblox.preLanguageServerStart(platformContext, context, addArg); + const typesConfig = vscode.workspace.getConfiguration("luau-lsp.types"); - if (typesConfig.get("roblox")) { - const securityLevel = - typesConfig.get("robloxSecurityLevel") ?? "PluginSecurity"; - await updateApiInfo(context); - addArg( - `--definitions=${globalTypesUri(context, securityLevel, "Prod").fsPath}`, - "Prod" - ); - addArg( - `--definitions=${globalTypesUri(context, securityLevel, "Debug").fsPath}`, - "Debug" - ); - addArg(`--docs=${apiDocsUri(context).fsPath}`); - } // Load extra type definitions const definitionFiles = typesConfig.get("definitionFiles"); @@ -425,14 +79,14 @@ const startLanguageServer = async (context: vscode.ExtensionContext) => { for (const definitionPath of definitionFiles) { let uri; if (vscode.workspace.workspaceFolders) { - uri = resolveUri( + uri = utils.resolveUri( vscode.workspace.workspaceFolders[0].uri, definitionPath ); } else { uri = vscode.Uri.file(definitionPath); } - if (await exists(uri)) { + if (await utils.exists(uri)) { addArg(`--definitions=${uri.fsPath}`); } else { vscode.window.showWarningMessage( @@ -448,14 +102,14 @@ const startLanguageServer = async (context: vscode.ExtensionContext) => { for (const documentationPath of documentationFiles) { let uri; if (vscode.workspace.workspaceFolders) { - uri = resolveUri( + uri = utils.resolveUri( vscode.workspace.workspaceFolders[0].uri, documentationPath ); } else { uri = vscode.Uri.file(documentationPath); } - if (await exists(uri)) { + if (await utils.exists(uri)) { addArg(`--docs=${uri.fsPath}`); } else { vscode.window.showWarningMessage( @@ -549,6 +203,8 @@ const startLanguageServer = async (context: vscode.ExtensionContext) => { clientOptions ); + platformContext.client = client; + // Register commands client.onNotification("$/command", (params) => { vscode.commands.executeCommand(params.command, params.data); @@ -564,21 +220,7 @@ const startLanguageServer = async (context: vscode.ExtensionContext) => { export async function activate(context: vscode.ExtensionContext) { console.log("Luau LSP activated"); - context.subscriptions.push( - vscode.commands.registerCommand("luau-lsp.updateApi", async () => { - await downloadApiDefinitions(context); - vscode.window - .showInformationMessage( - "API Types have been updated, reload server to take effect.", - "Reload Language Server" - ) - .then((command) => { - if (command === "Reload Language Server") { - vscode.commands.executeCommand("luau-lsp.reloadServer"); - } - }); - }) - ); + await roblox.onActivate(platformContext, context); context.subscriptions.push( vscode.commands.registerCommand("luau-lsp.reloadServer", async () => { @@ -587,41 +229,9 @@ export async function activate(context: vscode.ExtensionContext) { }) ); - const startSourcemapGenerationForAllFolders = () => { - if (vscode.workspace.workspaceFolders) { - for (const folder of vscode.workspace.workspaceFolders) { - startSourcemapGeneration(folder); - } - } - }; - - context.subscriptions.push( - vscode.commands.registerCommand( - "luau-lsp.regenerateSourcemap", - startSourcemapGenerationForAllFolders - ) - ); - context.subscriptions.push( vscode.workspace.onDidChangeConfiguration((e) => { - if (e.affectsConfiguration("luau-lsp.sourcemap")) { - if (vscode.workspace.workspaceFolders) { - for (const folder of vscode.workspace.workspaceFolders) { - const config = vscode.workspace.getConfiguration( - "luau-lsp.sourcemap", - folder - ); - if ( - !config.get("enabled") || - !config.get("autogenerate") - ) { - stopSourcemapGeneration(folder); - } else { - startSourcemapGeneration(folder); - } - } - } - } else if (e.affectsConfiguration("luau-lsp.fflags")) { + if (e.affectsConfiguration("luau-lsp.fflags")) { vscode.window .showInformationMessage( "Luau FFlags have been changed, reload server for this to take effect.", @@ -632,7 +242,10 @@ export async function activate(context: vscode.ExtensionContext) { vscode.commands.executeCommand("luau-lsp.reloadServer"); } }); - } else if (e.affectsConfiguration("luau-lsp.types")) { + } else if ( + e.affectsConfiguration("luau-lsp.types") || + e.affectsConfiguration("luau-lsp.platform.type") + ) { vscode.window .showInformationMessage( "Luau type definitions have been changed, reload server for this to take effect.", @@ -643,37 +256,18 @@ export async function activate(context: vscode.ExtensionContext) { vscode.commands.executeCommand("luau-lsp.reloadServer"); } }); - } else if (e.affectsConfiguration("luau-lsp.plugin")) { - if ( - vscode.workspace - .getConfiguration("luau-lsp.plugin") - .get("enabled") - ) { - stopPluginServer(true); - startPluginServer(); - } else { - stopPluginServer(); - } } }) ); - startSourcemapGenerationForAllFolders(); await startLanguageServer(context); - if ( - vscode.workspace.getConfiguration("luau-lsp.plugin").get("enabled") - ) { - startPluginServer(); - } + await roblox.postLanguageServerStart(platformContext, context); } export async function deactivate() { return Promise.allSettled([ - ...Array.from(sourcemapGeneratorProcesses.keys()).map((workspace) => - stopSourcemapGeneration(workspace) - ), - stopPluginServer(true), + ...roblox.onDeactivate(), client?.stop(), clientDisposables.map((disposable) => disposable.dispose()), ]); diff --git a/editors/code/src/roblox.ts b/editors/code/src/roblox.ts new file mode 100644 index 00000000..125f2f2c --- /dev/null +++ b/editors/code/src/roblox.ts @@ -0,0 +1,460 @@ +import * as vscode from "vscode"; +import { Server } from "http"; +import express from "express"; +import { spawn, ChildProcess } from "child_process"; +import { LanguageClient } from "vscode-languageclient/node"; +import { AddArgCallback, PlatformContext } from "./extension"; + +import * as utils from "./utils"; + +let pluginServer: Server | undefined = undefined; + +const CURRENT_VERSION_TXT = + "https://raw.githubusercontent.com/CloneTrooper1019/Roblox-Client-Tracker/roblox/version.txt"; +const API_DOCS = + "https://raw.githubusercontent.com/MaximumADHD/Roblox-Client-Tracker/roblox/api-docs/en-us.json"; + +const SECURITY_LEVELS = [ + "None", + "LocalUserSecurity", + "PluginSecurity", + "RobloxScriptSecurity", +]; + +const globalTypesEndpointForSecurityLevel = (securityLevel: string) => { + return `https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.${securityLevel}.d.luau`; +}; + +const globalTypesUri = ( + context: vscode.ExtensionContext, + securityLevel: string, + mode: "Prod" | "Debug" +) => { + if (mode === "Prod") { + return vscode.Uri.joinPath( + context.globalStorageUri, + `globalTypes.${securityLevel}.d.luau` + ); + } else { + return vscode.Uri.joinPath( + context.extensionUri, + "..", + "..", + `scripts/globalTypes.${securityLevel}.d.luau` + ); + } +}; + +const apiDocsUri = (context: vscode.ExtensionContext) => { + return vscode.Uri.joinPath(context.globalStorageUri, "api-docs.json"); +}; + +const downloadApiDefinitions = async (context: vscode.ExtensionContext) => { + try { + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.Window, + title: "Luau: Updating API Definitions", + cancellable: false, + }, + async () => { + return Promise.all([ + ...SECURITY_LEVELS.map((level) => + fetch(globalTypesEndpointForSecurityLevel(level)) + .then((r) => r.arrayBuffer()) + .then((data) => + vscode.workspace.fs.writeFile( + globalTypesUri(context, level, "Prod"), + new Uint8Array(data) + ) + ) + ), + fetch(API_DOCS) + .then((r) => r.arrayBuffer()) + .then((data) => + vscode.workspace.fs.writeFile( + apiDocsUri(context), + new Uint8Array(data) + ) + ), + ]); + } + ); + } catch (err) { + vscode.window.showErrorMessage( + "Failed to retrieve API information: " + err + ); + } +}; + +const updateApiInfo = async (context: vscode.ExtensionContext) => { + try { + const latestVersion = await fetch(CURRENT_VERSION_TXT).then((r) => + r.text() + ); + const currentVersion = context.globalState.get( + "current-api-version" + ); + const mustUpdate = + ( + await Promise.all( + SECURITY_LEVELS.map( + async (level) => + await utils.exists(globalTypesUri(context, level, "Prod")) + ) + ) + ).some((doesExist) => !doesExist) || + !(await utils.exists(apiDocsUri(context))); + + if (!currentVersion || currentVersion !== latestVersion || mustUpdate) { + context.globalState.update("current-api-version", latestVersion); + return downloadApiDefinitions(context); + } + } catch (err) { + vscode.window.showWarningMessage( + "Failed to retrieve API information: " + err + ); + } +}; + +const getRojoProjectFile = async ( + workspaceFolder: vscode.WorkspaceFolder, + config: vscode.WorkspaceConfiguration +) => { + let projectFile = + config.get("rojoProjectFile") ?? "default.project.json"; + const projectFileUri = utils.resolveUri(workspaceFolder.uri, projectFile); + + if (await utils.exists(projectFileUri)) { + return projectFile; + } + + // Search if there is a *.project.json file present in this workspace. + const foundProjectFiles = await vscode.workspace.findFiles( + new vscode.RelativePattern(workspaceFolder.uri, "*.project.json") + ); + + if (foundProjectFiles.length === 0) { + vscode.window.showWarningMessage( + `Unable to find project file ${projectFile}. Please configure a file in settings` + ); + return undefined; + } else if (foundProjectFiles.length === 1) { + const fileName = utils.basenameUri(foundProjectFiles[0]); + const option = await vscode.window.showWarningMessage( + `Unable to find project file ${projectFile}. We found ${fileName} available`, + `Set project file to ${fileName}`, + "Cancel" + ); + + if (option === `Set project file to ${fileName}`) { + config.update("rojoProjectFile", fileName); + return fileName; + } else { + return undefined; + } + } else { + const option = await vscode.window.showWarningMessage( + `Unable to find project file ${projectFile}. We found ${foundProjectFiles.length} files available`, + "Select project file", + "Cancel" + ); + if (option === "Select project file") { + const files = foundProjectFiles.map((file) => utils.basenameUri(file)); + const selectedFile = await vscode.window.showQuickPick(files); + if (selectedFile) { + config.update("rojoProjectFile", selectedFile); + selectedFile; + } else { + return undefined; + } + } else { + return undefined; + } + } + + return undefined; +}; + +const sourcemapGeneratorProcesses: Map = + new Map(); + +const stopSourcemapGeneration = async ( + workspaceFolder: vscode.WorkspaceFolder +) => { + const process = sourcemapGeneratorProcesses.get(workspaceFolder); + if (process) { + process.kill(); + } + sourcemapGeneratorProcesses.delete(workspaceFolder); +}; + +const startSourcemapGeneration = async ( + client: LanguageClient | undefined, + workspaceFolder: vscode.WorkspaceFolder +) => { + stopSourcemapGeneration(workspaceFolder); + + const config = vscode.workspace.getConfiguration( + "luau-lsp.sourcemap", + workspaceFolder + ); + + if (!config.get("enabled") || !config.get("autogenerate")) { + return; + } + + // Check if the project file exists + const projectFile = await getRojoProjectFile(workspaceFolder, config); + if (!projectFile) { + return; + } + + const loggingFunc = client ? client.info.bind(client) : console.log; + loggingFunc( + `Starting sourcemap generation for ${ + workspaceFolder.name + } (${workspaceFolder.uri.toString(true)})` + ); + + const workspacePath = workspaceFolder.uri.fsPath; + const rojoPath = config.get("rojoPath") ?? "rojo"; + const args = [ + "sourcemap", + projectFile, + "--watch", + "--output", + "sourcemap.json", + ]; + + if (config.get("includeNonScripts")) { + args.push("--include-non-scripts"); + } + + const childProcess = spawn(rojoPath, args, { + cwd: workspacePath, + }); + + sourcemapGeneratorProcesses.set(workspaceFolder, childProcess); + + let stderr = ""; + childProcess.stderr.on("data", (data) => { + stderr += data; + }); + + childProcess.on("close", (code, signal) => { + sourcemapGeneratorProcesses.delete(workspaceFolder); + if (childProcess.killed) { + return; + } + if (code !== 0) { + let output = `Failed to update sourcemap for ${workspaceFolder.name}: `; + let options = ["Retry"]; + + if (stderr.includes("Found argument 'sourcemap' which wasn't expected")) { + output += + "Your Rojo version doesn't have sourcemap support. Upgrade to Rojo v7.3.0+"; + } else if ( + stderr.includes("Found argument '--watch' which wasn't expected") + ) { + output += + "Your Rojo version doesn't have sourcemap watching support. Upgrade to Rojo v7.3.0+"; + } else if ( + stderr.includes("is not recognized") || + stderr.includes("ENOENT") + ) { + output += + "Rojo not found. Please install Rojo to your PATH or disable sourcemap autogeneration"; + options.push("Configure Settings"); + } else { + output += stderr; + } + + vscode.window.showWarningMessage(output, ...options).then((value) => { + if (value === "Retry") { + startSourcemapGeneration(client, workspaceFolder); + } else if (value === "Configure Settings") { + vscode.commands.executeCommand( + "workbench.action.openSettings", + "luau-lsp.sourcemap" + ); + } + }); + } + }); +}; + +const startPluginServer = async (client: LanguageClient | undefined) => { + if (pluginServer) { + return; + } + + const app = express(); + app.use( + express.json({ + limit: "3mb", + }) + ); + + app.post("/full", (req, res) => { + if (!client) { + return res.sendStatus(500); + } + + if (req.body.tree) { + client.sendNotification("$/plugin/full", req.body.tree); + res.sendStatus(200); + } else { + res.sendStatus(400); + } + }); + + app.post("/clear", (_req, res) => { + if (!client) { + return res.sendStatus(500); + } + + client.sendNotification("$/plugin/clear"); + res.sendStatus(200); + }); + + const port = vscode.workspace.getConfiguration("luau-lsp.plugin").get("port"); + pluginServer = app.listen(port); + + vscode.window.showInformationMessage( + `Luau Language Server Studio Plugin is now listening on port ${port}` + ); +}; + +const stopPluginServer = async (isDeactivating = false) => { + if (pluginServer) { + pluginServer.close(); + pluginServer = undefined; + + if (!isDeactivating) { + vscode.window.showInformationMessage( + `Luau Language Server Studio Plugin has disconnected` + ); + } + } +}; + +export const onActivate = async ( + platformContext: PlatformContext, + context: vscode.ExtensionContext +) => { + context.subscriptions.push( + vscode.commands.registerCommand("luau-lsp.updateApi", async () => { + await downloadApiDefinitions(context); + vscode.window + .showInformationMessage( + "API Types have been updated, reload server to take effect.", + "Reload Language Server" + ) + .then((command) => { + if (command === "Reload Language Server") { + vscode.commands.executeCommand("luau-lsp.reloadServer"); + } + }); + }) + ); + + const startSourcemapGenerationForAllFolders = () => { + if (vscode.workspace.workspaceFolders) { + for (const folder of vscode.workspace.workspaceFolders) { + startSourcemapGeneration(platformContext.client, folder); + } + } + }; + + context.subscriptions.push( + vscode.commands.registerCommand( + "luau-lsp.regenerateSourcemap", + startSourcemapGenerationForAllFolders + ) + ); + + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration("luau-lsp.sourcemap")) { + if (vscode.workspace.workspaceFolders) { + for (const folder of vscode.workspace.workspaceFolders) { + const config = vscode.workspace.getConfiguration( + "luau-lsp.sourcemap", + folder + ); + + if ( + !config.get("enabled") || + !config.get("autogenerate") + ) { + stopSourcemapGeneration(folder); + } else { + startSourcemapGeneration(platformContext.client, folder); + } + } + } + } else if (e.affectsConfiguration("luau-lsp.plugin")) { + if ( + vscode.workspace + .getConfiguration("luau-lsp.plugin") + .get("enabled") + ) { + stopPluginServer(true); + startPluginServer(platformContext.client); + } else { + stopPluginServer(); + } + } + }) + ); + + startSourcemapGenerationForAllFolders(); +}; + +export const preLanguageServerStart = async ( + _: PlatformContext, + context: vscode.ExtensionContext, + addArg: AddArgCallback +) => { + // Load roblox type definitions + const typesConfig = vscode.workspace.getConfiguration("luau-lsp.types"); + const platformConfig = vscode.workspace.getConfiguration("luau-lsp.platform"); + if ( + typesConfig.get("roblox") || + platformConfig.get("type") === "roblox" + ) { + const securityLevel = + typesConfig.get("robloxSecurityLevel") ?? "PluginSecurity"; + await updateApiInfo(context); + addArg( + `--definitions=${globalTypesUri(context, securityLevel, "Prod").fsPath}`, + "Prod" + ); + addArg( + `--definitions=${globalTypesUri(context, securityLevel, "Debug").fsPath}`, + "Debug" + ); + addArg(`--docs=${apiDocsUri(context).fsPath}`); + } +}; + +export const postLanguageServerStart = async ( + platformContext: PlatformContext, + _: vscode.ExtensionContext +) => { + if ( + vscode.workspace.getConfiguration("luau-lsp.plugin").get("enabled") + ) { + startPluginServer(platformContext.client); + } +}; + +export const onDeactivate = () => { + return [ + ...Array.from(sourcemapGeneratorProcesses.keys()).map((workspace) => + stopSourcemapGeneration(workspace) + ), + stopPluginServer(true), + ]; +}; diff --git a/editors/code/src/utils.ts b/editors/code/src/utils.ts new file mode 100644 index 00000000..6c3b7346 --- /dev/null +++ b/editors/code/src/utils.ts @@ -0,0 +1,17 @@ +import * as vscode from "vscode"; +import * as path from "path"; + +export const basenameUri = (uri: vscode.Uri): string => { + return path.basename(uri.fsPath); +}; + +export const exists = (uri: vscode.Uri): Thenable => { + return vscode.workspace.fs.stat(uri).then( + () => true, + () => false + ); +}; + +export const resolveUri = (uri: vscode.Uri, ...paths: string[]): vscode.Uri => { + return vscode.Uri.file(path.resolve(uri.fsPath, ...paths)); +}; diff --git a/src/AnalyzeCli.cpp b/src/AnalyzeCli.cpp index f84c549a..cf09449d 100644 --- a/src/AnalyzeCli.cpp +++ b/src/AnalyzeCli.cpp @@ -3,6 +3,9 @@ #include "Analyze/CliConfigurationParser.hpp" #include "Analyze/CliClient.hpp" +#include "LSP/ClientConfiguration.hpp" +#include "Platform/LSPPlatform.hpp" +#include "Platform/RobloxPlatform.hpp" #include "Luau/ModuleResolver.h" #include "Luau/BuiltinDefinitions.h" #include "Luau/Frontend.h" @@ -14,6 +17,7 @@ #include "glob/glob.hpp" #include #include +#include #include LUAU_FASTFLAG(DebugLuauTimeTracing) @@ -71,11 +75,11 @@ static bool reportError( { auto* fileResolver = static_cast(frontend.fileResolver); std::filesystem::path rootUriPath = fileResolver->rootUri.fsPath(); - auto path = fileResolver->resolveToRealPath(error.moduleName); + auto path = fileResolver->platform->resolveToRealPath(error.moduleName); // For consistency, we want to map the error.moduleName to a relative path (if it is a real path) Luau::ModuleName errorFriendlyName = error.moduleName; - if (!fileResolver->isVirtualPath(error.moduleName)) + if (!fileResolver->platform->isVirtualPath(error.moduleName)) errorFriendlyName = std::filesystem::proximate(*path, rootUriPath).generic_string(); std::string humanReadableName = fileResolver->getHumanReadableModuleName(errorFriendlyName); @@ -264,7 +268,6 @@ int startAnalyze(const argparse::ArgumentParser& program) } } - WorkspaceFileResolver fileResolver; if (baseLuaurc) { @@ -288,16 +291,21 @@ int startAnalyze(const argparse::ArgumentParser& program) fileResolver.rootUri = Uri::file(std::filesystem::current_path()); fileResolver.client = std::make_shared(client); - Luau::Frontend frontend(&fileResolver, &fileResolver, frontendOptions); - if (sourcemapPath) + if (auto platformArg = program.present("--platform")) { - if (auto sourceMapContents = readFile(*sourcemapPath)) - { - fileResolver.updateSourceMap(sourceMapContents.value()); - } + if (platformArg == "standard") + client.configuration.platform.type = LSPPlatformConfig::Standard; + else if (platformArg == "roblox") + client.configuration.platform.type = LSPPlatformConfig::Roblox; } + std::unique_ptr platform = LSPPlatform::getPlatform(client.configuration, &fileResolver); + + fileResolver.platform = platform.get(); + + Luau::Frontend frontend(&fileResolver, &fileResolver, frontendOptions); + Luau::registerBuiltinGlobals(frontend, frontend.globals, /* typeCheckForAutocomplete = */ false); Luau::registerBuiltinGlobals(frontend, frontend.globalsForAutocomplete, /* typeCheckForAutocomplete = */ true); @@ -316,8 +324,7 @@ int startAnalyze(const argparse::ArgumentParser& program) return 1; } - auto loadResult = types::registerDefinitions(frontend, frontend.globals, *definitionsContents, /* typeCheckForAutocomplete = */ false, - types::parseDefinitionsFileMetadata(*definitionsContents)); + auto loadResult = types::registerDefinitions(frontend, frontend.globals, *definitionsContents, /* typeCheckForAutocomplete = */ false); if (!loadResult.success) { fprintf(stderr, "Failed to load definitions\n"); @@ -337,10 +344,30 @@ int startAnalyze(const argparse::ArgumentParser& program) } return 1; } + + platform->mutateRegisteredDefinitions(frontend.globals, types::parseDefinitionsFileMetadata(*definitionsContents)); } - types::registerInstanceTypes(frontend, frontend.globals, frontend.globals.globalTypes, fileResolver, - !program.is_used("--no-strict-dm-types") && client.configuration.diagnostics.strictDatamodelTypes); + if (sourcemapPath) + { + if (client.configuration.platform.type == LSPPlatformConfig::Roblox) + { + auto robloxPlatform = dynamic_cast(platform.get()); + + if (auto sourceMapContents = readFile(*sourcemapPath)) + { + robloxPlatform->updateSourceNodeMap(sourceMapContents.value()); + + robloxPlatform->handleSourcemapUpdate( + frontend, frontend.globals, !program.is_used("--no-strict-dm-types") && client.configuration.diagnostics.strictDatamodelTypes); + } + } + else + { + std::cerr << "warning: a sourcemap was provided, but the current platform is not `roblox`. Use `--platform roblox` to ensure the " + "sourcemap option is respected.\n"; + } + } Luau::freeze(frontend.globals.globalTypes); Luau::freeze(frontend.globalsForAutocomplete.globalTypes); diff --git a/src/LanguageServer.cpp b/src/LanguageServer.cpp index 7bf1667b..21a333bc 100644 --- a/src/LanguageServer.cpp +++ b/src/LanguageServer.cpp @@ -12,9 +12,6 @@ if (!params) \ throw json_rpc::JsonRpcException(lsp::ErrorCode::InvalidParams, "params not provided for " method); -#define REQUIRED_PARAMS(params, method) \ - (!(params) ? throw json_rpc::JsonRpcException(lsp::ErrorCode::InvalidParams, "params not provided for " method) : (params).value()) - /// Finds the workspace which the file belongs to. /// If no workspace is found, the file is attached to the null workspace WorkspaceFolderPtr LanguageServer::findWorkspace(const lsp::DocumentUri& file) @@ -123,7 +120,7 @@ void LanguageServer::onRequest(const id_type& id, const std::string& method, std if (method == "initialize") { - response = onInitialize(REQUIRED_PARAMS(baseParams, "initialize")); + response = onInitialize(JSON_REQUIRED_PARAMS(baseParams, "initialize")); } else if (method == "shutdown") { @@ -131,63 +128,63 @@ void LanguageServer::onRequest(const id_type& id, const std::string& method, std } else if (method == "textDocument/completion") { - response = completion(REQUIRED_PARAMS(baseParams, "textDocument/completion")); + response = completion(JSON_REQUIRED_PARAMS(baseParams, "textDocument/completion")); } else if (method == "textDocument/documentLink") { - response = documentLink(REQUIRED_PARAMS(baseParams, "textDocument/documentLink")); + response = documentLink(JSON_REQUIRED_PARAMS(baseParams, "textDocument/documentLink")); } else if (method == "textDocument/hover") { - response = hover(REQUIRED_PARAMS(baseParams, "textDocument/hover")); + response = hover(JSON_REQUIRED_PARAMS(baseParams, "textDocument/hover")); } else if (method == "textDocument/signatureHelp") { - response = signatureHelp(REQUIRED_PARAMS(baseParams, "textDocument/signatureHelp")); + response = signatureHelp(JSON_REQUIRED_PARAMS(baseParams, "textDocument/signatureHelp")); } else if (method == "textDocument/definition") { - response = gotoDefinition(REQUIRED_PARAMS(baseParams, "textDocument/definition")); + response = gotoDefinition(JSON_REQUIRED_PARAMS(baseParams, "textDocument/definition")); } else if (method == "textDocument/typeDefinition") { - response = gotoTypeDefinition(REQUIRED_PARAMS(baseParams, "textDocument/typeDefinition")); + response = gotoTypeDefinition(JSON_REQUIRED_PARAMS(baseParams, "textDocument/typeDefinition")); } else if (method == "textDocument/references") { - response = references(REQUIRED_PARAMS(baseParams, "textDocument/references")); + response = references(JSON_REQUIRED_PARAMS(baseParams, "textDocument/references")); } else if (method == "textDocument/rename") { - response = rename(REQUIRED_PARAMS(baseParams, "textDocument/rename")); + response = rename(JSON_REQUIRED_PARAMS(baseParams, "textDocument/rename")); } else if (method == "textDocument/documentSymbol") { - response = documentSymbol(REQUIRED_PARAMS(baseParams, "textDocument/documentSymbol")); + response = documentSymbol(JSON_REQUIRED_PARAMS(baseParams, "textDocument/documentSymbol")); } else if (method == "textDocument/codeAction") { - response = codeAction(REQUIRED_PARAMS(baseParams, "textDocument/codeAction")); + response = codeAction(JSON_REQUIRED_PARAMS(baseParams, "textDocument/codeAction")); } // else if (method == "codeAction/resolve") // { - // response = codeActionResolve(REQUIRED_PARAMS(params, "codeAction/resolve")); + // response = codeActionResolve(JSON_REQUIRED_PARAMS(params, "codeAction/resolve")); // } else if (method == "textDocument/semanticTokens/full") { - response = semanticTokens(REQUIRED_PARAMS(baseParams, "textDocument/semanticTokens/full")); + response = semanticTokens(JSON_REQUIRED_PARAMS(baseParams, "textDocument/semanticTokens/full")); } else if (method == "textDocument/inlayHint") { - response = inlayHint(REQUIRED_PARAMS(baseParams, "textDocument/inlayHint")); + response = inlayHint(JSON_REQUIRED_PARAMS(baseParams, "textDocument/inlayHint")); } else if (method == "textDocument/documentColor") { - response = documentColor(REQUIRED_PARAMS(baseParams, "textDocument/documentColor")); + response = documentColor(JSON_REQUIRED_PARAMS(baseParams, "textDocument/documentColor")); } else if (method == "textDocument/colorPresentation") { - response = colorPresentation(REQUIRED_PARAMS(baseParams, "textDocument/colorPresentation")); + response = colorPresentation(JSON_REQUIRED_PARAMS(baseParams, "textDocument/colorPresentation")); } else if (method == "textDocument/prepareCallHierarchy") { @@ -219,13 +216,13 @@ void LanguageServer::onRequest(const id_type& id, const std::string& method, std } else if (method == "textDocument/diagnostic") { - response = documentDiagnostic(REQUIRED_PARAMS(baseParams, "textDocument/diagnostic")); + response = documentDiagnostic(JSON_REQUIRED_PARAMS(baseParams, "textDocument/diagnostic")); } else if (method == "workspace/diagnostic") { // This request has partial request support. // If workspaceDiagnostic returns nothing, then we don't signal a response (as data will be sent as progress notifications) - if (auto report = workspaceDiagnostic(REQUIRED_PARAMS(baseParams, "workspace/diagnostic"))) + if (auto report = workspaceDiagnostic(JSON_REQUIRED_PARAMS(baseParams, "workspace/diagnostic"))) { response = report; } @@ -287,11 +284,11 @@ void LanguageServer::onNotification(const std::string& method, std::optionalsetTrace(REQUIRED_PARAMS(params, "$/setTrace")); + client->setTrace(JSON_REQUIRED_PARAMS(params, "$/setTrace")); } else if (method == "$/cancelRequest") { @@ -300,11 +297,11 @@ void LanguageServer::onNotification(const std::string& method, std::optionalplatform && workspace->platform->handleNotification(method, params)) + return; + } + + client->sendLogMessage(lsp::MessageType::Warning, "unknown notification method: " + method); } - else if (method == "$/plugin/clear") +} + +bool LanguageServer::allWorkspacesConfigured() const +{ + for (auto& workspace : workspaceFolders) { - onStudioPluginClear(); + if (!workspace->isConfigured) + return false; } - else + + return true; +} + +void LanguageServer::handleMessage(const json_rpc::JsonRpcMessage& msg) +{ + try { - client->sendLogMessage(lsp::MessageType::Warning, "unknown notification method: " + method); + if (msg.is_request()) + { + if (isInitialized && !allWorkspacesConfigured()) + { + configPostponedMessages.emplace_back(msg); + return; + } + + onRequest(msg.id.value(), msg.method.value(), msg.params); + } + else if (msg.is_response()) + { + client->handleResponse(msg); + } + else if (msg.is_notification()) + { + onNotification(msg.method.value(), msg.params); + } + else + { + throw JsonRpcException(lsp::ErrorCode::InvalidRequest, "invalid json-rpc message"); + } + } + catch (const JsonRpcException& e) + { + client->sendError(msg.id, e); + } + catch (const std::exception& e) + { + client->sendError(msg.id, JsonRpcException(lsp::ErrorCode::InternalError, e.what())); } } @@ -345,6 +388,14 @@ void LanguageServer::processInputLoop() std::string jsonString; while (std::cin) { + if (configPostponedMessages.size() > 0 && allWorkspacesConfigured()) + { + for (const auto& msg : configPostponedMessages) + handleMessage(msg); + + configPostponedMessages.clear(); + } + if (client->readRawMessage(jsonString)) { // sendTrace(jsonString, std::nullopt); @@ -355,35 +406,12 @@ void LanguageServer::processInputLoop() auto msg = json_rpc::parse(jsonString); id = msg.id; - if (msg.is_request()) - { - onRequest(msg.id.value(), msg.method.value(), msg.params); - } - else if (msg.is_response()) - { - client->handleResponse(msg); - } - else if (msg.is_notification()) - { - onNotification(msg.method.value(), msg.params); - } - else - { - throw JsonRpcException(lsp::ErrorCode::InvalidRequest, "invalid json-rpc message"); - } - } - catch (const JsonRpcException& e) - { - client->sendError(id, e); + handleMessage(msg); } catch (const json::exception& e) { client->sendError(id, JsonRpcException(lsp::ErrorCode::ParseError, e.what())); } - catch (const std::exception& e) - { - client->sendError(id, JsonRpcException(lsp::ErrorCode::InternalError, e.what())); - } } } } @@ -448,7 +476,7 @@ void LanguageServer::onInitialized([[maybe_unused]] const lsp::InitializedParams workspace->setupWithConfiguration(config); // Refresh diagnostics - this->recomputeDiagnostics(workspace, config); + workspace->recomputeDiagnostics(config); // Refresh inlay hint if changed if (!oldConfig || oldConfig->inlayHints != config.inlayHints) @@ -517,57 +545,6 @@ void LanguageServer::onInitialized([[maybe_unused]] const lsp::InitializedParams } } -void LanguageServer::pushDiagnostics(WorkspaceFolderPtr& workspace, const lsp::DocumentUri& uri, const size_t version) -{ - // Convert the diagnostics report into a series of diagnostics published for each relevant file - lsp::DocumentDiagnosticParams params{lsp::TextDocumentIdentifier{uri}}; - auto diagnostics = workspace->documentDiagnostics(params); - client->publishDiagnostics(lsp::PublishDiagnosticsParams{uri, version, diagnostics.items}); - - if (!diagnostics.relatedDocuments.empty()) - { - for (const auto& [relatedUri, relatedDiagnostics] : diagnostics.relatedDocuments) - { - if (relatedDiagnostics.kind == lsp::DocumentDiagnosticReportKind::Full) - { - client->publishDiagnostics(lsp::PublishDiagnosticsParams{Uri::parse(relatedUri), std::nullopt, relatedDiagnostics.items}); - } - } - } -} - -/// Recompute all necessary diagnostics when we detect a configuration (or sourcemap) change -void LanguageServer::recomputeDiagnostics(WorkspaceFolderPtr& workspace, const ClientConfiguration& config) -{ - // Handle diagnostics if in push-mode - if ((!client->capabilities.textDocument || !client->capabilities.textDocument->diagnostic)) - { - // Recompute workspace diagnostics if requested - if (config.diagnostics.workspace) - { - auto diagnostics = workspace->workspaceDiagnostics({}); - for (const auto& report : diagnostics.items) - { - if (report.kind == lsp::DocumentDiagnosticReportKind::Full) - { - client->publishDiagnostics(lsp::PublishDiagnosticsParams{report.uri, report.version, report.items}); - } - } - } - // Recompute diagnostics for all currently opened files - else - { - for (const auto& [_, document] : workspace->fileResolver.managedFiles) - this->pushDiagnostics(workspace, document.uri(), document.version()); - } - } - else - { - client->terminateWorkspaceDiagnostics(); - client->refreshWorkspaceDiagnostics(); - } -} - void LanguageServer::onDidOpenTextDocument(const lsp::DidOpenTextDocumentParams& params) { // Start managing the file in-memory @@ -579,7 +556,7 @@ void LanguageServer::onDidOpenTextDocument(const lsp::DidOpenTextDocumentParams& // however if a client doesn't yet support it, we push the diagnostics instead if (!client->capabilities.textDocument || !client->capabilities.textDocument->diagnostic) { - pushDiagnostics(workspace, params.textDocument.uri, params.textDocument.version); + workspace->pushDiagnostics(params.textDocument.uri, params.textDocument.version); } } @@ -610,7 +587,7 @@ void LanguageServer::onDidChangeTextDocument(const lsp::DidChangeTextDocumentPar std::unordered_map reverseDependencyDiagnostics{}; for (auto& module : markedDirty) { - auto filePath = workspace->fileResolver.resolveToRealPath(module); + auto filePath = workspace->platform->resolveToRealPath(module); if (filePath) { auto uri = Uri::file(*filePath); @@ -713,58 +690,7 @@ void LanguageServer::onDidChangeWatchedFiles(const lsp::DidChangeWatchedFilesPar for (const auto& change : params.changes) { auto workspace = findWorkspace(change.uri); - auto config = client->getConfiguration(workspace->rootUri); - auto filePath = change.uri.fsPath(); - - // Flag sourcemap changes - if (filePath.filename() == "sourcemap.json") - { - client->sendLogMessage(lsp::MessageType::Info, "Registering sourcemap changed for workspace " + workspace->name); - workspace->updateSourceMap(); - - // Recompute diagnostics - this->recomputeDiagnostics(workspace, config); - } - else if (filePath.filename() == ".luaurc") - { - client->sendLogMessage( - lsp::MessageType::Info, "Acknowledge config changed for workspace " + workspace->name + ", clearing configuration cache"); - workspace->fileResolver.clearConfigCache(); - - // Recompute diagnostics - this->recomputeDiagnostics(workspace, config); - } - else if (filePath.extension() == ".lua" || filePath.extension() == ".luau") - { - // Notify if it was a definitions file - if (workspace->isDefinitionFile(filePath, config)) - { - client->sendWindowMessage( - lsp::MessageType::Info, "Detected changes to global definitions files. Please reload your workspace for this to take effect"); - continue; - } - - // Index the workspace on changes - // We only update the require graph. We do not perform type checking - if (config.index.enabled && workspace->isConfigured) - { - auto moduleName = workspace->fileResolver.getModuleName(change.uri); - - std::vector markedDirty{}; - workspace->frontend.markDirty(moduleName, &markedDirty); - - if (change.type == lsp::FileChangeType::Created) - workspace->frontend.parse(moduleName); - - // Re-check the reverse dependencies - for (const auto& reverseDep : markedDirty) - workspace->frontend.parse(reverseDep); - } - - // Clear the diagnostics for the file in case it was not managed - if (change.type == lsp::FileChangeType::Deleted) - workspace->clearDiagnosticsForFile(change.uri); - } + workspace->onDidChangeWatchedFiles(change); } } diff --git a/src/LuauExt.cpp b/src/LuauExt.cpp index d1d7f1cd..04a4d2dd 100644 --- a/src/LuauExt.cpp +++ b/src/LuauExt.cpp @@ -1,36 +1,15 @@ +#include + +#include "Platform/LSPPlatform.hpp" #include "Luau/BuiltinDefinitions.h" -#include "Luau/ConstraintSolver.h" #include "Luau/ToString.h" #include "Luau/Transpiler.h" +#include "Luau/TypeInfer.h" #include "LSP/LuauExt.hpp" #include "LSP/Utils.hpp" namespace types { -std::optional getTypeIdForClass(const Luau::ScopePtr& globalScope, std::optional className) -{ - std::optional baseType; - if (className.has_value()) - { - baseType = globalScope->lookupType(className.value()); - } - if (!baseType.has_value()) - { - baseType = globalScope->lookupType("Instance"); - } - - if (baseType.has_value()) - { - return baseType->type; - } - else - { - // If we reach this stage, we couldn't find the class name nor the "Instance" type - // This most likely means a valid definitions file was not provided - return std::nullopt; - } -} - std::optional getTypeName(Luau::TypeId typeId) { std::optional name = std::nullopt; @@ -61,651 +40,7 @@ std::optional getTypeName(Luau::TypeId typeId) return name; } -// Retrieves the corresponding Luau type for a Sourcemap node -// If it does not yet exist, the type is produced -Luau::TypeId getSourcemapType(const Luau::GlobalTypes& globals, Luau::TypeArena& arena, const SourceNodePtr& node) -{ - // Gets the type corresponding to the sourcemap node if it exists - // Make sure to use the correct ty version (base typeChecker vs autocomplete typeChecker) - if (node->tys.find(&globals) != node->tys.end()) - return node->tys.at(&globals); - - Luau::LazyType ltv( - [&globals, &arena, node](Luau::LazyType& ltv) -> void - { - // Check if the LTV already has an unwrapped type - if (ltv.unwrapped.load()) - return; - - // Handle if the node is no longer valid - if (!node) - { - ltv.unwrapped = globals.builtinTypes->anyType; - return; - } - - auto instanceTy = globals.globalScope->lookupType("Instance"); - if (!instanceTy) - { - ltv.unwrapped = globals.builtinTypes->anyType; - return; - } - - // Look up the base class instance - Luau::TypeId baseTypeId = getTypeIdForClass(globals.globalScope, node->className).value_or(nullptr); - if (!baseTypeId) - { - ltv.unwrapped = globals.builtinTypes->anyType; - return; - } - - // Point the metatable to the metatable of "Instance" so that we allow equality - std::optional instanceMetaIdentity; - if (auto* ctv = Luau::get(instanceTy->type)) - instanceMetaIdentity = ctv->metatable; - - // Create the ClassType representing the instance - std::string typeName = getTypeName(baseTypeId).value_or(node->name); - Luau::ClassType baseInstanceCtv{typeName, {}, baseTypeId, instanceMetaIdentity, {}, {}, "@roblox"}; - auto typeId = arena.addType(std::move(baseInstanceCtv)); - - // Attach Parent and Children info - // Get the mutable version of the type var - if (auto* ctv = Luau::getMutable(typeId)) - { - if (auto parentNode = node->parent.lock()) - ctv->props["Parent"] = Luau::makeProperty(getSourcemapType(globals, arena, parentNode)); - - // Add children as properties - for (const auto& child : node->children) - ctv->props[child->name] = Luau::makeProperty(getSourcemapType(globals, arena, child)); - - // Add FindFirstAncestor and FindFirstChild - if (auto instanceType = getTypeIdForClass(globals.globalScope, "Instance")) - { - auto findFirstAncestorFunction = Luau::makeFunction(arena, typeId, {globals.builtinTypes->stringType}, {"name"}, {*instanceType}); - - Luau::attachMagicFunction(findFirstAncestorFunction, - [&arena, &globals, node](Luau::TypeChecker& typeChecker, const Luau::ScopePtr& scope, const Luau::AstExprCall& expr, - const Luau::WithPredicate& withPredicate) -> std::optional> - { - if (expr.args.size < 1) - return std::nullopt; - - auto str = expr.args.data[0]->as(); - if (!str) - return std::nullopt; - - // This is a O(n) search, not great! - if (auto ancestor = node->findAncestor(std::string(str->value.data, str->value.size))) - { - return Luau::WithPredicate{arena.addTypePack({getSourcemapType(globals, arena, *ancestor)})}; - } - - return std::nullopt; - }); - Luau::attachDcrMagicFunction(findFirstAncestorFunction, - [node, &arena, &globals](Luau::MagicFunctionCallContext context) -> bool - { - if (context.callSite->args.size < 1) - return false; - - auto str = context.callSite->args.data[0]->as(); - if (!str) - return false; - - if (auto ancestor = node->findAncestor(std::string(str->value.data, str->value.size))) - { - asMutable(context.result) - ->ty.emplace( - context.solver->arena->addTypePack({getSourcemapType(globals, arena, *ancestor)})); - return true; - } - return false; - }); - ctv->props["FindFirstAncestor"] = Luau::makeProperty(findFirstAncestorFunction, "@roblox/globaltype/Instance.FindFirstAncestor"); - - auto findFirstChildFunction = Luau::makeFunction(arena, typeId, {globals.builtinTypes->stringType}, {"name"}, {*instanceType}); - Luau::attachMagicFunction(findFirstChildFunction, - [node, &arena, &globals](Luau::TypeChecker& typeChecker, const Luau::ScopePtr& scope, const Luau::AstExprCall& expr, - const Luau::WithPredicate& withPredicate) -> std::optional> - { - if (expr.args.size < 1) - return std::nullopt; - - auto str = expr.args.data[0]->as(); - if (!str) - return std::nullopt; - - if (auto child = node->findChild(std::string(str->value.data, str->value.size))) - return Luau::WithPredicate{arena.addTypePack({getSourcemapType(globals, arena, *child)})}; - - return std::nullopt; - }); - Luau::attachDcrMagicFunction(findFirstChildFunction, - [node, &arena, &globals](Luau::MagicFunctionCallContext context) -> bool - { - if (context.callSite->args.size < 1) - return false; - - auto str = context.callSite->args.data[0]->as(); - if (!str) - return false; - - if (auto child = node->findChild(std::string(str->value.data, str->value.size))) - { - asMutable(context.result) - ->ty.emplace(context.solver->arena->addTypePack({getSourcemapType(globals, arena, *child)})); - return true; - } - return false; - }); - ctv->props["FindFirstChild"] = Luau::makeProperty(findFirstChildFunction, "@roblox/globaltype/Instance.FindFirstChild"); - } - } - - ltv.unwrapped = typeId; - return; - }); - auto ty = arena.addType(std::move(ltv)); - node->tys.insert_or_assign(&globals, ty); - - return ty; -} - -// Magic function for `Instance:IsA("ClassName")` predicate -std::optional> magicFunctionInstanceIsA(Luau::TypeChecker& typeChecker, const Luau::ScopePtr& scope, - const Luau::AstExprCall& expr, const Luau::WithPredicate& withPredicate) -{ - if (expr.args.size != 1) - return std::nullopt; - - auto index = expr.func->as(); - auto str = expr.args.data[0]->as(); - if (!index || !str) - return std::nullopt; - - std::optional lvalue = tryGetLValue(*index->expr); - if (!lvalue) - return std::nullopt; - - std::string className(str->value.data, str->value.size); - std::optional tfun = typeChecker.globalScope->lookupType(className); - if (!tfun || !tfun->typeParams.empty() || !tfun->typePackParams.empty()) - { - typeChecker.reportError(Luau::TypeError{expr.args.data[0]->location, Luau::UnknownSymbol{className, Luau::UnknownSymbol::Type}}); - return std::nullopt; - } - - auto type = Luau::follow(tfun->type); - - Luau::TypeArena& arena = typeChecker.currentModule->internalTypes; - Luau::TypePackId booleanPack = arena.addTypePack({typeChecker.booleanType}); - return Luau::WithPredicate{booleanPack, {Luau::IsAPredicate{std::move(*lvalue), expr.location, type}}}; -} - - - -static bool dcrMagicFunctionInstanceIsA(Luau::MagicFunctionCallContext context) -{ - if (context.callSite->args.size != 1) - return false; - - auto index = context.callSite->func->as(); - auto str = context.callSite->args.data[0]->as(); - if (!index || !str) - return false; - - std::string className(str->value.data, str->value.size); - std::optional tfun = context.constraint->scope->lookupType(className); - if (!tfun) - context.solver->reportError( - Luau::TypeError{context.callSite->args.data[0]->location, Luau::UnknownSymbol{className, Luau::UnknownSymbol::Type}}); - - return false; -} - -void dcrMagicRefinementInstanceIsA(const Luau::MagicRefinementContext& context) -{ - if (context.callSite->args.size != 1 || context.discriminantTypes.empty()) - return; - - auto index = context.callSite->func->as(); - auto str = context.callSite->args.data[0]->as(); - if (!index || !str) - return; - - std::optional discriminantTy = context.discriminantTypes[0]; - if (!discriminantTy) - return; - - std::string className(str->value.data, str->value.size); - std::optional tfun = context.scope->lookupType(className); - if (!tfun) - return; - - LUAU_ASSERT(Luau::get(*discriminantTy)); - asMutable(*discriminantTy)->ty.emplace(Luau::follow(tfun->type)); -} - -// Magic function for `instance:Clone()`, so that we return the exact subclass that `instance` is, rather than just a generic Instance -std::optional> magicFunctionInstanceClone(Luau::TypeChecker& typeChecker, const Luau::ScopePtr& scope, - const Luau::AstExprCall& expr, const Luau::WithPredicate& withPredicate) -{ - auto index = expr.func->as(); - if (!index) - return std::nullopt; - - Luau::TypeArena& arena = typeChecker.currentModule->internalTypes; - auto instanceType = typeChecker.checkExpr(scope, *index->expr); - return Luau::WithPredicate{arena.addTypePack({instanceType.type})}; -} - -static bool dcrMagicFunctionInstanceClone(Luau::MagicFunctionCallContext context) -{ - auto index = context.callSite->func->as(); - if (!index) - return false; - - // The cloned type is the self type, i.e. the first argument - auto selfTy = Luau::first(context.arguments); - if (!selfTy) - return false; - - asMutable(context.result)->ty.emplace(context.solver->arena->addTypePack({*selfTy})); - return true; -} - -// Magic function for `Instance:FindFirstChildWhichIsA("ClassName")` and friends -std::optional> magicFunctionFindFirstXWhichIsA(Luau::TypeChecker& typeChecker, const Luau::ScopePtr& scope, - const Luau::AstExprCall& expr, const Luau::WithPredicate& withPredicate) -{ - if (expr.args.size < 1) - return std::nullopt; - - auto str = expr.args.data[0]->as(); - if (!str) - return std::nullopt; - - std::optional tfun = scope->lookupType(std::string(str->value.data, str->value.size)); - if (!tfun || !tfun->typeParams.empty() || !tfun->typePackParams.empty()) - return std::nullopt; - - auto type = Luau::follow(tfun->type); - - Luau::TypeArena& arena = typeChecker.currentModule->internalTypes; - Luau::TypeId nillableClass = Luau::makeOption(typeChecker.builtinTypes, arena, type); - return Luau::WithPredicate{arena.addTypePack({nillableClass})}; -} - -static bool dcrMagicFunctionFindFirstXWhichIsA(Luau::MagicFunctionCallContext context) -{ - if (context.callSite->args.size < 1) - return false; - - auto str = context.callSite->args.data[0]->as(); - if (!str) - return false; - - std::optional tfun = context.constraint->scope->lookupType(std::string(str->value.data, str->value.size)); - if (!tfun || !tfun->typeParams.empty() || !tfun->typePackParams.empty()) - return false; - - auto type = Luau::follow(tfun->type); - - Luau::TypeId nillableClass = Luau::makeOption(context.solver->builtinTypes, *context.solver->arena, type); - asMutable(context.result)->ty.emplace(context.solver->arena->addTypePack({nillableClass})); - return true; -} - -// Magic function for `EnumItem:IsA("EnumType")` predicate -std::optional> magicFunctionEnumItemIsA(Luau::TypeChecker& typeChecker, const Luau::ScopePtr& scope, - const Luau::AstExprCall& expr, const Luau::WithPredicate& withPredicate) -{ - if (expr.args.size != 1) - return std::nullopt; - - auto index = expr.func->as(); - auto str = expr.args.data[0]->as(); - if (!index || !str) - return std::nullopt; - - std::optional lvalue = tryGetLValue(*index->expr); - if (!lvalue) - return std::nullopt; - - std::string enumItem(str->value.data, str->value.size); - std::optional tfun = scope->lookupImportedType("Enum", enumItem); - if (!tfun || !tfun->typeParams.empty() || !tfun->typePackParams.empty()) - { - typeChecker.reportError(Luau::TypeError{expr.args.data[0]->location, Luau::UnknownSymbol{enumItem, Luau::UnknownSymbol::Type}}); - return std::nullopt; - } - - auto type = Luau::follow(tfun->type); - - Luau::TypeArena& arena = typeChecker.currentModule->internalTypes; - Luau::TypePackId booleanPack = arena.addTypePack({typeChecker.booleanType}); - return Luau::WithPredicate{booleanPack, {Luau::IsAPredicate{std::move(*lvalue), expr.location, type}}}; -} - -static bool dcrMagicFunctionEnumItemIsA(Luau::MagicFunctionCallContext context) -{ - if (context.callSite->args.size != 1) - return false; - - auto index = context.callSite->func->as(); - auto str = context.callSite->args.data[0]->as(); - if (!index || !str) - return false; - - std::string enumItem(str->value.data, str->value.size); - std::optional tfun = context.constraint->scope->lookupImportedType("Enum", enumItem); - if (!tfun) - context.solver->reportError( - Luau::TypeError{context.callSite->args.data[0]->location, Luau::UnknownSymbol{enumItem, Luau::UnknownSymbol::Type}}); - - return false; -} - -static void dcrMagicRefinementEnumItemIsA(const Luau::MagicRefinementContext& context) -{ - if (context.callSite->args.size != 1 || context.discriminantTypes.empty()) - return; - - auto index = context.callSite->func->as(); - auto str = context.callSite->args.data[0]->as(); - if (!index || !str) - return; - - std::optional discriminantTy = context.discriminantTypes[0]; - if (!discriminantTy) - return; - - std::string enumItem(str->value.data, str->value.size); - std::optional tfun = context.scope->lookupImportedType("Enum", enumItem); - if (!tfun) - return; - - LUAU_ASSERT(Luau::get(*discriminantTy)); - asMutable(*discriminantTy)->ty.emplace(Luau::follow(tfun->type)); -} - -// Magic function for `instance:GetPropertyChangedSignal()`, so that we can perform type checking on the provided property -static std::optional> magicFunctionGetPropertyChangedSignal(Luau::TypeChecker& typeChecker, - const Luau::ScopePtr& scope, const Luau::AstExprCall& expr, const Luau::WithPredicate& withPredicate) -{ - if (expr.args.size != 1) - return std::nullopt; - - auto index = expr.func->as(); - auto str = expr.args.data[0]->as(); - if (!index || !str) - return std::nullopt; - - - auto instanceType = typeChecker.checkExpr(scope, *index->expr); - auto ctv = Luau::get(Luau::follow(instanceType.type)); - if (!ctv) - return std::nullopt; - - std::string property(str->value.data, str->value.size); - if (!Luau::lookupClassProp(ctv, property)) - { - typeChecker.reportError(Luau::TypeError{expr.args.data[0]->location, Luau::UnknownProperty{instanceType.type, property}}); - return std::nullopt; - } - - return std::nullopt; -} - -static bool dcrMagicFunctionGetPropertyChangedSignal(Luau::MagicFunctionCallContext context) -{ - if (context.callSite->args.size != 1) - return false; - - auto index = context.callSite->func->as(); - auto str = context.callSite->args.data[0]->as(); - if (!index || !str) - return false; - - // The cloned type is the self type, i.e. the first argument - auto selfTy = Luau::first(context.arguments); - if (!selfTy) - return false; - - auto ctv = Luau::get(Luau::follow(selfTy)); - if (!ctv) - return false; - - std::string property(str->value.data, str->value.size); - if (!Luau::lookupClassProp(ctv, property)) - { - context.solver->reportError(Luau::TypeError{context.callSite->args.data[0]->location, Luau::UnknownProperty{*selfTy, property}}); - } - - return false; -} - -void addChildrenToCTV(const Luau::GlobalTypes& globals, Luau::TypeArena& arena, const Luau::TypeId& ty, const SourceNodePtr& node) -{ - if (auto* ctv = Luau::getMutable(ty)) - { - // Clear out all the old registered children - for (auto it = ctv->props.begin(); it != ctv->props.end();) - { - if (hasTag(it->second, "@sourcemap-generated")) - it = ctv->props.erase(it); - else - ++it; - } - - - // Extend the props to include the children - for (const auto& child : node->children) - { - ctv->props[child->name] = Luau::Property{ - getSourcemapType(globals, arena, child), - /* deprecated */ false, - /* deprecatedSuggestion */ {}, - /* location */ std::nullopt, - /* tags */ {"@sourcemap-generated"}, - /* documentationSymbol*/ std::nullopt, - }; - } - } -} - -// TODO: expressiveTypes is used because of a Luau issue where we can't cast a most specific Instance type (which we create here) -// to another type. For the time being, we therefore make all our DataModel instance types marked as "any". -// Remove this once Luau has improved -void registerInstanceTypes(Luau::Frontend& frontend, const Luau::GlobalTypes& globals, Luau::TypeArena& arena, - const WorkspaceFileResolver& fileResolver, bool expressiveTypes) -{ - if (!fileResolver.rootSourceNode) - return; - - // Create a type for the root source node - getSourcemapType(globals, arena, fileResolver.rootSourceNode); - - // Modify sourcemap types - if (fileResolver.rootSourceNode->className == "DataModel") - { - // Mutate DataModel with its children - if (auto dataModelType = globals.globalScope->lookupType("DataModel")) - addChildrenToCTV(globals, arena, dataModelType->type, fileResolver.rootSourceNode); - - // Mutate globally-registered Services to include children information (so it's available through :GetService) - for (const auto& service : fileResolver.rootSourceNode->children) - { - auto serviceName = service->className; // We know it must be a service of the same class name - if (auto serviceType = globals.globalScope->lookupType(serviceName)) - addChildrenToCTV(globals, arena, serviceType->type, service); - } - - // Add containers to player and copy over instances - // TODO: Player.Character should contain StarterCharacter instances - if (auto playerType = globals.globalScope->lookupType("Player")) - { - if (auto* ctv = Luau::getMutable(playerType->type)) - { - // Player.Backpack should be defined - if (auto backpackType = globals.globalScope->lookupType("Backpack")) - { - ctv->props["Backpack"] = Luau::makeProperty(backpackType->type); - // TODO: should we duplicate StarterPack into here as well? Is that a reasonable assumption to make? - } - - // Player.PlayerGui should contain StarterGui instances - if (auto playerGuiType = globals.globalScope->lookupType("PlayerGui")) - { - if (auto starterGui = fileResolver.rootSourceNode->findChild("StarterGui")) - addChildrenToCTV(globals, arena, playerGuiType->type, *starterGui); - ctv->props["PlayerGui"] = Luau::makeProperty(playerGuiType->type); - } - - // Player.StarterGear should contain StarterPack instances - if (auto starterGearType = globals.globalScope->lookupType("StarterGear")) - { - if (auto starterPack = fileResolver.rootSourceNode->findChild("StarterPack")) - addChildrenToCTV(globals, arena, starterGearType->type, *starterPack); - - ctv->props["StarterGear"] = Luau::makeProperty(starterGearType->type); - } - - // Player.PlayerScripts should contain StarterPlayerScripts instances - if (auto playerScriptsType = globals.globalScope->lookupType("PlayerScripts")) - { - if (auto starterPlayer = fileResolver.rootSourceNode->findChild("StarterPlayer")) - { - if (auto starterPlayerScripts = starterPlayer.value()->findChild("StarterPlayerScripts")) - { - addChildrenToCTV(globals, arena, playerScriptsType->type, *starterPlayerScripts); - } - } - ctv->props["PlayerScripts"] = Luau::makeProperty(playerScriptsType->type); - } - } - } - } - - // Prepare module scope so that we can dynamically reassign the type of "script" to retrieve instance info - frontend.prepareModuleScope = [&frontend, &fileResolver, &arena, expressiveTypes]( - const Luau::ModuleName& name, const Luau::ScopePtr& scope, bool forAutocomplete) - { - Luau::GlobalTypes& globals = forAutocomplete ? frontend.globalsForAutocomplete : frontend.globals; - - // TODO: we hope to remove these in future! - if (!expressiveTypes && !forAutocomplete) - { - scope->bindings[Luau::AstName("script")] = Luau::Binding{globals.builtinTypes->anyType}; - scope->bindings[Luau::AstName("workspace")] = Luau::Binding{globals.builtinTypes->anyType}; - scope->bindings[Luau::AstName("game")] = Luau::Binding{globals.builtinTypes->anyType}; - } - - if (expressiveTypes || forAutocomplete) - if (auto node = - fileResolver.isVirtualPath(name) ? fileResolver.getSourceNodeFromVirtualPath(name) : fileResolver.getSourceNodeFromRealPath(name)) - scope->bindings[Luau::AstName("script")] = Luau::Binding{getSourcemapType(globals, arena, node.value())}; - }; -} - -// Since in Roblox land, debug is extended to introduce more methods, but the api-docs -// mark the package name as `@luau` instead of `@roblox` -static void fixDebugDocumentationSymbol(Luau::TypeId ty, const std::string& libraryName) -{ - auto mutableTy = Luau::asMutable(ty); - auto newDocumentationSymbol = mutableTy->documentationSymbol.value(); - replace(newDocumentationSymbol, "@roblox", "@luau"); - mutableTy->documentationSymbol = newDocumentationSymbol; - - if (auto* ttv = Luau::getMutable(ty)) - { - ttv->name = "typeof(" + libraryName + ")"; - for (auto& [name, prop] : ttv->props) - { - newDocumentationSymbol = prop.documentationSymbol.value(); - replace(newDocumentationSymbol, "@roblox", "@luau"); - prop.documentationSymbol = newDocumentationSymbol; - } - } -} - -static auto createMagicFunctionTypeLookup(const std::vector& lookupList, const std::string& errorMessagePrefix) -{ - return [lookupList, errorMessagePrefix](Luau::TypeChecker& typeChecker, const Luau::ScopePtr& scope, const Luau::AstExprCall& expr, - const Luau::WithPredicate& withPredicate) -> std::optional> - { - if (expr.args.size < 1) - return std::nullopt; - - if (auto str = expr.args.data[0]->as()) - { - auto className = std::string(str->value.data, str->value.size); - if (contains(lookupList, className)) - { - std::optional tfun = typeChecker.globalScope->lookupType(className); - if (!tfun || !tfun->typeParams.empty() || !tfun->typePackParams.empty()) - { - typeChecker.reportError(Luau::TypeError{expr.args.data[0]->location, Luau::UnknownSymbol{className, Luau::UnknownSymbol::Type}}); - return std::nullopt; - } - - auto type = Luau::follow(tfun->type); - - Luau::TypeArena& arena = typeChecker.currentModule->internalTypes; - Luau::TypePackId classTypePack = arena.addTypePack({type}); - return Luau::WithPredicate{classTypePack}; - } - else - { - typeChecker.reportError( - Luau::TypeError{expr.args.data[0]->location, Luau::GenericError{errorMessagePrefix + " '" + className + "'"}}); - } - } - - return std::nullopt; - }; -} - -static auto createDcrMagicFunctionTypeLookup(const std::vector& lookupList, const std::string& errorMessagePrefix) -{ - return [lookupList, errorMessagePrefix](Luau::MagicFunctionCallContext context) -> bool - { - if (context.callSite->args.size < 1) - return false; - - if (auto str = context.callSite->args.data[0]->as()) - { - auto className = std::string(str->value.data, str->value.size); - if (contains(lookupList, className)) - { - // TODO: only check the global scope? - std::optional tfun = context.constraint->scope->lookupType(className); - if (!tfun || !tfun->typeParams.empty() || !tfun->typePackParams.empty()) - { - context.solver->reportError( - Luau::TypeError{context.callSite->args.data[0]->location, Luau::UnknownSymbol{className, Luau::UnknownSymbol::Type}}); - return false; - } - - auto type = Luau::follow(tfun->type); - Luau::TypePackId classTypePack = context.solver->arena->addTypePack({type}); - asMutable(context.result)->ty.emplace(classTypePack); - return true; - } - else - { - context.solver->reportError( - Luau::TypeError{context.callSite->args.data[0]->location, Luau::GenericError{errorMessagePrefix + " '" + className + "'"}}); - } - } - - return false; - }; -} - -std::optional parseDefinitionsFileMetadata(const std::string& definitions) +std::optional parseDefinitionsFileMetadata(const std::string& definitions) { auto firstLine = getFirstLine(definitions); if (Luau::startsWith(firstLine, "--#METADATA#")) @@ -716,176 +51,11 @@ std::optional parseDefinitionsFileMetadata(const std::s return std::nullopt; } -Luau::LoadDefinitionFileResult registerDefinitions(Luau::Frontend& frontend, Luau::GlobalTypes& globals, const std::string& definitions, - bool typeCheckForAutocomplete, std::optional metadata) +Luau::LoadDefinitionFileResult registerDefinitions( + Luau::Frontend& frontend, Luau::GlobalTypes& globals, const std::string& definitions, bool typeCheckForAutocomplete) { // TODO: packageName shouldn't just be "@roblox" - auto loadResult = - frontend.loadDefinitionFile(globals, globals.globalScope, definitions, "@roblox", /* captureComments = */ false, typeCheckForAutocomplete); - if (!loadResult.success) - return loadResult; - - // HACK: Mark "debug" using `@luau` symbol instead - if (auto it = globals.globalScope->bindings.find(Luau::AstName("debug")); it != globals.globalScope->bindings.end()) - { - auto newDocumentationSymbol = it->second.documentationSymbol.value(); - replace(newDocumentationSymbol, "@roblox", "@luau"); - it->second.documentationSymbol = newDocumentationSymbol; - fixDebugDocumentationSymbol(it->second.typeId, "debug"); - } - - // HACK: Mark "utf8" using `@luau` symbol instead - if (auto it = globals.globalScope->bindings.find(Luau::AstName("utf8")); it != globals.globalScope->bindings.end()) - { - auto newDocumentationSymbol = it->second.documentationSymbol.value(); - replace(newDocumentationSymbol, "@roblox", "@luau"); - it->second.documentationSymbol = newDocumentationSymbol; - fixDebugDocumentationSymbol(it->second.typeId, "utf8"); - } - - // Extend Instance types - if (auto instanceType = globals.globalScope->lookupType("Instance")) - { - if (auto* ctv = Luau::getMutable(instanceType->type)) - { - - - // ctv->props["Clone"] = Luau::makeProperty(Luau::makeFunction(arena, std::nullopt, {tabTy}, {tabTy}), - // "@luau/global/table.clone"); - - - Luau::attachMagicFunction(ctv->props["IsA"].type(), types::magicFunctionInstanceIsA); - Luau::attachMagicFunction(ctv->props["FindFirstChildWhichIsA"].type(), types::magicFunctionFindFirstXWhichIsA); - Luau::attachMagicFunction(ctv->props["FindFirstChildOfClass"].type(), types::magicFunctionFindFirstXWhichIsA); - Luau::attachMagicFunction(ctv->props["FindFirstAncestorWhichIsA"].type(), types::magicFunctionFindFirstXWhichIsA); - Luau::attachMagicFunction(ctv->props["FindFirstAncestorOfClass"].type(), types::magicFunctionFindFirstXWhichIsA); - Luau::attachMagicFunction(ctv->props["Clone"].type(), types::magicFunctionInstanceClone); - Luau::attachMagicFunction(ctv->props["GetPropertyChangedSignal"].type(), magicFunctionGetPropertyChangedSignal); - - Luau::attachDcrMagicRefinement(ctv->props["IsA"].type(), types::dcrMagicRefinementInstanceIsA); - Luau::attachDcrMagicFunction(ctv->props["IsA"].type(), types::dcrMagicFunctionInstanceIsA); - Luau::attachDcrMagicFunction(ctv->props["FindFirstChildWhichIsA"].type(), types::dcrMagicFunctionFindFirstXWhichIsA); - Luau::attachDcrMagicFunction(ctv->props["FindFirstChildOfClass"].type(), types::dcrMagicFunctionFindFirstXWhichIsA); - Luau::attachDcrMagicFunction(ctv->props["FindFirstAncestorWhichIsA"].type(), types::dcrMagicFunctionFindFirstXWhichIsA); - Luau::attachDcrMagicFunction(ctv->props["FindFirstAncestorOfClass"].type(), types::dcrMagicFunctionFindFirstXWhichIsA); - Luau::attachDcrMagicFunction(ctv->props["Clone"].type(), types::dcrMagicFunctionInstanceClone); - Luau::attachDcrMagicFunction(ctv->props["GetPropertyChangedSignal"].type(), types::dcrMagicFunctionGetPropertyChangedSignal); - - // Autocomplete ClassNames for :IsA("") and counterparts - Luau::attachTag(ctv->props["IsA"].type(), "ClassNames"); - Luau::attachTag(ctv->props["FindFirstChildWhichIsA"].type(), "ClassNames"); - Luau::attachTag(ctv->props["FindFirstChildOfClass"].type(), "ClassNames"); - Luau::attachTag(ctv->props["FindFirstAncestorWhichIsA"].type(), "ClassNames"); - Luau::attachTag(ctv->props["FindFirstAncestorOfClass"].type(), "ClassNames"); - - // Autocomplete Properties for :GetPropertyChangedSignal("") - Luau::attachTag(ctv->props["GetPropertyChangedSignal"].type(), "Properties"); - - // Go through all the defined classes and if they are a subclass of Instance then give them the - // same metatable identity as Instance so that equality comparison works. - // NOTE: This will OVERWRITE any metatables set on these classes! - // We assume that all subclasses of instance don't have any metamethods - for (auto& [_, ty] : globals.globalScope->exportedTypeBindings) - { - if (auto* c = Luau::getMutable(ty.type)) - { - // Check if the ctv is a subclass of instance - if (Luau::isSubclass(c, ctv)) - { - c->metatable = ctv->metatable; - } - } - } - } - } - - // Attach onto Instance.new() - if (metadata.has_value() && !metadata->CREATABLE_INSTANCES.empty()) - if (auto instanceGlobal = globals.globalScope->lookup(Luau::AstName("Instance"))) - if (auto ttv = Luau::get(instanceGlobal.value())) - if (auto newFunction = ttv->props.find("new"); - newFunction != ttv->props.end() && Luau::get(newFunction->second.type())) - { - - Luau::attachTag(newFunction->second.type(), "CreatableInstances"); - Luau::attachMagicFunction( - newFunction->second.type(), createMagicFunctionTypeLookup(metadata->CREATABLE_INSTANCES, "Invalid class name")); - Luau::attachDcrMagicFunction( - newFunction->second.type(), createDcrMagicFunctionTypeLookup(metadata->CREATABLE_INSTANCES, "Invalid class name")); - } - - // Attach onto `game:GetService()` - if (metadata.has_value() && !metadata->SERVICES.empty()) - if (auto serviceProviderType = globals.globalScope->lookupType("ServiceProvider")) - if (auto* ctv = Luau::getMutable(serviceProviderType->type); - ctv && Luau::get(ctv->props["GetService"].type())) - { - Luau::attachTag(ctv->props["GetService"].type(), "Services"); - Luau::attachMagicFunction(ctv->props["GetService"].type(), createMagicFunctionTypeLookup(metadata->SERVICES, "Invalid service name")); - Luau::attachDcrMagicFunction( - ctv->props["GetService"].type(), createDcrMagicFunctionTypeLookup(metadata->SERVICES, "Invalid service name")); - } - - // Move Enums over as imported type bindings - std::unordered_map enumTypes{}; - for (auto it = globals.globalScope->exportedTypeBindings.begin(); it != globals.globalScope->exportedTypeBindings.end();) - { - auto erase = false; - auto ty = it->second.type; - if (auto* ctv = Luau::getMutable(ty)) - { - if (Luau::startsWith(ctv->name, "Enum")) - { - if (ctv->name == "EnumItem") - { - Luau::attachMagicFunction(ctv->props["IsA"].type(), types::magicFunctionEnumItemIsA); - Luau::attachDcrMagicFunction(ctv->props["IsA"].type(), types::dcrMagicFunctionEnumItemIsA); - Luau::attachDcrMagicRefinement(ctv->props["IsA"].type(), types::dcrMagicRefinementEnumItemIsA); - Luau::attachTag(ctv->props["IsA"].type(), "Enums"); - } - else if (ctv->name != "Enum" && ctv->name != "Enums") - { - // Erase the "Enum" at the start - ctv->name = ctv->name.substr(4); - - // Move the enum over to the imported types if it is not internal, otherwise rename the type - if (endsWith(ctv->name, "_INTERNAL")) - { - ctv->name.erase(ctv->name.rfind("_INTERNAL"), 9); - } - else - { - enumTypes.emplace(ctv->name, it->second); - // Erase the metatable for the type, so it can be used in comparison - } - - // Update the documentation symbol - Luau::asMutable(ty)->documentationSymbol = "@roblox/enum/" + ctv->name; - for (auto& [name, prop] : ctv->props) - { - prop.documentationSymbol = "@roblox/enum/" + ctv->name + "." + name; - Luau::attachTag(prop, "EnumItem"); - } - - // Prefix the name (after it has been placed into enumTypes) with "Enum." - ctv->name = "Enum." + ctv->name; - - erase = true; - } - - // Erase the metatable from the type to allow comparison - ctv->metatable = std::nullopt; - } - } - - if (erase) - it = globals.globalScope->exportedTypeBindings.erase(it); - else - ++it; - } - globals.globalScope->importedTypeBindings.emplace("Enum", enumTypes); - - return loadResult; + return frontend.loadDefinitionFile(globals, globals.globalScope, definitions, "@roblox", /* captureComments = */ false, typeCheckForAutocomplete); } using NameOrExpr = std::variant; diff --git a/src/StudioPlugin.cpp b/src/StudioPlugin.cpp deleted file mode 100644 index 217b6922..00000000 --- a/src/StudioPlugin.cpp +++ /dev/null @@ -1,49 +0,0 @@ -#include "LSP/LanguageServer.hpp" -#include "LSP/Sourcemap.hpp" -#include "LSP/PluginDataModel.hpp" - -void LanguageServer::onStudioPluginFullChange(const PluginNode& dataModel) -{ - client->sendLogMessage(lsp::MessageType::Info, "received full change from studio plugin"); - - // TODO: handle multi-workspace setup - auto workspace = workspaceFolders.at(0); - workspace->fileResolver.pluginInfo = std::make_shared(dataModel); - - // Mutate the sourcemap with the new information - workspace->updateSourceMap(); -} - -void LanguageServer::onStudioPluginClear() -{ - client->sendLogMessage(lsp::MessageType::Info, "received clear from studio plugin"); - - // TODO: handle multi-workspace setup - auto workspace = workspaceFolders.at(0); - - workspace->fileResolver.pluginInfo = nullptr; - - // Mutate the sourcemap with the new information - workspace->updateSourceMap(); -} - -void SourceNode::mutateWithPluginInfo(const PluginNodePtr& pluginInstance) -{ - // We currently perform purely additive changes where we add in new children - for (const auto& dmChild : pluginInstance->children) - { - if (auto existingChildNode = findChild(dmChild->name)) - { - existingChildNode.value()->mutateWithPluginInfo(dmChild); - } - else - { - SourceNode childNode; - childNode.name = dmChild->name; - childNode.className = dmChild->className; - childNode.mutateWithPluginInfo(dmChild); - - children.push_back(std::make_shared(childNode)); - } - } -} \ No newline at end of file diff --git a/src/Utils.cpp b/src/Utils.cpp index 083ebc76..cb5db74e 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -1,5 +1,6 @@ #include "LSP/Utils.hpp" #include "Luau/StringUtils.h" +#include "Platform/RobloxPlatform.hpp" #include #include diff --git a/src/Workspace.cpp b/src/Workspace.cpp index d2911ffa..8f89e716 100644 --- a/src/Workspace.cpp +++ b/src/Workspace.cpp @@ -1,7 +1,11 @@ #include "LSP/Workspace.hpp" #include +#include +#include "LSP/LanguageServer.hpp" +#include "Platform/LSPPlatform.hpp" +#include "Platform/RobloxPlatform.hpp" #include "glob/glob.hpp" #include "Luau/BuiltinDefinitions.h" @@ -13,9 +17,12 @@ void WorkspaceFolder::openTextDocument(const lsp::DocumentUri& uri, const lsp::D fileResolver.managedFiles.emplace( std::make_pair(normalisedUri, TextDocument(uri, params.textDocument.languageId, params.textDocument.version, params.textDocument.text))); - // Mark the file as dirty as we don't know what changes were made to it - auto moduleName = fileResolver.getModuleName(uri); - frontend.markDirty(moduleName); + if (isConfigured) + { + // Mark the file as dirty as we don't know what changes were made to it + auto moduleName = fileResolver.getModuleName(uri); + frontend.markDirty(moduleName); + } } void WorkspaceFolder::updateTextDocument( @@ -70,6 +77,54 @@ void WorkspaceFolder::clearDiagnosticsForFile(const lsp::DocumentUri& uri) } } +void WorkspaceFolder::onDidChangeWatchedFiles(const lsp::FileEvent& change) +{ + auto filePath = change.uri.fsPath(); + auto config = client->getConfiguration(rootUri); + + platform->onDidChangeWatchedFiles(change); + + if (filePath.filename() == ".luaurc") + { + client->sendLogMessage(lsp::MessageType::Info, "Acknowledge config changed for workspace " + name + ", clearing configuration cache"); + fileResolver.clearConfigCache(); + + // Recompute diagnostics + recomputeDiagnostics(config); + } + else if (filePath.extension() == ".lua" || filePath.extension() == ".luau") + { + // Notify if it was a definitions file + if (isDefinitionFile(filePath, config)) + { + client->sendWindowMessage( + lsp::MessageType::Info, "Detected changes to global definitions files. Please reload your workspace for this to take effect"); + return; + } + + // Index the workspace on changes + // We only update the require graph. We do not perform type checking + if (config.index.enabled && isConfigured) + { + auto moduleName = fileResolver.getModuleName(change.uri); + + std::vector markedDirty{}; + frontend.markDirty(moduleName, &markedDirty); + + if (change.type == lsp::FileChangeType::Created) + frontend.parse(moduleName); + + // Re-check the reverse dependencies + for (const auto& reverseDep : markedDirty) + frontend.parse(reverseDep); + } + + // Clear the diagnostics for the file in case it was not managed + if (change.type == lsp::FileChangeType::Deleted) + clearDiagnosticsForFile(change.uri); + } +} + /// Whether the file has been marked as ignored by any of the ignored lists in the configuration bool WorkspaceFolder::isIgnoredFile(const std::filesystem::path& path, const std::optional& givenConfig) { @@ -191,37 +246,6 @@ void WorkspaceFolder::indexFiles(const ClientConfiguration& config) client->sendTrace("workspace: indexing all files COMPLETED"); } -bool WorkspaceFolder::updateSourceMap() -{ - auto sourcemapPath = rootUri.fsPath() / "sourcemap.json"; - client->sendTrace("Updating sourcemap contents from " + sourcemapPath.generic_string()); - - // Read in the sourcemap - // TODO: we assume a sourcemap.json file in the workspace root - if (auto sourceMapContents = readFile(sourcemapPath)) - { - frontend.clear(); - fileResolver.updateSourceMap(sourceMapContents.value()); - - // Recreate instance types - auto config = client->getConfiguration(rootUri); - instanceTypes.clear(); - // NOTE: expressive types is always enabled for autocomplete, regardless of the setting! - // We pass the same setting even when we are registering autocomplete globals since - // the setting impacts what happens to diagnostics (as both calls overwrite frontend.prepareModuleScope) - types::registerInstanceTypes(frontend, frontend.globals, instanceTypes, fileResolver, - /* expressiveTypes: */ config.diagnostics.strictDatamodelTypes); - types::registerInstanceTypes(frontend, frontend.globalsForAutocomplete, instanceTypes, fileResolver, - /* expressiveTypes: */ config.diagnostics.strictDatamodelTypes); - - return true; - } - else - { - return false; - } -} - void WorkspaceFolder::initialize() { client->sendTrace("workspace initialization: registering Luau globals"); @@ -252,10 +276,8 @@ void WorkspaceFolder::initialize() client->sendTrace("workspace initialization: parsing definitions file metadata COMPLETED", json(definitionsFileMetadata).dump()); client->sendTrace("workspace initialization: registering types definition"); - auto result = types::registerDefinitions( - frontend, frontend.globals, *definitionsContents, /* typeCheckForAutocomplete = */ false, definitionsFileMetadata); - types::registerDefinitions( - frontend, frontend.globalsForAutocomplete, *definitionsContents, /* typeCheckForAutocomplete = */ true, definitionsFileMetadata); + auto result = types::registerDefinitions(frontend, frontend.globals, *definitionsContents, /* typeCheckForAutocomplete = */ false); + types::registerDefinitions(frontend, frontend.globalsForAutocomplete, *definitionsContents, /* typeCheckForAutocomplete = */ true); client->sendTrace("workspace initialization: registering types definition COMPLETED"); auto uri = Uri::file(definitionsFile); @@ -289,17 +311,20 @@ void WorkspaceFolder::initialize() void WorkspaceFolder::setupWithConfiguration(const ClientConfiguration& configuration) { client->sendTrace("workspace: setting up with configuration"); - isConfigured = true; - if (configuration.sourcemap.enabled) + platform = LSPPlatform::getPlatform(configuration, &fileResolver, this); + + fileResolver.platform = platform.get(); + + if (!isConfigured) { - client->sendTrace("workspace: sourcemap enabled"); - if (!isNullWorkspace() && !updateSourceMap()) - { - client->sendWindowMessage( - lsp::MessageType::Error, "Failed to load sourcemap.json for workspace '" + name + "'. Instance information will not be available"); - } + isConfigured = true; + + platform->mutateRegisteredDefinitions(frontend.globals, definitionsFileMetadata); + platform->mutateRegisteredDefinitions(frontend.globalsForAutocomplete, definitionsFileMetadata); } if (configuration.index.enabled) indexFiles(configuration); + + platform->setupWithConfiguration(configuration); } diff --git a/src/WorkspaceFileResolver.cpp b/src/WorkspaceFileResolver.cpp index 0699fb99..72fc2896 100644 --- a/src/WorkspaceFileResolver.cpp +++ b/src/WorkspaceFileResolver.cpp @@ -13,7 +13,7 @@ Luau::ModuleName WorkspaceFileResolver::getModuleName(const Uri& name) const return name.toString(); auto fsPath = name.fsPath().generic_string(); - if (auto virtualPath = resolveToVirtualPath(fsPath)) + if (auto virtualPath = platform->resolveToVirtualPath(fsPath)) { return *virtualPath; } @@ -49,7 +49,7 @@ const TextDocument* WorkspaceFileResolver::getTextDocumentFromModuleName(const L if (Luau::startsWith(name, "untitled:")) return getTextDocument(Uri::parse(name)); - if (auto filePath = resolveToRealPath(name)) + if (auto filePath = platform->resolveToRealPath(name)) return getTextDocument(Uri::file(*filePath)); return nullptr; @@ -60,323 +60,48 @@ TextDocumentPtr WorkspaceFileResolver::getOrCreateTextDocumentFromModuleName(con if (auto document = getTextDocumentFromModuleName(name)) return TextDocumentPtr(document); - if (auto filePath = resolveToRealPath(name)) + if (auto filePath = platform->resolveToRealPath(name)) if (auto source = readSource(name)) return TextDocumentPtr(Uri::file(*filePath), "luau", source->source); return TextDocumentPtr(nullptr); } -std::optional WorkspaceFileResolver::getSourceNodeFromVirtualPath(const Luau::ModuleName& name) const -{ - if (virtualPathsToSourceNodes.find(name) == virtualPathsToSourceNodes.end()) - return std::nullopt; - return virtualPathsToSourceNodes.at(name); -} - -std::optional WorkspaceFileResolver::getSourceNodeFromRealPath(const std::string& name) const -{ - std::error_code ec; - auto canonicalName = std::filesystem::weakly_canonical(name, ec); - if (ec.value() != 0) - canonicalName = name; - auto strName = canonicalName.generic_string(); - if (realPathsToSourceNodes.find(strName) == realPathsToSourceNodes.end()) - return std::nullopt; - return realPathsToSourceNodes.at(strName); -} - -Luau::ModuleName WorkspaceFileResolver::getVirtualPathFromSourceNode(const SourceNodePtr& sourceNode) -{ - return sourceNode->virtualPath; -} - -std::optional WorkspaceFileResolver::getRealPathFromSourceNode(const SourceNodePtr& sourceNode) const -{ - // NOTE: this filepath is generated by the sourcemap, which is relative to the cwd where the sourcemap - // command was run from. Hence, we concatenate it to the end of the workspace path - // TODO: make sure this is correct once we make sourcemap.json generic - auto filePath = sourceNode->getScriptFilePath(); - if (filePath) - return rootUri.fsPath() / *filePath; - return std::nullopt; -} - -std::optional WorkspaceFileResolver::resolveToVirtualPath(const std::string& name) const -{ - if (isVirtualPath(name)) - { - return name; - } - else - { - auto sourceNode = getSourceNodeFromRealPath(name); - if (!sourceNode) - return std::nullopt; - return getVirtualPathFromSourceNode(sourceNode.value()); - } -} - -std::optional WorkspaceFileResolver::resolveToRealPath(const Luau::ModuleName& name) const -{ - if (isVirtualPath(name)) - { - if (auto sourceNode = getSourceNodeFromVirtualPath(name)) - { - return getRealPathFromSourceNode(*sourceNode); - } - } - else - { - return name; - } - - return std::nullopt; -} - std::optional WorkspaceFileResolver::readSource(const Luau::ModuleName& name) { Luau::SourceCode::Type sourceType = Luau::SourceCode::Type::None; - std::optional source; std::filesystem::path realFileName = name; - if (isVirtualPath(name)) + if (platform->isVirtualPath(name)) { - auto sourceNode = getSourceNodeFromVirtualPath(name); - if (!sourceNode) - return std::nullopt; - auto filePath = getRealPathFromSourceNode(sourceNode.value()); + auto filePath = platform->resolveToRealPath(name); if (!filePath) return std::nullopt; - realFileName = filePath.value(); - sourceType = sourceNode.value()->sourceCodeType(); - } - else - { - sourceType = sourceCodeTypeFromPath(realFileName); - } - if (auto textDocument = getTextDocumentFromModuleName(name)) - { - source = textDocument->getText(); + realFileName = *filePath; + sourceType = platform->sourceCodeTypeFromPath(*filePath); } else { - source = readFile(realFileName); - if (source && realFileName.extension() == ".json") - { - try - { - source = "--!strict\nreturn " + jsonValueToLuau(json::parse(*source)); - } - catch (const std::exception& e) - { - // TODO: display diagnostic? - std::cerr << "Failed to load JSON module: " << realFileName.generic_string() << " - " << e.what() << '\n'; - return std::nullopt; - } - } + sourceType = platform->sourceCodeTypeFromPath(realFileName); } - if (!source) - return std::nullopt; - - return Luau::SourceCode{*source, sourceType}; -} - -/// Modify the context so that game/Players/LocalPlayer items point to the correct place -std::string mapContext(const std::string& context) -{ - if (context == "game/Players/LocalPlayer/PlayerScripts") - return "game/StarterPlayer/StarterPlayerScripts"; - else if (context == "game/Players/LocalPlayer/PlayerGui") - return "game/StarterGui"; - else if (context == "game/Players/LocalPlayer/StarterGear") - return "game/StarterPack"; - return context; -} - -/// Returns the base path to use in a string require. -/// This depends on user configuration, whether requires are taken relative to file or workspace root, defaulting to the latter -std::filesystem::path WorkspaceFileResolver::getRequireBasePath(std::optional fileModuleName) const -{ - if (!client) - return rootUri.fsPath(); - - auto config = client->getConfiguration(rootUri); - switch (config.require.mode) - { - case RequireModeConfig::RelativeToWorkspaceRoot: - return rootUri.fsPath(); - case RequireModeConfig::RelativeToFile: - { - if (fileModuleName.has_value()) - { - auto filePath = resolveToRealPath(*fileModuleName); - if (filePath) - return filePath->parent_path(); - else - return rootUri.fsPath(); - } - else - { - return rootUri.fsPath(); - } - } - } - - return rootUri.fsPath(); -} - -// Resolve the string using a directory alias if present -std::optional resolveDirectoryAlias( - const std::filesystem::path& rootPath, const std::unordered_map& directoryAliases, const std::string& str) -{ - for (const auto& [alias, path] : directoryAliases) - { - if (Luau::startsWith(str, alias)) - { - std::filesystem::path directoryPath = path; - std::string remainder = str.substr(alias.length()); - - // If remainder begins with a '/' character, we need to trim it off before it gets mistaken for an - // absolute path - remainder.erase(0, remainder.find_first_not_of("/\\")); - - auto filePath = resolvePath(remainder.empty() ? directoryPath : directoryPath / remainder); - if (!filePath.is_absolute()) - filePath = rootPath / filePath; - - return filePath; - } - } + if (auto source = platform->readSourceCode(name, realFileName)) + return Luau::SourceCode{*source, sourceType}; return std::nullopt; } -std::optional WorkspaceFileResolver::resolveStringRequire(const Luau::ModuleInfo* context, const std::string& requiredString) const -{ - std::filesystem::path basePath = getRequireBasePath(context ? std::optional(context->name) : std::nullopt); - auto filePath = basePath / requiredString; - - // Check for custom require overrides - if (client) - { - auto config = client->getConfiguration(rootUri); - - // Check file aliases - if (auto it = config.require.fileAliases.find(requiredString); it != config.require.fileAliases.end()) - { - filePath = resolvePath(it->second); - } - // Check directory aliases - else if (auto aliasedPath = resolveDirectoryAlias(rootUri.fsPath(), config.require.directoryAliases, requiredString)) - { - filePath = aliasedPath.value(); - } - } - - std::error_code ec; - filePath = std::filesystem::weakly_canonical(filePath, ec); - - // Handle "init.luau" files in a directory - if (std::filesystem::is_directory(filePath, ec)) - { - filePath /= "init"; - } - - // Add file endings - if (filePath.extension() != ".luau" && filePath.extension() != ".lua") - { - auto fullFilePath = filePath.string() + ".luau"; - if (!std::filesystem::exists(fullFilePath)) - // fall back to .lua if a module with .luau doesn't exist - filePath = filePath.string() + ".lua"; - else - filePath = fullFilePath; - } - - // URI-ify the file path so that its normalised (in particular, the drive letter) - auto uri = Uri::parse(Uri::file(filePath).toString()); - - return Luau::ModuleInfo{getModuleName(uri)}; -} - std::optional WorkspaceFileResolver::resolveModule(const Luau::ModuleInfo* context, Luau::AstExpr* node) { - // Handle require("path") for compatibility - if (auto* expr = node->as()) - { - std::string requiredString(expr->value.data, expr->value.size); - return resolveStringRequire(context, requiredString); - } - else if (auto* g = node->as()) - { - if (g->name == "game") - return Luau::ModuleInfo{"game"}; - - if (g->name == "script") - { - if (auto virtualPath = resolveToVirtualPath(context->name)) - { - return Luau::ModuleInfo{virtualPath.value()}; - } - } - } - else if (auto* i = node->as()) - { - if (context) - { - if (strcmp(i->index.value, "Parent") == 0) - { - // Pop the name instead - auto parentPath = getParentPath(context->name); - if (parentPath.has_value()) - return Luau::ModuleInfo{parentPath.value(), context->optional}; - } - - return Luau::ModuleInfo{mapContext(context->name) + '/' + i->index.value, context->optional}; - } - } - else if (auto* i_expr = node->as()) - { - if (auto* index = i_expr->index->as()) - { - if (context) - return Luau::ModuleInfo{mapContext(context->name) + '/' + std::string(index->value.data, index->value.size), context->optional}; - } - } - else if (auto* call = node->as(); call && call->self && call->args.size >= 1 && context) - { - if (auto* index = call->args.data[0]->as()) - { - Luau::AstName func = call->func->as()->index; - - if (func == "GetService" && context->name == "game") - { - return Luau::ModuleInfo{"game/" + std::string(index->value.data, index->value.size)}; - } - else if (func == "WaitForChild" || (func == "FindFirstChild" && call->args.size == 1)) // Don't allow recursive FFC - { - return Luau::ModuleInfo{mapContext(context->name) + '/' + std::string(index->value.data, index->value.size), context->optional}; - } - else if (func == "FindFirstAncestor") - { - auto ancestorName = getAncestorPath(context->name, std::string(index->value.data, index->value.size), rootSourceNode); - if (ancestorName) - return Luau::ModuleInfo{*ancestorName, context->optional}; - } - } - } - - return std::nullopt; + return platform->resolveModule(context, node); } std::string WorkspaceFileResolver::getHumanReadableModuleName(const Luau::ModuleName& name) const { - if (isVirtualPath(name)) + if (platform->isVirtualPath(name)) { - if (auto realPath = resolveToRealPath(name)) + if (auto realPath = platform->resolveToRealPath(name)) { return realPath->relative_path().generic_string() + " [" + name + "]"; } @@ -393,7 +118,7 @@ std::string WorkspaceFileResolver::getHumanReadableModuleName(const Luau::Module const Luau::Config& WorkspaceFileResolver::getConfig(const Luau::ModuleName& name) const { - std::optional realPath = resolveToRealPath(name); + std::optional realPath = platform->resolveToRealPath(name); if (!realPath || !realPath->has_relative_path() || !realPath->has_parent_path()) return defaultConfig; @@ -442,58 +167,3 @@ void WorkspaceFileResolver::clearConfigCache() { configCache.clear(); } - -void WorkspaceFileResolver::writePathsToMap(const SourceNodePtr& node, const std::string& base) -{ - node->virtualPath = base; - virtualPathsToSourceNodes[base] = node; - - if (auto realPath = node->getScriptFilePath()) - { - std::error_code ec; - auto canonicalName = std::filesystem::weakly_canonical(rootUri.fsPath() / *realPath, ec); - if (ec.value() != 0) - canonicalName = *realPath; - realPathsToSourceNodes[canonicalName.generic_string()] = node; - } - - for (auto& child : node->children) - { - child->parent = node; - writePathsToMap(child, base + "/" + child->name); - } -} - -void WorkspaceFileResolver::updateSourceMap(const std::string& sourceMapContents) -{ - realPathsToSourceNodes.clear(); - virtualPathsToSourceNodes.clear(); - - try - { - auto j = json::parse(sourceMapContents); - rootSourceNode = std::make_shared(j.get()); - - // Mutate with plugin info - if (pluginInfo) - { - if (rootSourceNode->className == "DataModel") - { - rootSourceNode->mutateWithPluginInfo(pluginInfo); - } - else - { - std::cerr << "Attempted to update plugin information for a non-DM instance" << '\n'; - } - } - - // Write paths - std::string base = rootSourceNode->className == "DataModel" ? "game" : "ProjectRoot"; - writePathsToMap(rootSourceNode, base); - } - catch (const std::exception& e) - { - // TODO: log message? - std::cerr << e.what() << '\n'; - } -} diff --git a/src/include/LSP/ClientConfiguration.hpp b/src/include/LSP/ClientConfiguration.hpp index 227ba794..3d149c9e 100644 --- a/src/include/LSP/ClientConfiguration.hpp +++ b/src/include/LSP/ClientConfiguration.hpp @@ -13,7 +13,7 @@ struct ClientDiagnosticsConfiguration }; NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ClientDiagnosticsConfiguration, includeDependents, workspace, strictDatamodelTypes) -struct ClientSourcemapConfiguration +struct ClientRobloxSourcemapConfiguration { /// Whether Rojo sourcemap-related features are enabled bool enabled = true; @@ -24,11 +24,12 @@ struct ClientSourcemapConfiguration /// Whether non script instances should be included in the generated sourcemap bool includeNonScripts = true; }; -NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ClientSourcemapConfiguration, enabled, autogenerate, rojoProjectFile, includeNonScripts); +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ClientRobloxSourcemapConfiguration, enabled, autogenerate, rojoProjectFile, includeNonScripts); struct ClientTypesConfiguration { /// Whether Roblox-related definitions should be supported + /// DEPRECATED: USE `platform.type` INSTEAD bool roblox = true; /// Any definition files to load globally std::vector definitionFiles{}; @@ -195,6 +196,22 @@ struct ClientBytecodeConfiguration }; NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ClientBytecodeConfiguration, debugLevel, typeInfoLevel, vectorLib, vectorCtor, vectorType) +enum struct LSPPlatformConfig +{ + Standard, + Roblox +}; +NLOHMANN_JSON_SERIALIZE_ENUM(LSPPlatformConfig, { + {LSPPlatformConfig::Standard, "standard"}, + {LSPPlatformConfig::Roblox, "roblox"}, + }) + +struct ClientPlatformConfiguration +{ + LSPPlatformConfig type = LSPPlatformConfig::Roblox; +}; + +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ClientPlatformConfiguration, type); // These are the passed configuration options by the client, prefixed with `luau-lsp.` // Here we also define the default settings @@ -204,7 +221,8 @@ struct ClientConfiguration /// DEPRECATED: Use completion.autocompleteEnd instead bool autocompleteEnd = false; std::vector ignoreGlobs{}; - ClientSourcemapConfiguration sourcemap{}; + ClientPlatformConfiguration platform{}; + ClientRobloxSourcemapConfiguration sourcemap{}; ClientDiagnosticsConfiguration diagnostics{}; ClientTypesConfiguration types{}; ClientInlayHintsConfiguration inlayHints{}; @@ -216,5 +234,5 @@ struct ClientConfiguration ClientFFlagsConfiguration fflags{}; ClientBytecodeConfiguration bytecode{}; }; -NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ClientConfiguration, autocompleteEnd, ignoreGlobs, sourcemap, diagnostics, types, inlayHints, hover, - completion, signatureHelp, require, index, fflags, bytecode); +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ClientConfiguration, autocompleteEnd, ignoreGlobs, platform, sourcemap, diagnostics, types, + inlayHints, hover, completion, signatureHelp, require, index, fflags, bytecode); diff --git a/src/include/LSP/Completion.hpp b/src/include/LSP/Completion.hpp new file mode 100644 index 00000000..3ac261bc --- /dev/null +++ b/src/include/LSP/Completion.hpp @@ -0,0 +1,18 @@ +#pragma once + +/// Defining sort text levels assigned to completion items +/// Note that sort text is lexicographically +namespace SortText +{ +static constexpr const char* PrioritisedSuggestion = "0"; +static constexpr const char* TableProperties = "1"; +static constexpr const char* CorrectTypeKind = "2"; +static constexpr const char* CorrectFunctionResult = "3"; +static constexpr const char* Default = "4"; +static constexpr const char* WrongIndexType = "5"; +static constexpr const char* MetatableIndex = "6"; +static constexpr const char* AutoImports = "7"; +static constexpr const char* AutoImportsAbsolute = "71"; +static constexpr const char* Keywords = "8"; +static constexpr const char* Deprioritized = "9"; +} // namespace SortText diff --git a/src/include/LSP/JsonRpc.hpp b/src/include/LSP/JsonRpc.hpp index dfdee875..4c45afaf 100644 --- a/src/include/LSP/JsonRpc.hpp +++ b/src/include/LSP/JsonRpc.hpp @@ -49,17 +49,17 @@ class JsonRpcMessage std::optional result; std::optional error; - bool is_request() + [[nodiscard]] bool is_request() const { return this->id.has_value() && this->method.has_value(); } - bool is_response() + [[nodiscard]] bool is_response() const { return this->id.has_value() && (this->result.has_value() || this->error.has_value()); } - bool is_notification() + [[nodiscard]] bool is_notification() const { return !this->id.has_value() && this->method.has_value(); } diff --git a/src/include/LSP/LanguageServer.hpp b/src/include/LSP/LanguageServer.hpp index 571855c4..df52e572 100644 --- a/src/include/LSP/LanguageServer.hpp +++ b/src/include/LSP/LanguageServer.hpp @@ -1,6 +1,7 @@ #include #include +#include "LSP/JsonRpc.hpp" #include "nlohmann/json.hpp" #include "Protocol/Structures.hpp" @@ -14,6 +15,9 @@ using namespace json_rpc; using WorkspaceFolderPtr = std::shared_ptr; using ClientPtr = std::shared_ptr; +#define JSON_REQUIRED_PARAMS(params, method) \ + (!(params) ? throw json_rpc::JsonRpcException(lsp::ErrorCode::InvalidParams, "params not provided for " method) : (params).value()) + inline lsp::PositionEncodingKind& positionEncoding() { static lsp::PositionEncodingKind encoding = lsp::PositionEncodingKind::UTF16; @@ -31,6 +35,8 @@ class LanguageServer WorkspaceFolderPtr nullWorkspace; std::vector workspaceFolders; + std::vector configPostponedMessages; + public: explicit LanguageServer(ClientPtr aClient, std::optional aDefaultConfig) : client(std::move(aClient)) @@ -52,12 +58,12 @@ class LanguageServer // Dispatch handlers private: + bool allWorkspacesConfigured() const; + void handleMessage(const json_rpc::JsonRpcMessage& msg); + lsp::InitializeResult onInitialize(const lsp::InitializeParams& params); void onInitialized([[maybe_unused]] const lsp::InitializedParams& params); - void pushDiagnostics(WorkspaceFolderPtr& workspace, const lsp::DocumentUri& uri, const size_t version); - void recomputeDiagnostics(WorkspaceFolderPtr& workspace, const ClientConfiguration& config); - void onDidOpenTextDocument(const lsp::DidOpenTextDocumentParams& params); void onDidChangeTextDocument(const lsp::DidChangeTextDocumentParams& params); void onDidCloseTextDocument(const lsp::DidCloseTextDocumentParams& params); @@ -65,9 +71,6 @@ class LanguageServer void onDidChangeWorkspaceFolders(const lsp::DidChangeWorkspaceFoldersParams& params); void onDidChangeWatchedFiles(const lsp::DidChangeWatchedFilesParams& params); - void onStudioPluginFullChange(const PluginNode& dataModel); - void onStudioPluginClear(); - std::vector completion(const lsp::CompletionParams& params); std::vector documentLink(const lsp::DocumentLinkParams& params); lsp::DocumentColorResult documentColor(const lsp::DocumentColorParams& params); diff --git a/src/include/LSP/LuauExt.hpp b/src/include/LSP/LuauExt.hpp index e633bffb..91ea392b 100644 --- a/src/include/LSP/LuauExt.hpp +++ b/src/include/LSP/LuauExt.hpp @@ -8,26 +8,18 @@ #include "Protocol/Structures.hpp" #include "Protocol/Diagnostics.hpp" +#include "nlohmann/json.hpp" + namespace types { -std::optional getTypeIdForClass(const Luau::ScopePtr& globalScope, std::optional className); std::optional getTypeName(Luau::TypeId typeId); bool isMetamethod(const Luau::Name& name); -struct DefinitionsFileMetadata -{ - std::vector CREATABLE_INSTANCES{}; - std::vector SERVICES{}; -}; -NLOHMANN_DEFINE_OPTIONAL(DefinitionsFileMetadata, CREATABLE_INSTANCES, SERVICES) +std::optional parseDefinitionsFileMetadata(const std::string& definitions); -std::optional parseDefinitionsFileMetadata(const std::string& definitions); - -void registerInstanceTypes(Luau::Frontend& frontend, const Luau::GlobalTypes& globals, Luau::TypeArena& arena, - const WorkspaceFileResolver& fileResolver, bool expressiveTypes); -Luau::LoadDefinitionFileResult registerDefinitions(Luau::Frontend& frontend, Luau::GlobalTypes& globals, const std::string& definitions, - bool typeCheckForAutocomplete = false, std::optional metadata = std::nullopt); +Luau::LoadDefinitionFileResult registerDefinitions( + Luau::Frontend& frontend, Luau::GlobalTypes& globals, const std::string& definitions, bool typeCheckForAutocomplete = false); using NameOrExpr = std::variant; @@ -82,25 +74,22 @@ struct FindImportsVisitor : public Luau::AstVisitor std::optional previousRequireLine = std::nullopt; public: - std::optional firstServiceDefinitionLine = std::nullopt; - std::optional lastServiceDefinitionLine = std::nullopt; - std::map serviceLineMap{}; std::optional firstRequireLine = std::nullopt; std::vector> requiresMap{{}}; - size_t findBestLineForService(const std::string& serviceName, size_t minimumLineNumber) + virtual bool handleLocal(Luau::AstStatLocal* local, Luau::AstLocal* localName, Luau::AstExpr* expr, unsigned int line) { - if (firstServiceDefinitionLine) - minimumLineNumber = *firstServiceDefinitionLine > minimumLineNumber ? *firstServiceDefinitionLine : minimumLineNumber; + return false; + } - size_t lineNumber = minimumLineNumber; - for (auto& [definedService, stat] : serviceLineMap) - { - auto location = stat->location.end.line; - if (definedService < serviceName && location >= lineNumber) - lineNumber = location + 1; - } - return lineNumber; + [[nodiscard]] virtual size_t getMinimumRequireLine() const + { + return 0; + } + + [[nodiscard]] virtual bool shouldPrependNewline(size_t lineNumber) const + { + return false; } bool containsRequire(const std::string& module) @@ -124,15 +113,10 @@ struct FindImportsVisitor : public Luau::AstVisitor auto line = expr->location.end.line; - if (isGetService(expr)) - { - firstServiceDefinitionLine = - !firstServiceDefinitionLine.has_value() || firstServiceDefinitionLine.value() >= line ? line : firstServiceDefinitionLine.value(); - lastServiceDefinitionLine = - !lastServiceDefinitionLine.has_value() || lastServiceDefinitionLine.value() <= line ? line : lastServiceDefinitionLine.value(); - serviceLineMap.emplace(std::string(localName->name.value), local); - } - else if (isRequire(expr)) + if (handleLocal(local, localName, expr, line)) + return false; + + if (isRequire(expr)) { firstRequireLine = !firstRequireLine.has_value() || firstRequireLine.value() >= line ? line : firstRequireLine.value(); diff --git a/src/include/LSP/PluginDataModel.hpp b/src/include/LSP/PluginDataModel.hpp deleted file mode 100644 index 6b1887ef..00000000 --- a/src/include/LSP/PluginDataModel.hpp +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once -#include -#include "nlohmann/json.hpp" -using json = nlohmann::json; - -using PluginNodePtr = std::shared_ptr; - -struct PluginNode -{ - std::string name = ""; - std::string className = ""; - std::vector children{}; -}; - -static void from_json(const json& j, PluginNode& p) -{ - j.at("Name").get_to(p.name); - j.at("ClassName").get_to(p.className); - - if (j.contains("Children")) - { - for (auto& child : j.at("Children")) - { - p.children.push_back(std::make_shared(child.get())); - } - } -} diff --git a/src/include/LSP/Sourcemap.hpp b/src/include/LSP/Sourcemap.hpp deleted file mode 100644 index e38c35a2..00000000 --- a/src/include/LSP/Sourcemap.hpp +++ /dev/null @@ -1,54 +0,0 @@ -#pragma once -#include -#include -#include -#include "Luau/Type.h" -#include "Luau/TypeInfer.h" -#include "Luau/GlobalTypes.h" -#include "nlohmann/json.hpp" -#include "LSP/PluginDataModel.hpp" -using json = nlohmann::json; - -using SourceNodePtr = std::shared_ptr; - -struct SourceNode -{ - std::weak_ptr parent; // Can be null! NOT POPULATED BY SOURCEMAP, must be written to manually - std::string name; - std::string className; - std::vector filePaths{}; - std::vector children{}; - std::string virtualPath; // NB: NOT POPULATED BY SOURCEMAP, must be written to manually - // The corresponding TypeId for this sourcemap node - // A different TypeId is created for each type checker (frontend.typeChecker and frontend.typeCheckerForAutocomplete) - std::unordered_map tys{}; // NB: NOT POPULATED BY SOURCEMAP, created manually. Can be null! - - bool isScript(); - std::optional getScriptFilePath(); - Luau::SourceCode::Type sourceCodeType() const; - std::optional findChild(const std::string& name); - // O(n) search for ancestor of name - std::optional findAncestor(const std::string& name); - - // Studio Plugin - void mutateWithPluginInfo(const PluginNodePtr& pluginInfo); -}; - -static void from_json(const json& j, SourceNode& p) -{ - j.at("name").get_to(p.name); - j.at("className").get_to(p.className); - - if (j.contains("filePaths")) - j.at("filePaths").get_to(p.filePaths); - - if (j.contains("children")) - { - for (auto& child : j.at("children")) - { - p.children.push_back(std::make_shared(child.get())); - } - } -} -Luau::SourceCode::Type sourceCodeTypeFromPath(const std::filesystem::path& requirePath); -std::string jsonValueToLuau(const json& val); diff --git a/src/include/LSP/Utils.hpp b/src/include/LSP/Utils.hpp index 8049cb1f..8cc68cbc 100644 --- a/src/include/LSP/Utils.hpp +++ b/src/include/LSP/Utils.hpp @@ -7,7 +7,9 @@ #include #include #include -#include + +// TODO: must duplicate using to avoid cyclical includes +using SourceNodePtr = std::shared_ptr; std::optional getParentPath(const std::string& path); std::optional getAncestorPath(const std::string& path, const std::string& ancestorName, const SourceNodePtr& rootSourceNode); diff --git a/src/include/LSP/Workspace.hpp b/src/include/LSP/Workspace.hpp index 6dfcb15b..5c6c7c46 100644 --- a/src/include/LSP/Workspace.hpp +++ b/src/include/LSP/Workspace.hpp @@ -1,5 +1,7 @@ #pragma once #include +#include +#include "Platform/LSPPlatform.hpp" #include "Luau/Frontend.h" #include "Luau/Autocomplete.h" #include "Protocol/Structures.hpp" @@ -26,13 +28,13 @@ class WorkspaceFolder { public: std::shared_ptr client; + std::unique_ptr platform; std::string name; lsp::DocumentUri rootUri; WorkspaceFileResolver fileResolver; Luau::Frontend frontend; bool isConfigured = false; - Luau::TypeArena instanceTypes; - std::optional definitionsFileMetadata; + std::optional definitionsFileMetadata; public: WorkspaceFolder(const std::shared_ptr& client, std::string name, const lsp::DocumentUri& uri, std::optional defaultConfig) @@ -61,6 +63,8 @@ class WorkspaceFolder const lsp::DocumentUri& uri, const lsp::DidChangeTextDocumentParams& params, std::vector* markedDirty = nullptr); void closeTextDocument(const lsp::DocumentUri& uri); + void onDidChangeWatchedFiles(const lsp::FileEvent& change); + /// Whether the file has been marked as ignored by any of the ignored lists in the configuration bool isIgnoredFile(const std::filesystem::path& path, const std::optional& givenConfig = std::nullopt); /// Whether the file has been specified in the configuration as a definitions file @@ -68,6 +72,8 @@ class WorkspaceFolder lsp::DocumentDiagnosticReport documentDiagnostics(const lsp::DocumentDiagnosticParams& params); lsp::WorkspaceDiagnosticReport workspaceDiagnostics(const lsp::WorkspaceDiagnosticParams& params); + void recomputeDiagnostics(const ClientConfiguration& config); + void pushDiagnostics(const lsp::DocumentUri& uri, const size_t version); void clearDiagnosticsForFile(const lsp::DocumentUri& uri); @@ -79,9 +85,8 @@ class WorkspaceFolder private: void endAutocompletion(const lsp::CompletionParams& params); void suggestImports(const Luau::ModuleName& moduleName, const Luau::Position& position, const ClientConfiguration& config, - const TextDocument& textDocument, std::vector& result, bool includeServices = true); + const TextDocument& textDocument, std::vector& result, bool completingTypeReferencePrefix = true); lsp::WorkspaceEdit computeOrganiseRequiresEdit(const lsp::DocumentUri& uri); - lsp::WorkspaceEdit computeOrganiseServicesEdit(const lsp::DocumentUri& uri); std::vector findReverseDependencies(const Luau::ModuleName& moduleName); public: @@ -123,8 +128,6 @@ class WorkspaceFolder lsp::BytecodeResult bytecode(const lsp::BytecodeParams& params); lsp::CompilerRemarksResult compilerRemarks(const lsp::CompilerRemarksParams& params); - bool updateSourceMap(); - bool isNullWorkspace() const { return name == "$NULL_WORKSPACE"; diff --git a/src/include/LSP/WorkspaceFileResolver.hpp b/src/include/LSP/WorkspaceFileResolver.hpp index 6de00314..dc4124dd 100644 --- a/src/include/LSP/WorkspaceFileResolver.hpp +++ b/src/include/LSP/WorkspaceFileResolver.hpp @@ -8,8 +8,8 @@ #include "Luau/Config.h" #include "LSP/Client.hpp" #include "LSP/Uri.hpp" -#include "LSP/Sourcemap.hpp" #include "LSP/TextDocument.hpp" +#include "Platform/LSPPlatform.hpp" // A wrapper around a text document pointer @@ -68,28 +68,19 @@ struct TextDocumentPtr } }; -std::optional resolveDirectoryAlias( - const std::filesystem::path& rootPath, const std::unordered_map& directoryAliases, const std::string& str); - struct WorkspaceFileResolver : Luau::FileResolver , Luau::ConfigResolver { private: - mutable std::unordered_map realPathsToSourceNodes{}; mutable std::unordered_map configCache{}; public: Luau::Config defaultConfig; std::shared_ptr client; Uri rootUri; - // The root source node from a parsed Rojo source map - SourceNodePtr rootSourceNode; - - mutable std::unordered_map virtualPathsToSourceNodes{}; - // Plugin-provided DataModel information - PluginNodePtr pluginInfo; + LSPPlatform* platform = nullptr; // Currently opened files where content is managed by client mutable std::unordered_map managedFiles{}; @@ -112,38 +103,16 @@ struct WorkspaceFileResolver TextDocumentPtr getOrCreateTextDocumentFromModuleName(const Luau::ModuleName& name); - /// The name points to a virtual path (i.e., game/ or ProjectRoot/) - static bool isVirtualPath(const Luau::ModuleName& name) - { - return name == "game" || name == "ProjectRoot" || Luau::startsWith(name, "game/") || Luau::startsWith(name, "ProjectRoot/"); - } - - std::filesystem::path getRequireBasePath(std::optional fileModuleName) const; - - std::optional resolveStringRequire(const Luau::ModuleInfo* context, const std::string& requiredString) const; - // Return the corresponding module name from a file Uri // We first try and find a virtual file path which matches it, and return that. Otherwise, we use the file system path Luau::ModuleName getModuleName(const Uri& name) const; - std::optional getSourceNodeFromVirtualPath(const Luau::ModuleName& name) const; - - std::optional getSourceNodeFromRealPath(const std::string& name) const; - - std::optional getRealPathFromSourceNode(const SourceNodePtr& sourceNode) const; - - std::optional resolveToRealPath(const Luau::ModuleName& name) const; - std::optional readSource(const Luau::ModuleName& name) override; std::optional resolveModule(const Luau::ModuleInfo* context, Luau::AstExpr* node) override; std::string getHumanReadableModuleName(const Luau::ModuleName& name) const override; const Luau::Config& getConfig(const Luau::ModuleName& name) const override; void clearConfigCache(); - void updateSourceMap(const std::string& sourceMapContents); private: - static Luau::ModuleName getVirtualPathFromSourceNode(const SourceNodePtr& sourceNode); - std::optional resolveToVirtualPath(const std::string& name) const; const Luau::Config& readConfigRec(const std::filesystem::path& path) const; - void writePathsToMap(const SourceNodePtr& node, const std::string& base); }; diff --git a/src/include/Platform/LSPPlatform.hpp b/src/include/Platform/LSPPlatform.hpp new file mode 100644 index 00000000..4a362aea --- /dev/null +++ b/src/include/Platform/LSPPlatform.hpp @@ -0,0 +1,132 @@ +#pragma once + +#include "LSP/ClientConfiguration.hpp" +#include "LSP/TextDocument.hpp" +#include "Luau/Ast.h" +#include "Luau/Autocomplete.h" +#include "Luau/FileResolver.h" +#include "Luau/Frontend.h" +#include "Luau/GlobalTypes.h" +#include "Luau/Module.h" +#include "Luau/TypeFwd.h" +#include "Protocol/CodeAction.hpp" +#include "Protocol/Completion.hpp" +#include "Protocol/LanguageFeatures.hpp" +#include "Protocol/SignatureHelp.hpp" +#include "Protocol/Workspace.hpp" +#include "nlohmann/json.hpp" + +#include +#include +#include + +class WorkspaceFolder; +struct WorkspaceFileResolver; + +class LSPPlatform +{ +protected: + WorkspaceFileResolver* fileResolver; + WorkspaceFolder* workspaceFolder; + +public: + virtual void mutateRegisteredDefinitions(Luau::GlobalTypes& globals, std::optional metadata) {} + + virtual void onDidChangeWatchedFiles(const lsp::FileEvent& change) {} + + virtual void setupWithConfiguration(const ClientConfiguration& config) {} + + /// The name points to a virtual path (i.e. for Roblox, game/ or ProjectRoot/) + [[nodiscard]] virtual bool isVirtualPath(const Luau::ModuleName& name) const + { + return false; + } + + [[nodiscard]] virtual std::optional resolveToVirtualPath(const std::string& name) const + { + return std::nullopt; + } + + [[nodiscard]] virtual std::optional resolveToRealPath(const Luau::ModuleName& name) const + { + return name; + } + + [[nodiscard]] virtual Luau::SourceCode::Type sourceCodeTypeFromPath(const std::filesystem::path& path) const + { + return Luau::SourceCode::Type::Module; + } + + [[nodiscard]] virtual std::optional readSourceCode(const Luau::ModuleName& name, const std::filesystem::path& path) const; + + std::optional resolveStringRequire(const Luau::ModuleInfo* context, const std::string& requiredString); + virtual std::optional resolveModule(const Luau::ModuleInfo* context, Luau::AstExpr* node); + + virtual void handleCompletion( + const TextDocument& textDocument, const Luau::SourceModule& module, Luau::Position position, std::vector& items) + { + } + + virtual std::optional completionCallback( + const std::string& tag, std::optional ctx, std::optional contents, const Luau::ModuleName& moduleName); + + virtual const char* handleSortText( + const Luau::Frontend& frontend, const std::string& name, const Luau::AutocompleteEntry& entry, const std::unordered_set& tags) + { + return nullptr; + } + + virtual std::optional handleEntryKind(const Luau::AutocompleteEntry& entry) + { + return std::nullopt; + } + + virtual void handleSuggestImports(const TextDocument& textDocument, const Luau::SourceModule& module, const ClientConfiguration& config, + size_t hotCommentsLineNumber, bool completingTypeReferencePrefix, std::vector& items) + { + } + + virtual void handleSignatureHelp( + const TextDocument& textDocument, const Luau::SourceModule& module, Luau::Position position, lsp::SignatureHelp& signatureHelp) + { + } + + virtual void handleCodeAction(const lsp::CodeActionParams& params, std::vector& items) {} + + virtual lsp::DocumentColorResult documentColor(const TextDocument& textDocument, const Luau::SourceModule& module) + { + return {}; + } + + virtual lsp::ColorPresentationResult colorPresentation(const lsp::ColorPresentationParams& params) + { + return {}; + } + + virtual std::optional handleHover(const TextDocument& textDocument, const Luau::SourceModule& module, Luau::Position position) + { + return std::nullopt; + } + + virtual bool handleNotification(const std::string& method, std::optional params) + { + return false; + } + + static std::unique_ptr getPlatform( + const ClientConfiguration& config, WorkspaceFileResolver* fileResolver, WorkspaceFolder* workspaceFolder = nullptr); + + LSPPlatform(const LSPPlatform& copyFrom) = delete; + LSPPlatform(LSPPlatform&&) = delete; + LSPPlatform& operator=(const LSPPlatform& copyFrom) = delete; + LSPPlatform& operator=(LSPPlatform&&) = delete; + + LSPPlatform(WorkspaceFileResolver* fileResolver = nullptr, WorkspaceFolder* workspaceFolder = nullptr); + virtual ~LSPPlatform() = default; + +private: + [[nodiscard]] std::filesystem::path getRequireBasePath(std::optional fileModuleName) const; +}; + +std::optional resolveDirectoryAlias( + const std::filesystem::path& rootPath, const std::unordered_map& directoryAliases, const std::string& str); diff --git a/src/include/Platform/RobloxPlatform.hpp b/src/include/Platform/RobloxPlatform.hpp new file mode 100644 index 00000000..efa38ddc --- /dev/null +++ b/src/include/Platform/RobloxPlatform.hpp @@ -0,0 +1,196 @@ +#pragma once + +#include "LSP/LuauExt.hpp" +#include "Platform/LSPPlatform.hpp" + +using json = nlohmann::json; +using SourceNodePtr = std::shared_ptr; +using PluginNodePtr = std::shared_ptr; + +struct RobloxDefinitionsFileMetadata +{ + std::vector CREATABLE_INSTANCES{}; + std::vector SERVICES{}; +}; +NLOHMANN_DEFINE_OPTIONAL(RobloxDefinitionsFileMetadata, CREATABLE_INSTANCES, SERVICES) + +struct RobloxFindImportsVisitor : public FindImportsVisitor +{ +public: + std::optional firstServiceDefinitionLine = std::nullopt; + std::optional lastServiceDefinitionLine = std::nullopt; + std::map serviceLineMap{}; + + size_t findBestLineForService(const std::string& serviceName, size_t minimumLineNumber) + { + if (firstServiceDefinitionLine) + minimumLineNumber = *firstServiceDefinitionLine > minimumLineNumber ? *firstServiceDefinitionLine : minimumLineNumber; + + size_t lineNumber = minimumLineNumber; + for (auto& [definedService, stat] : serviceLineMap) + { + auto location = stat->location.end.line; + if (definedService < serviceName && location >= lineNumber) + lineNumber = location + 1; + } + return lineNumber; + } + + bool handleLocal(Luau::AstStatLocal* local, Luau::AstLocal* localName, Luau::AstExpr* expr, unsigned int line) override + { + if (!isGetService(expr)) + return false; + + firstServiceDefinitionLine = + !firstServiceDefinitionLine.has_value() || firstServiceDefinitionLine.value() >= line ? line : firstServiceDefinitionLine.value(); + lastServiceDefinitionLine = + !lastServiceDefinitionLine.has_value() || lastServiceDefinitionLine.value() <= line ? line : lastServiceDefinitionLine.value(); + serviceLineMap.emplace(std::string(localName->name.value), local); + + return true; + } + + [[nodiscard]] size_t getMinimumRequireLine() const override + { + if (lastServiceDefinitionLine) + return *lastServiceDefinitionLine + 1; + + return 0; + } + + [[nodiscard]] bool shouldPrependNewline(size_t lineNumber) const override + { + return lastServiceDefinitionLine && lineNumber - *lastServiceDefinitionLine == 1; + } +}; + +struct SourceNode +{ + std::weak_ptr parent; // Can be null! NOT POPULATED BY SOURCEMAP, must be written to manually + std::string name; + std::string className; + std::vector filePaths{}; + std::vector children{}; + std::string virtualPath; // NB: NOT POPULATED BY SOURCEMAP, must be written to manually + // The corresponding TypeId for this sourcemap node + // A different TypeId is created for each type checker (frontend.typeChecker and frontend.typeCheckerForAutocomplete) + std::unordered_map tys{}; // NB: NOT POPULATED BY SOURCEMAP, created manually. Can be null! + + bool isScript(); + std::optional getScriptFilePath(); + Luau::SourceCode::Type sourceCodeType() const; + std::optional findChild(const std::string& name); + // O(n) search for ancestor of name + std::optional findAncestor(const std::string& name); +}; + +static void from_json(const json& j, SourceNode& p) +{ + j.at("name").get_to(p.name); + j.at("className").get_to(p.className); + + if (j.contains("filePaths")) + j.at("filePaths").get_to(p.filePaths); + + if (j.contains("children")) + { + for (auto& child : j.at("children")) + { + p.children.push_back(std::make_shared(child.get())); + } + } +} + +struct PluginNode +{ + std::string name = ""; + std::string className = ""; + std::vector children{}; +}; + +static void from_json(const json& j, PluginNode& p) +{ + j.at("Name").get_to(p.name); + j.at("ClassName").get_to(p.className); + + if (j.contains("Children")) + { + for (auto& child : j.at("Children")) + { + p.children.push_back(std::make_shared(child.get())); + } + } +} + +class RobloxPlatform : public LSPPlatform +{ +private: + // Plugin-provided DataModel information + PluginNodePtr pluginInfo; + + mutable std::unordered_map realPathsToSourceNodes{}; + mutable std::unordered_map virtualPathsToSourceNodes{}; + + std::optional getSourceNodeFromVirtualPath(const Luau::ModuleName& name) const; + std::optional getSourceNodeFromRealPath(const std::string& name) const; + std::optional getRealPathFromSourceNode(const SourceNodePtr& sourceNode) const; + static Luau::ModuleName getVirtualPathFromSourceNode(const SourceNodePtr& sourceNode); + + bool updateSourceMap(); + void writePathsToMap(const SourceNodePtr& node, const std::string& base); + +public: + // The root source node from a parsed Rojo source map + SourceNodePtr rootSourceNode; + + Luau::TypeArena instanceTypes; + + void mutateRegisteredDefinitions(Luau::GlobalTypes& globals, std::optional metadata) override; + + void onDidChangeWatchedFiles(const lsp::FileEvent& change) override; + + void setupWithConfiguration(const ClientConfiguration& config) override; + + bool isVirtualPath(const Luau::ModuleName& name) const override + { + return name == "game" || name == "ProjectRoot" || Luau::startsWith(name, "game/") || Luau::startsWith(name, "ProjectRoot/"); + } + + std::optional resolveToVirtualPath(const std::string& name) const override; + + std::optional resolveToRealPath(const Luau::ModuleName& name) const override; + + Luau::SourceCode::Type sourceCodeTypeFromPath(const std::filesystem::path& path) const override; + + std::optional readSourceCode(const Luau::ModuleName& name, const std::filesystem::path& path) const override; + + std::optional resolveModule(const Luau::ModuleInfo* context, Luau::AstExpr* node) override; + + void updateSourceNodeMap(const std::string& sourceMapContents); + + void handleSourcemapUpdate(Luau::Frontend& frontend, const Luau::GlobalTypes& globals, bool expressiveTypes); + + std::optional completionCallback(const std::string& tag, std::optional ctx, + std::optional contents, const Luau::ModuleName& moduleName) override; + + const char* handleSortText(const Luau::Frontend& frontend, const std::string& name, const Luau::AutocompleteEntry& entry, + const std::unordered_set& tags) override; + + std::optional handleEntryKind(const Luau::AutocompleteEntry& entry) override; + + void handleSuggestImports(const TextDocument& textDocument, const Luau::SourceModule& module, const ClientConfiguration& config, + size_t hotCommentsLineNumber, bool completingTypeReferencePrefix, std::vector& items) override; + + lsp::WorkspaceEdit computeOrganiseServicesEdit(const lsp::DocumentUri& uri); + void handleCodeAction(const lsp::CodeActionParams& params, std::vector& items) override; + + lsp::DocumentColorResult documentColor(const TextDocument& textDocument, const Luau::SourceModule& module) override; + + lsp::ColorPresentationResult colorPresentation(const lsp::ColorPresentationParams& params) override; + + void onStudioPluginFullChange(const PluginNode& dataModel); + void onStudioPluginClear(); + bool handleNotification(const std::string& method, std::optional params) override; + + using LSPPlatform::LSPPlatform; +}; diff --git a/src/include/Protocol/CodeAction.hpp b/src/include/Protocol/CodeAction.hpp index f0febf7f..ef71f6f0 100644 --- a/src/include/Protocol/CodeAction.hpp +++ b/src/include/Protocol/CodeAction.hpp @@ -3,6 +3,7 @@ #include #include +#include "LSP/Utils.hpp" #include "Protocol/Diagnostics.hpp" #include "Protocol/Structures.hpp" @@ -153,6 +154,10 @@ struct CodeActionContext */ // TODO: this is technicall optional, but it causes build issues CodeActionTriggerKind triggerKind = CodeActionTriggerKind::Invoked; + + [[nodiscard]] bool wants(lsp::CodeActionKind kind) const { + return only.empty() || contains(only, kind); + } }; NLOHMANN_DEFINE_OPTIONAL(CodeActionContext, diagnostics, only, triggerKind) diff --git a/src/main.cpp b/src/main.cpp index c204899b..fd410f5c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -249,6 +249,9 @@ int main(int argc, char** argv) .help("path to a .luaurc file which acts as the base default configuration") .action(file_path_parser) .metavar("PATH"); + analyze_command.add_argument("--platform") + .help("platform-specific support features") + .choices("standard", "roblox"); analyze_command.add_argument("--settings").help("path to LSP-style settings").action(file_path_parser).metavar("PATH"); analyze_command.add_argument("files").help("files to perform analysis on").remaining(); diff --git a/src/operations/CallHierarchy.cpp b/src/operations/CallHierarchy.cpp index 0c775202..84e8b65e 100644 --- a/src/operations/CallHierarchy.cpp +++ b/src/operations/CallHierarchy.cpp @@ -396,4 +396,4 @@ std::vector WorkspaceFolder::callHierarchyOutgoi } return result; -} \ No newline at end of file +} diff --git a/src/operations/CodeAction.cpp b/src/operations/CodeAction.cpp index 47920f72..43d51d40 100644 --- a/src/operations/CodeAction.cpp +++ b/src/operations/CodeAction.cpp @@ -69,63 +69,6 @@ lsp::WorkspaceEdit WorkspaceFolder::computeOrganiseRequiresEdit(const lsp::Docum return workspaceEdit; } -lsp::WorkspaceEdit WorkspaceFolder::computeOrganiseServicesEdit(const lsp::DocumentUri& uri) -{ - auto moduleName = fileResolver.getModuleName(uri); - auto textDocument = fileResolver.getTextDocument(uri); - - if (!textDocument) - throw JsonRpcException(lsp::ErrorCode::RequestFailed, "No managed text document for " + uri.toString()); - - frontend.parse(moduleName); - - auto sourceModule = frontend.getSourceModule(moduleName); - if (!sourceModule) - return {}; - - // Find all `local X = game:GetService("Service")` - FindImportsVisitor visitor; - visitor.visit(sourceModule->root); - - if (visitor.serviceLineMap.empty()) - return {}; - - // Test to see that if all the services are already sorted -> if they are, then just leave alone - // to prevent clogging the undo history stack - Luau::Location previousServiceLocation{{0, 0}, {0, 0}}; - bool isSorted = true; - for (const auto& [_, stat] : visitor.serviceLineMap) - { - if (stat->location.begin < previousServiceLocation.begin) - { - isSorted = false; - break; - } - previousServiceLocation = stat->location; - } - if (isSorted) - return {}; - - std::vector edits; - // We firstly delete all the previous services, as they will be added later - edits.reserve(visitor.serviceLineMap.size()); - for (const auto& [_, stat] : visitor.serviceLineMap) - edits.emplace_back(lsp::TextEdit{{{stat->location.begin.line, 0}, {stat->location.begin.line + 1, 0}}, ""}); - - // We find the first line to add these services to, and then add them in sorted order - lsp::Range insertLocation{{visitor.firstServiceDefinitionLine.value(), 0}, {visitor.firstServiceDefinitionLine.value(), 0}}; - for (const auto& [serviceName, stat] : visitor.serviceLineMap) - { - // We need to rewrite the statement as we expected it - auto importText = Luau::toString(stat) + "\n"; - edits.emplace_back(lsp::TextEdit{insertLocation, importText}); - } - - lsp::WorkspaceEdit workspaceEdit; - workspaceEdit.changes.emplace(uri.toString(), edits); - return workspaceEdit; -} - lsp::CodeActionResult WorkspaceFolder::codeAction(const lsp::CodeActionParams& params) { std::vector result; @@ -133,8 +76,7 @@ lsp::CodeActionResult WorkspaceFolder::codeAction(const lsp::CodeActionParams& p auto config = client->getConfiguration(rootUri); // Add organise imports code action - if (params.context.only.empty() || contains(params.context.only, lsp::CodeActionKind::Source) || - contains(params.context.only, lsp::CodeActionKind::SourceOrganizeImports)) + if (params.context.wants(lsp::CodeActionKind::Source) || params.context.wants(lsp::CodeActionKind::SourceOrganizeImports)) { // Add sort requires code action lsp::CodeAction organiseImportsAction; @@ -147,18 +89,10 @@ lsp::CodeActionResult WorkspaceFolder::codeAction(const lsp::CodeActionParams& p // contains(client->capabilities.textDocument->codeAction->resolveSupport->properties, "edit")) organiseImportsAction.edit = computeOrganiseRequiresEdit(params.textDocument.uri); result.emplace_back(organiseImportsAction); - - // If in Roblox mode, add a sort services code action - if (config.types.roblox) - { - lsp::CodeAction sortServicesAction; - sortServicesAction.title = "Sort services"; - sortServicesAction.kind = lsp::CodeActionKind::SourceOrganizeImports; - sortServicesAction.edit = computeOrganiseServicesEdit(params.textDocument.uri); - result.emplace_back(sortServicesAction); - } } + platform->handleCodeAction(params, result); + return result; } @@ -166,4 +100,4 @@ lsp::CodeActionResult LanguageServer::codeAction(const lsp::CodeActionParams& pa { auto workspace = findWorkspace(params.textDocument.uri); return workspace->codeAction(params); -} \ No newline at end of file +} diff --git a/src/operations/ColorProvider.cpp b/src/operations/ColorProvider.cpp index 83c776cb..956c1251 100644 --- a/src/operations/ColorProvider.cpp +++ b/src/operations/ColorProvider.cpp @@ -2,6 +2,7 @@ #include "LSP/Workspace.hpp" #include "LSP/ColorProvider.hpp" +#include "Platform/LSPPlatform.hpp" #include "Protocol/LanguageFeatures.hpp" #include @@ -112,124 +113,9 @@ std::string rgbToHex(RGB in) return hexString.str(); } -struct DocumentColorVisitor : public Luau::AstVisitor -{ - const TextDocument* textDocument; - std::vector colors{}; - - explicit DocumentColorVisitor(const TextDocument* textDocument) - : textDocument(textDocument) - { - } - - bool visit(Luau::AstExprCall* call) override - { - if (auto index = call->func->as()) - { - if (auto global = index->expr->as()) - { - if (global->name == "Color3") - { - if (index->index == "new" || index->index == "fromRGB" || index->index == "fromHSV" || index->index == "fromHex") - { - std::array color = {0.0, 0.0, 0.0}; - - if (index->index == "new") - { - size_t argIndex = 0; - for (auto arg : call->args) - { - if (argIndex >= 3) - return true; // Don't create as the colour is not in the right format - if (auto number = arg->as()) - color.at(argIndex) = number->value; - else - return true; // Don't create as we can't parse the full colour - argIndex++; - } - } - else if (index->index == "fromRGB") - { - size_t argIndex = 0; - for (auto arg : call->args) - { - if (argIndex >= 3) - return true; // Don't create as the colour is not in the right format - if (auto number = arg->as()) - color.at(argIndex) = number->value / 255.0; - else - return true; // Don't create as we can't parse the full colour - argIndex++; - } - } - else if (index->index == "fromHSV") - { - size_t argIndex = 0; - for (auto arg : call->args) - { - if (argIndex >= 3) - return true; // Don't create as the colour is not in the right format - if (auto number = arg->as()) - color.at(argIndex) = number->value; - else - return true; // Don't create as we can't parse the full colour - argIndex++; - } - RGB data = hsvToRgb({color[0], color[1], color[2]}); - color[0] = data.r / 255.0; - color[1] = data.g / 255.0; - color[2] = data.b / 255.0; - } - else if (index->index == "fromHex") - { - if (call->args.size != 1) - return true; // Don't create as the colour is not in the right format - if (auto string = call->args.data[0]->as()) - { - try - { - RGB data = hexToRgb(std::string(string->value.data, string->value.size)); - color[0] = data.r / 255.0; - color[1] = data.g / 255.0; - color[2] = data.b / 255.0; - } - catch (const std::exception&) - { - return true; // Invalid hex string - } - } - else - return true; // Don't create as we can't parse the full colour - } - - colors.emplace_back(lsp::ColorInformation{ - lsp::Range{textDocument->convertPosition(call->location.begin), textDocument->convertPosition(call->location.end)}, - {std::clamp(color[0], 0.0, 1.0), std::clamp(color[1], 0.0, 1.0), std::clamp(color[2], 0.0, 1.0), 1.0}}); - } - } - } - } - - return true; - } - - bool visit(Luau::AstStatBlock* block) override - { - for (Luau::AstStat* stat : block->body) - { - stat->visit(this); - } - - return false; - } -}; - lsp::DocumentColorResult WorkspaceFolder::documentColor(const lsp::DocumentColorParams& params) { - // Only enabled for Roblox code auto config = client->getConfiguration(rootUri); - if (!config.types.roblox) - return {}; auto moduleName = fileResolver.getModuleName(params.textDocument.uri); auto textDocument = fileResolver.getTextDocument(params.textDocument.uri); @@ -242,9 +128,7 @@ lsp::DocumentColorResult WorkspaceFolder::documentColor(const lsp::DocumentColor if (!sourceModule) return {}; - DocumentColorVisitor visitor{textDocument}; - visitor.visit(sourceModule->root); - return visitor.colors; + return platform->documentColor(*textDocument, *sourceModule); } lsp::DocumentColorResult LanguageServer::documentColor(const lsp::DocumentColorParams& params) @@ -255,37 +139,11 @@ lsp::DocumentColorResult LanguageServer::documentColor(const lsp::DocumentColorP lsp::ColorPresentationResult WorkspaceFolder::colorPresentation(const lsp::ColorPresentationParams& params) { - // Create color presentations - lsp::ColorPresentationResult presentations; - - // Add Color3.new - presentations.emplace_back(lsp::ColorPresentation{"Color3.new(" + std::to_string(params.color.red) + ", " + std::to_string(params.color.green) + - ", " + std::to_string(params.color.blue) + ")"}); - - // Convert to RGB values - RGB rgb{ - (int)std::floor(params.color.red * 255.0), - (int)std::floor(params.color.green * 255.0), - (int)std::floor(params.color.blue * 255.0), - }; - - // Add Color3.fromRGB - presentations.emplace_back( - lsp::ColorPresentation{"Color3.fromRGB(" + std::to_string(rgb.r) + ", " + std::to_string(rgb.g) + ", " + std::to_string(rgb.b) + ")"}); - - // Add Color3.fromHSV - HSV hsv = rgbToHsv(rgb); - presentations.emplace_back( - lsp::ColorPresentation{"Color3.fromHSV(" + std::to_string(hsv.h) + ", " + std::to_string(hsv.s) + ", " + std::to_string(hsv.v) + ")"}); - - // Add Color3.fromHex - presentations.emplace_back(lsp::ColorPresentation{"Color3.fromHex(\"" + rgbToHex(rgb) + "\")"}); - - return presentations; + return platform->colorPresentation(params); } lsp::ColorPresentationResult LanguageServer::colorPresentation(const lsp::ColorPresentationParams& params) { auto workspace = findWorkspace(params.textDocument.uri); return workspace->colorPresentation(params); -} \ No newline at end of file +} diff --git a/src/operations/Completion.cpp b/src/operations/Completion.cpp index f661623f..b1aaa468 100644 --- a/src/operations/Completion.cpp +++ b/src/operations/Completion.cpp @@ -1,67 +1,17 @@ +#include +#include + #include "Luau/AstQuery.h" #include "Luau/Autocomplete.h" +#include "Luau/TxnLog.h" #include "Luau/TypeUtils.h" +#include "LSP/Completion.hpp" #include "LSP/LanguageServer.hpp" #include "LSP/Workspace.hpp" #include "LSP/LuauExt.hpp" #include "LSP/DocumentationParser.hpp" -/// Defining sort text levels assigned to completion items -/// Note that sort text is lexicographically -namespace SortText -{ -static constexpr const char* PrioritisedSuggestion = "0"; -static constexpr const char* TableProperties = "1"; -static constexpr const char* CorrectTypeKind = "2"; -static constexpr const char* CorrectFunctionResult = "3"; -static constexpr const char* Default = "4"; -static constexpr const char* WrongIndexType = "5"; -static constexpr const char* MetatableIndex = "6"; -static constexpr const char* AutoImports = "7"; -static constexpr const char* AutoImportsAbsolute = "71"; -static constexpr const char* Keywords = "8"; -static constexpr const char* Deprioritized = "9"; -} // namespace SortText - -static constexpr const char* COMMON_SERVICES[] = { - "Players", - "ReplicatedStorage", - "ServerStorage", - "MessagingService", - "TeleportService", - "HttpService", - "CollectionService", - "DataStoreService", - "ContextActionService", - "UserInputService", - "Teams", - "Chat", - "TextService", - "TextChatService", - "GamepadService", - "VoiceChatService", -}; - -static constexpr const char* COMMON_INSTANCE_PROPERTIES[] = { - "Parent", - "Name", - // Methods - "FindFirstChild", - "IsA", - "Destroy", - "GetAttribute", - "GetChildren", - "GetDescendants", - "WaitForChild", - "Clone", - "SetAttribute", -}; - -static constexpr const char* COMMON_SERVICE_PROVIDER_PROPERTIES[] = { - "GetService", -}; - void WorkspaceFolder::endAutocompletion(const lsp::CompletionParams& params) { auto moduleName = fileResolver.getModuleName(params.textDocument.uri); @@ -203,89 +153,8 @@ void WorkspaceFolder::endAutocompletion(const lsp::CompletionParams& params) } } -static lsp::TextEdit createRequireTextEdit(const std::string& name, const std::string& path, size_t lineNumber, bool prependNewline = false) -{ - auto range = lsp::Range{{lineNumber, 0}, {lineNumber, 0}}; - auto importText = "local " + name + " = require(" + path + ")\n"; - if (prependNewline) - importText = "\n" + importText; - return {range, importText}; -} - -static lsp::TextEdit createServiceTextEdit(const std::string& name, size_t lineNumber, bool appendNewline = false) -{ - auto range = lsp::Range{{lineNumber, 0}, {lineNumber, 0}}; - auto importText = "local " + name + " = game:GetService(\"" + name + "\")\n"; - if (appendNewline) - importText += "\n"; - return {range, importText}; -} - -static lsp::CompletionItem createSuggestService(const std::string& service, size_t lineNumber, bool appendNewline = false) -{ - auto textEdit = createServiceTextEdit(service, lineNumber, appendNewline); - - lsp::CompletionItem item; - item.label = service; - item.kind = lsp::CompletionItemKind::Class; - item.detail = "Auto-import"; - item.documentation = {lsp::MarkupKind::Markdown, codeBlock("luau", textEdit.newText)}; - item.insertText = service; - item.sortText = SortText::AutoImports; - - item.additionalTextEdits.emplace_back(textEdit); - - return item; -} - -static lsp::CompletionItem createSuggestRequire( - const std::string& name, const std::vector& textEdits, const char* sortText, const std::string& path) -{ - std::string documentation; - for (const auto& edit : textEdits) - documentation += edit.newText; - - lsp::CompletionItem item; - item.label = name; - item.kind = lsp::CompletionItemKind::Module; - item.detail = "Auto-import"; - item.documentation = {lsp::MarkupKind::Markdown, codeBlock("luau", documentation) + "\n\n" + path}; - item.insertText = name; - item.sortText = sortText; - - item.additionalTextEdits = textEdits; - - return item; -} - -static size_t getLengthEqual(const std::string& a, const std::string& b) -{ - size_t i = 0; - for (; i < a.size() && i < b.size(); ++i) - { - if (a[i] != b[i]) - break; - } - return i; -} - -static std::string optimiseAbsoluteRequire(const std::string& path) -{ - if (!Luau::startsWith(path, "game/")) - return path; - - auto parts = Luau::split(path, '/'); - if (parts.size() > 2) - { - auto service = std::string(parts[1]); - return service + "/" + Luau::join(std::vector(parts.begin() + 2, parts.end()), "/"); - } - - return path; -} - void WorkspaceFolder::suggestImports(const Luau::ModuleName& moduleName, const Luau::Position& position, const ClientConfiguration& config, - const TextDocument& textDocument, std::vector& result, bool includeServices) + const TextDocument& textDocument, std::vector& result, bool completingTypeReferencePrefix) { auto sourceModule = frontend.getSourceModule(moduleName); auto module = frontend.moduleResolverForAutocomplete.getModule(moduleName); @@ -306,124 +175,7 @@ void WorkspaceFolder::suggestImports(const Luau::ModuleName& moduleName, const L hotCommentsLineNumber = hotComment.location.begin.line + 1U; } - // Find all GetService and require calls - FindImportsVisitor importsVisitor; - importsVisitor.visit(sourceModule->root); - - // If in roblox mode - suggest services - if (config.types.roblox && config.completion.imports.suggestServices && includeServices) - { - auto services = definitionsFileMetadata.has_value() ? definitionsFileMetadata->SERVICES : std::vector{}; - for (auto& service : services) - { - // ASSUMPTION: if the service was defined, it was defined with the exact same name - if (contains(importsVisitor.serviceLineMap, service)) - continue; - - size_t lineNumber = importsVisitor.findBestLineForService(service, hotCommentsLineNumber); - - bool appendNewline = false; - if (config.completion.imports.separateGroupsWithLine && importsVisitor.firstRequireLine && - importsVisitor.firstRequireLine.value() - lineNumber == 0) - appendNewline = true; - - result.emplace_back(createSuggestService(service, lineNumber, appendNewline)); - } - } - - if (config.completion.imports.suggestRequires) - { - size_t minimumLineNumber = hotCommentsLineNumber; - if (importsVisitor.lastServiceDefinitionLine) - minimumLineNumber = - *importsVisitor.lastServiceDefinitionLine >= minimumLineNumber ? (*importsVisitor.lastServiceDefinitionLine + 1) : minimumLineNumber; - - if (importsVisitor.firstRequireLine) - minimumLineNumber = *importsVisitor.firstRequireLine >= minimumLineNumber ? (*importsVisitor.firstRequireLine) : minimumLineNumber; - - for (auto& [path, node] : fileResolver.virtualPathsToSourceNodes) - { - auto name = node->name; - replaceAll(name, " ", "_"); - - if (path == moduleName || node->className != "ModuleScript" || importsVisitor.containsRequire(name)) - continue; - if (auto scriptFilePath = fileResolver.getRealPathFromSourceNode(node); scriptFilePath && isIgnoredFile(*scriptFilePath, config)) - continue; - - std::string requirePath; - std::vector textEdits; - - // Compute the style of require - bool isRelative = false; - auto parent1 = getParentPath(moduleName), parent2 = getParentPath(path); - if (config.completion.imports.requireStyle == ImportRequireStyle::AlwaysRelative || - Luau::startsWith(path, "ProjectRoot/") || // All model projects should always require relatively - (config.completion.imports.requireStyle != ImportRequireStyle::AlwaysAbsolute && - (Luau::startsWith(moduleName, path) || Luau::startsWith(path, moduleName) || parent1 == parent2))) - { - requirePath = "./" + std::filesystem::relative(path, moduleName).string(); - isRelative = true; - } - else - requirePath = optimiseAbsoluteRequire(path); - - auto require = convertToScriptPath(requirePath); - - size_t lineNumber = minimumLineNumber; - size_t bestLength = 0; - for (auto& group : importsVisitor.requiresMap) - { - for (auto& [_, stat] : group) - { - auto line = stat->location.end.line; - - // HACK: We read the text of the require argument to sort the lines - // Note: requires may be in the form `require(path) :: any`, so we need to handle that too - Luau::AstExprCall* call = stat->values.data[0]->as(); - if (auto assertion = stat->values.data[0]->as()) - call = assertion->expr->as(); - if (!call) - continue; - - auto location = call->args.data[0]->location; - auto range = lsp::Range{{location.begin.line, location.begin.column}, {location.end.line, location.end.column}}; - auto argText = textDocument.getText(range); - auto length = getLengthEqual(argText, require); - - if (length > bestLength && argText < require && line >= lineNumber) - lineNumber = line + 1; - } - } - - if (!isRelative) - { - // Service will be the first part of the path - // If we haven't imported the service already, then we auto-import it - auto service = requirePath.substr(0, requirePath.find('/')); - if (!contains(importsVisitor.serviceLineMap, service)) - { - auto lineNumber = importsVisitor.findBestLineForService(service, hotCommentsLineNumber); - bool appendNewline = false; - // If there is no firstRequireLine, then the require that we insert will become the first require, - // so we use `.value_or(lineNumber)` to ensure it equals 0 and a newline is added - if (config.completion.imports.separateGroupsWithLine && importsVisitor.firstRequireLine.value_or(lineNumber) - lineNumber == 0) - appendNewline = true; - textEdits.emplace_back(createServiceTextEdit(service, lineNumber, appendNewline)); - } - } - - // Whether we need to add a newline before the require to separate it from the services - bool prependNewline = false; - if (config.completion.imports.separateGroupsWithLine && importsVisitor.lastServiceDefinitionLine && - lineNumber - importsVisitor.lastServiceDefinitionLine.value() == 1) - prependNewline = true; - - textEdits.emplace_back(createRequireTextEdit(node->name, require, lineNumber, prependNewline)); - - result.emplace_back(createSuggestRequire(name, textEdits, isRelative ? SortText::AutoImports : SortText::AutoImportsAbsolute, path)); - } - } + platform->handleSuggestImports(textDocument, *sourceModule, config, hotCommentsLineNumber, completingTypeReferencePrefix, result); } static bool canUseSnippets(const lsp::ClientCapabilities& capabilities) @@ -444,8 +196,11 @@ static bool deprecated(const Luau::AutocompleteEntry& entry, std::optional entryKind(const Luau::AutocompleteEntry& entry) +static std::optional entryKind(const Luau::AutocompleteEntry& entry, LSPPlatform* platform) { + if (auto kind = platform->handleEntryKind(entry)) + return kind; + if (entry.type.has_value()) { auto id = Luau::follow(entry.type.value()); @@ -455,12 +210,6 @@ static std::optional entryKind(const Luau::Autocomplete // Try to infer more type info about the entry to provide better suggestion info if (Luau::get(id)) return lsp::CompletionItemKind::Function; - else if (auto ttv = Luau::get(id)) - { - // Special case the RBXScriptSignal type as a connection - if (ttv->name && ttv->name.value() == "RBXScriptSignal") - return lsp::CompletionItemKind::Event; - } } if (std::find(entry.tags.begin(), entry.tags.end(), "File") != entry.tags.end()) @@ -489,37 +238,16 @@ static std::optional entryKind(const Luau::Autocomplete return std::nullopt; } -static const char* sortText(const Luau::Frontend& frontend, const std::string& name, const Luau::AutocompleteEntry& entry, bool isGetService) +static const char* sortText(const Luau::Frontend& frontend, const std::string& name, const Luau::AutocompleteEntry& entry, + const std::unordered_set& tags, LSPPlatform& platform) { + if (auto text = platform.handleSortText(frontend, name, entry, tags)) + return text; + // If it's a file or directory alias, de-prioritise it compared to normal paths if (std::find(entry.tags.begin(), entry.tags.end(), "Alias") != entry.tags.end()) return SortText::AutoImports; - // If it's a `game:GetSerivce("$1")` call, then prioritise common services - if (isGetService) - if (auto it = std::find(std::begin(COMMON_SERVICES), std::end(COMMON_SERVICES), name); it != std::end(COMMON_SERVICES)) - return SortText::PrioritisedSuggestion; - - // If calling a property on ServiceProvider, then prioritise these properties - if (auto dataModelType = frontend.globalsForAutocomplete.globalScope->lookupType("ServiceProvider"); - dataModelType && Luau::get(dataModelType->type) && entry.containingClass && - Luau::isSubclass(entry.containingClass.value(), Luau::get(dataModelType->type)) && !entry.wrongIndexType) - { - if (auto it = std::find(std::begin(COMMON_SERVICE_PROVIDER_PROPERTIES), std::end(COMMON_SERVICE_PROVIDER_PROPERTIES), name); - it != std::end(COMMON_SERVICE_PROVIDER_PROPERTIES)) - return SortText::PrioritisedSuggestion; - } - - // If calling a property on an Instance, then prioritise these properties - else if (auto instanceType = frontend.globalsForAutocomplete.globalScope->lookupType("Instance"); - instanceType && Luau::get(instanceType->type) && entry.containingClass && - Luau::isSubclass(entry.containingClass.value(), Luau::get(instanceType->type)) && !entry.wrongIndexType) - { - if (auto it = std::find(std::begin(COMMON_INSTANCE_PROPERTIES), std::end(COMMON_INSTANCE_PROPERTIES), name); - it != std::end(COMMON_INSTANCE_PROPERTIES)) - return SortText::PrioritisedSuggestion; - } - // If the entry is `loadstring`, deprioritise it if (auto it = frontend.globalsForAutocomplete.globalScope->bindings.find(Luau::AstName("loadstring")); it != frontend.globalsForAutocomplete.globalScope->bindings.end()) @@ -672,7 +400,7 @@ std::vector WorkspaceFolder::completion(const lsp::Completi if (!textDocument) throw JsonRpcException(lsp::ErrorCode::RequestFailed, "No managed text document for " + params.textDocument.uri.toString()); - bool isGetService = false; + std::unordered_set tags; // We must perform check before autocompletion checkStrict(moduleName, /* forAutocomplete: */ true); @@ -682,176 +410,8 @@ std::vector WorkspaceFolder::completion(const lsp::Completi [&](const std::string& tag, std::optional ctx, std::optional contents) -> std::optional { - if (tag == "ClassNames") - { - if (auto instanceType = frontend.globals.globalScope->lookupType("Instance")) - { - if (auto* ctv = Luau::get(instanceType->type)) - { - Luau::AutocompleteEntryMap result; - for (auto& [_, ty] : frontend.globals.globalScope->exportedTypeBindings) - { - if (auto* c = Luau::get(ty.type)) - { - // Check if the ctv is a subclass of instance - if (Luau::isSubclass(c, ctv)) - - result.insert_or_assign( - c->name, Luau::AutocompleteEntry{Luau::AutocompleteEntryKind::String, frontend.builtinTypes->stringType, - false, false, Luau::TypeCorrectKind::Correct}); - } - } - - return result; - } - } - } - else if (tag == "Properties") - { - if (ctx && ctx.value()) - { - Luau::AutocompleteEntryMap result; - auto ctv = ctx.value(); - while (ctv) - { - for (auto& [propName, prop] : ctv->props) - { - // Don't include functions or events - auto ty = Luau::follow(prop.type()); - if (Luau::get(ty) || Luau::isOverloadedFunction(ty)) - continue; - else if (auto ttv = Luau::get(ty); ttv && ttv->name && ttv->name.value() == "RBXScriptSignal") - continue; - - result.insert_or_assign(propName, Luau::AutocompleteEntry{Luau::AutocompleteEntryKind::String, - frontend.builtinTypes->stringType, false, false, Luau::TypeCorrectKind::Correct}); - } - if (ctv->parent) - ctv = Luau::get(*ctv->parent); - else - break; - } - return result; - } - } - else if (tag == "Enums") - { - auto it = frontend.globals.globalScope->importedTypeBindings.find("Enum"); - if (it == frontend.globals.globalScope->importedTypeBindings.end()) - return std::nullopt; - - Luau::AutocompleteEntryMap result; - for (auto& [enumName, _] : it->second) - result.insert_or_assign(enumName, Luau::AutocompleteEntry{Luau::AutocompleteEntryKind::String, frontend.builtinTypes->stringType, - false, false, Luau::TypeCorrectKind::Correct}); - - return result; - } - else if (tag == "Require") - { - if (!contents.has_value()) - return std::nullopt; - - Luau::AutocompleteEntryMap result; - - // Include any files in the directory - auto contentsString = contents.value(); - - // We should strip any trailing values until a `/` is found in case autocomplete - // is triggered half-way through. - // E.g., for "Contents/Test|", we should only consider up to "Contents/" to find all files - // For "Mod|", we should only consider an empty string "" - auto separator = contentsString.find_last_of("/\\"); - if (separator == std::string::npos) - contentsString = ""; - else - contentsString = contentsString.substr(0, separator + 1); - - // Populate with custom file aliases - for (const auto& [aliasName, _] : config.require.fileAliases) - { - Luau::AutocompleteEntry entry{ - Luau::AutocompleteEntryKind::String, frontend.builtinTypes->stringType, false, false, Luau::TypeCorrectKind::Correct}; - entry.tags.push_back("File"); - entry.tags.push_back("Alias"); - result.insert_or_assign(aliasName, entry); - } - - // Populate with custom directory aliases, if we are at the start of a string require - if (contentsString == "") - { - for (const auto& [aliasName, _] : config.require.directoryAliases) - { - Luau::AutocompleteEntry entry{ - Luau::AutocompleteEntryKind::String, frontend.builtinTypes->stringType, false, false, Luau::TypeCorrectKind::Correct}; - entry.tags.push_back("Directory"); - entry.tags.push_back("Alias"); - result.insert_or_assign(aliasName, entry); - } - } - - // Check if it starts with a directory alias, otherwise resolve with require base path - std::filesystem::path currentDirectory = resolveDirectoryAlias(rootUri.fsPath(), config.require.directoryAliases, contentsString) - .value_or(fileResolver.getRequireBasePath(moduleName).append(contentsString)); - - try - { - for (const auto& dir_entry : std::filesystem::directory_iterator(currentDirectory)) - { - if (dir_entry.is_regular_file() || dir_entry.is_directory()) - { - std::string fileName = dir_entry.path().filename().generic_string(); - Luau::AutocompleteEntry entry{ - Luau::AutocompleteEntryKind::String, frontend.builtinTypes->stringType, false, false, Luau::TypeCorrectKind::Correct}; - entry.tags.push_back(dir_entry.is_directory() ? "Directory" : "File"); - result.insert_or_assign(fileName, entry); - } - } - - // Add in ".." support - if (currentDirectory.has_parent_path()) - { - Luau::AutocompleteEntry dotdotEntry{ - Luau::AutocompleteEntryKind::String, frontend.builtinTypes->stringType, false, false, Luau::TypeCorrectKind::Correct}; - dotdotEntry.tags.push_back("Directory"); - result.insert_or_assign("..", dotdotEntry); - } - } - catch (std::exception&) - { - } - - return result; - } - else if (tag == "CreatableInstances") - { - Luau::AutocompleteEntryMap result; - if (definitionsFileMetadata) - { - for (const auto& className : this->definitionsFileMetadata->CREATABLE_INSTANCES) - result.insert_or_assign(className, Luau::AutocompleteEntry{Luau::AutocompleteEntryKind::String, - frontend.builtinTypes->stringType, false, false, Luau::TypeCorrectKind::Correct}); - } - return result; - } - else if (tag == "Services") - { - Luau::AutocompleteEntryMap result; - - // We are autocompleting a `game:GetService("$1")` call, so we set a flag to - // highlight this so that we can prioritise common services first in the list - isGetService = true; - if (definitionsFileMetadata) - { - for (const auto& className : this->definitionsFileMetadata->SERVICES) - result.insert_or_assign(className, Luau::AutocompleteEntry{Luau::AutocompleteEntryKind::String, - frontend.builtinTypes->stringType, false, false, Luau::TypeCorrectKind::Correct}); - } - - return result; - } - - return std::nullopt; + tags.insert(tag); + return platform->completionCallback(tag, ctx, std::move(contents), moduleName); }); @@ -872,8 +432,8 @@ std::vector WorkspaceFolder::completion(const lsp::Completi item.documentation = {lsp::MarkupKind::Markdown, documentationString.value()}; item.deprecated = deprecated(entry, item.documentation); - item.kind = entryKind(entry); - item.sortText = sortText(frontend, name, entry, isGetService); + item.kind = entryKind(entry, platform.get()); + item.sortText = sortText(frontend, name, entry, tags, *platform); if (entry.kind == Luau::AutocompleteEntryKind::GeneratedFunction) item.insertText = entry.insertText; @@ -990,11 +550,14 @@ std::vector WorkspaceFolder::completion(const lsp::Completi items.emplace_back(item); } + if (auto module = frontend.getSourceModule(moduleName)) + platform->handleCompletion(*textDocument, *module, position, items); + if (config.completion.suggestImports || config.completion.imports.enabled) { if (result.context == Luau::AutocompleteContext::Expression || result.context == Luau::AutocompleteContext::Statement) { - suggestImports(moduleName, position, config, *textDocument, items, /* includeServices: */ true); + suggestImports(moduleName, position, config, *textDocument, items, /* completingTypeReferencePrefix: */ false); } else if (result.context == Luau::AutocompleteContext::Type) { @@ -1002,7 +565,7 @@ std::vector WorkspaceFolder::completion(const lsp::Completi if (auto node = result.ancestry.back()) if (auto typeReference = node->as()) if (!typeReference->prefix) - suggestImports(moduleName, position, config, *textDocument, items, /* includeServices: */ false); + suggestImports(moduleName, position, config, *textDocument, items, /* completingTypeReferencePrefix: */ true); } } diff --git a/src/operations/Diagnostics.cpp b/src/operations/Diagnostics.cpp index 4054f35f..5025bff4 100644 --- a/src/operations/Diagnostics.cpp +++ b/src/operations/Diagnostics.cpp @@ -45,7 +45,7 @@ lsp::DocumentDiagnosticReport WorkspaceFolder::documentDiagnostics(const lsp::Do } else { - auto fileName = fileResolver.resolveToRealPath(error.moduleName); + auto fileName = platform->resolveToRealPath(error.moduleName); if (!fileName || isIgnoredFile(*fileName, config)) continue; auto textDocument = fileResolver.getTextDocumentFromModuleName(error.moduleName); @@ -83,6 +83,12 @@ lsp::DocumentDiagnosticReport WorkspaceFolder::documentDiagnostics(const lsp::Do lsp::WorkspaceDiagnosticReport WorkspaceFolder::workspaceDiagnostics(const lsp::WorkspaceDiagnosticParams& params) { + if (!isConfigured) + { + lsp::DiagnosticServerCancellationData cancellationData{/*retriggerRequest: */ true}; + throw JsonRpcException(lsp::ErrorCode::ServerCancelled, "server not yet received configuration for diagnostics", cancellationData); + } + lsp::WorkspaceDiagnosticReport workspaceReport; // Don't compute any workspace diagnostics for null workspace @@ -171,6 +177,56 @@ lsp::DocumentDiagnosticReport LanguageServer::documentDiagnostic(const lsp::Docu return workspace->documentDiagnostics(params); } +void WorkspaceFolder::pushDiagnostics(const lsp::DocumentUri& uri, const size_t version) +{ + // Convert the diagnostics report into a series of diagnostics published for each relevant file + lsp::DocumentDiagnosticParams params{lsp::TextDocumentIdentifier{uri}}; + auto diagnostics = documentDiagnostics(params); + client->publishDiagnostics(lsp::PublishDiagnosticsParams{uri, version, diagnostics.items}); + + if (!diagnostics.relatedDocuments.empty()) + { + for (const auto& [relatedUri, relatedDiagnostics] : diagnostics.relatedDocuments) + { + if (relatedDiagnostics.kind == lsp::DocumentDiagnosticReportKind::Full) + { + client->publishDiagnostics(lsp::PublishDiagnosticsParams{Uri::parse(relatedUri), std::nullopt, relatedDiagnostics.items}); + } + } + } +} + +/// Recompute all necessary diagnostics when we detect a configuration (or sourcemap) change +void WorkspaceFolder::recomputeDiagnostics(const ClientConfiguration& config) { + // Handle diagnostics if in push-mode + if ((!client->capabilities.textDocument || !client->capabilities.textDocument->diagnostic)) + { + // Recompute workspace diagnostics if requested + if (config.diagnostics.workspace) + { + auto diagnostics = workspaceDiagnostics({}); + for (const auto& report : diagnostics.items) + { + if (report.kind == lsp::DocumentDiagnosticReportKind::Full) + { + client->publishDiagnostics(lsp::PublishDiagnosticsParams{report.uri, report.version, report.items}); + } + } + } + // Recompute diagnostics for all currently opened files + else + { + for (const auto& [_, document] : fileResolver.managedFiles) + pushDiagnostics(document.uri(), document.version()); + } + } + else + { + client->terminateWorkspaceDiagnostics(); + client->refreshWorkspaceDiagnostics(); + } +} + lsp::PartialResponse LanguageServer::workspaceDiagnostic(const lsp::WorkspaceDiagnosticParams& params) { lsp::WorkspaceDiagnosticReport fullReport; diff --git a/src/operations/DocumentLink.cpp b/src/operations/DocumentLink.cpp index b3024d68..b7564d8d 100644 --- a/src/operations/DocumentLink.cpp +++ b/src/operations/DocumentLink.cpp @@ -49,7 +49,7 @@ std::vector WorkspaceFolder::documentLink(const lsp::Document if (auto moduleInfo = frontend.moduleResolver.resolveModuleInfo(moduleName, *require.require)) { // Resolve the module info to a URI - auto realName = fileResolver.resolveToRealPath(moduleInfo->name); + auto realName = platform->resolveToRealPath(moduleInfo->name); if (realName) { lsp::DocumentLink link; @@ -68,4 +68,4 @@ std::vector LanguageServer::documentLink(const lsp::DocumentL { auto workspace = findWorkspace(params.textDocument.uri); return workspace->documentLink(params); -} \ No newline at end of file +} diff --git a/src/operations/DocumentSymbol.cpp b/src/operations/DocumentSymbol.cpp index 26aeb709..ca20e816 100644 --- a/src/operations/DocumentSymbol.cpp +++ b/src/operations/DocumentSymbol.cpp @@ -164,4 +164,4 @@ std::optional> LanguageServer::documentSymbol(c { auto workspace = findWorkspace(params.textDocument.uri); return workspace->documentSymbol(params); -} \ No newline at end of file +} diff --git a/src/operations/GotoDefinition.cpp b/src/operations/GotoDefinition.cpp index e25badc9..6d023516 100644 --- a/src/operations/GotoDefinition.cpp +++ b/src/operations/GotoDefinition.cpp @@ -87,7 +87,7 @@ lsp::DefinitionResult WorkspaceFolder::gotoDefinition(const lsp::DefinitionParam { if (definitionModuleName) { - if (auto file = fileResolver.resolveToRealPath(*definitionModuleName)) + if (auto file = platform->resolveToRealPath(*definitionModuleName)) { auto document = fileResolver.getTextDocumentFromModuleName(*definitionModuleName); auto uri = document ? document->uri() : Uri::file(*file); @@ -114,7 +114,7 @@ lsp::DefinitionResult WorkspaceFolder::gotoDefinition(const lsp::DefinitionParam { if (auto importedName = lookupImportedModule(*scope, reference->prefix.value().value)) { - auto fileName = fileResolver.resolveToRealPath(*importedName); + auto fileName = platform->resolveToRealPath(*importedName); if (!fileName) return result; uri = Uri::file(*fileName); @@ -194,7 +194,7 @@ std::optional WorkspaceFolder::gotoTypeDefinition(const lsp::Type { if (auto importedName = lookupImportedModule(*scope, reference->prefix.value().value)) { - auto fileName = fileResolver.resolveToRealPath(*importedName); + auto fileName = platform->resolveToRealPath(*importedName); if (!fileName) return std::nullopt; uri = Uri::file(*fileName); diff --git a/src/operations/Hover.cpp b/src/operations/Hover.cpp index 69c71786..0589c015 100644 --- a/src/operations/Hover.cpp +++ b/src/operations/Hover.cpp @@ -124,6 +124,9 @@ std::optional WorkspaceFolder::hover(const lsp::HoverParams& params) if (!sourceModule) return std::nullopt; + if (auto hover = platform->handleHover(*textDocument, *sourceModule, position)) + return hover; + auto exprOrLocal = Luau::findExprOrLocalAtPosition(*sourceModule, position); auto node = findNodeOrTypeAtPosition(*sourceModule, position); auto scope = Luau::findScopeAtPosition(*module, position); diff --git a/src/operations/InlayHints.cpp b/src/operations/InlayHints.cpp index 00bf0100..e597595a 100644 --- a/src/operations/InlayHints.cpp +++ b/src/operations/InlayHints.cpp @@ -295,8 +295,6 @@ lsp::InlayHintResult WorkspaceFolder::inlayHint(const lsp::InlayHintParams& para if (!textDocument) throw JsonRpcException(lsp::ErrorCode::RequestFailed, "No managed text document for " + params.textDocument.uri.toString()); - std::vector result{}; - // TODO: expressiveTypes - remove "forAutocomplete" once the types have been fixed checkStrict(moduleName, /* forAutocomplete: */ config.hover.strictDatamodelTypes); @@ -308,6 +306,7 @@ lsp::InlayHintResult WorkspaceFolder::inlayHint(const lsp::InlayHintParams& para InlayHintVisitor visitor{module, config, textDocument}; visitor.visit(sourceModule->root); + return visitor.hints; } diff --git a/src/operations/References.cpp b/src/operations/References.cpp index 5d0e241b..81e1dfb8 100644 --- a/src/operations/References.cpp +++ b/src/operations/References.cpp @@ -202,7 +202,7 @@ static std::vector processReferences(WorkspaceFileResolver& fileR } else { - if (auto filePath = fileResolver.resolveToRealPath(reference.moduleName)) + if (auto filePath = fileResolver.platform->resolveToRealPath(reference.moduleName)) { if (auto source = fileResolver.readSource(reference.moduleName)) { diff --git a/src/operations/SignatureHelp.cpp b/src/operations/SignatureHelp.cpp index 00d7ba1c..d74832fa 100644 --- a/src/operations/SignatureHelp.cpp +++ b/src/operations/SignatureHelp.cpp @@ -2,6 +2,8 @@ #include "LSP/LanguageServer.hpp" #include "Luau/AstQuery.h" +#include "Luau/Normalize.h" +#include "Luau/Unifier.h" #include "LSP/LuauExt.hpp" #include "LSP/DocumentationParser.hpp" @@ -212,7 +214,10 @@ std::optional WorkspaceFolder::signatureHelp(const lsp::Sign if (auto candidateFunctionType = Luau::get(part)) addSignature(part, candidateFunctionType, /* isOverloaded = */ true); - return lsp::SignatureHelp{signatures, activeSignature.value_or(0), activeParameter}; + lsp::SignatureHelp help = lsp::SignatureHelp{signatures, activeSignature.value_or(0), activeParameter}; + platform->handleSignatureHelp(*textDocument, *sourceModule, position, help); + + return help; } std::optional LanguageServer::signatureHelp(const lsp::SignatureHelpParams& params) diff --git a/src/operations/WorkspaceSymbol.cpp b/src/operations/WorkspaceSymbol.cpp index 3d13be3b..0ecfbddb 100644 --- a/src/operations/WorkspaceSymbol.cpp +++ b/src/operations/WorkspaceSymbol.cpp @@ -120,7 +120,7 @@ std::optional> WorkspaceFolder::workspaceSymbo } else { - if (auto filePath = fileResolver.resolveToRealPath(moduleName)) + if (auto filePath = platform->resolveToRealPath(moduleName)) { if (auto source = fileResolver.readSource(moduleName)) { @@ -134,4 +134,4 @@ std::optional> WorkspaceFolder::workspaceSymbo } return result; -} \ No newline at end of file +} diff --git a/src/platform/LSPPlatform.cpp b/src/platform/LSPPlatform.cpp new file mode 100644 index 00000000..fb4e1c4d --- /dev/null +++ b/src/platform/LSPPlatform.cpp @@ -0,0 +1,237 @@ +#include "Platform/LSPPlatform.hpp" + +#include "LSP/ClientConfiguration.hpp" +#include "LSP/Workspace.hpp" +#include "Platform/RobloxPlatform.hpp" + +#include + +LSPPlatform::LSPPlatform(WorkspaceFileResolver* fileResolver, WorkspaceFolder* workspaceFolder) + : fileResolver(fileResolver) + , workspaceFolder(workspaceFolder) +{ +} + +std::unique_ptr LSPPlatform::getPlatform( + const ClientConfiguration& config, WorkspaceFileResolver* fileResolver, WorkspaceFolder* workspaceFolder) +{ + if (config.types.roblox && config.platform.type == LSPPlatformConfig::Roblox) + return std::make_unique(fileResolver, workspaceFolder); + + return std::make_unique(fileResolver, workspaceFolder); +} + +std::optional LSPPlatform::readSourceCode(const Luau::ModuleName& name, const std::filesystem::path& path) const +{ + if (auto textDocument = fileResolver->getTextDocumentFromModuleName(name)) + return textDocument->getText(); + + if (path.extension() == ".lua" || path.extension() == ".luau") + return readFile(path); + + return std::nullopt; +} + +// Resolve the string using a directory alias if present +std::optional resolveDirectoryAlias( + const std::filesystem::path& rootPath, const std::unordered_map& directoryAliases, const std::string& str) +{ + for (const auto& [alias, path] : directoryAliases) + { + if (Luau::startsWith(str, alias)) + { + std::filesystem::path directoryPath = path; + std::string remainder = str.substr(alias.length()); + + // If remainder begins with a '/' character, we need to trim it off before it gets mistaken for an + // absolute path + remainder.erase(0, remainder.find_first_not_of("/\\")); + + auto filePath = resolvePath(remainder.empty() ? directoryPath : directoryPath / remainder); + if (!filePath.is_absolute()) + filePath = rootPath / filePath; + + return filePath; + } + } + + return std::nullopt; +} + +/// Returns the base path to use in a string require. +/// This depends on user configuration, whether requires are taken relative to file or workspace root, defaulting to the latter +std::filesystem::path LSPPlatform::getRequireBasePath(std::optional fileModuleName) const +{ + if (!fileResolver->client) + return fileResolver->rootUri.fsPath(); + + auto config = fileResolver->client->getConfiguration(fileResolver->rootUri); + switch (config.require.mode) + { + case RequireModeConfig::RelativeToWorkspaceRoot: + return fileResolver->rootUri.fsPath(); + case RequireModeConfig::RelativeToFile: + { + if (fileModuleName.has_value()) + { + auto filePath = resolveToRealPath(*fileModuleName); + if (filePath) + return filePath->parent_path(); + else + return fileResolver->rootUri.fsPath(); + } + else + { + return fileResolver->rootUri.fsPath(); + } + } + } + + return fileResolver->rootUri.fsPath(); +} + +std::optional LSPPlatform::resolveStringRequire(const Luau::ModuleInfo* context, const std::string& requiredString) +{ + std::filesystem::path basePath = getRequireBasePath(context ? std::optional(context->name) : std::nullopt); + auto filePath = basePath / requiredString; + + // Check for custom require overrides + if (fileResolver->client) + { + auto config = fileResolver->client->getConfiguration(fileResolver->rootUri); + + // Check file aliases + if (auto it = config.require.fileAliases.find(requiredString); it != config.require.fileAliases.end()) + { + filePath = resolvePath(it->second); + } + // Check directory aliases + else if (auto aliasedPath = resolveDirectoryAlias(fileResolver->rootUri.fsPath(), config.require.directoryAliases, requiredString)) + { + filePath = aliasedPath.value(); + } + } + + std::error_code ec; + filePath = std::filesystem::weakly_canonical(filePath, ec); + + // Handle "init.luau" files in a directory + if (std::filesystem::is_directory(filePath, ec)) + { + filePath /= "init"; + } + + // Add file endings + if (filePath.extension() != ".luau" && filePath.extension() != ".lua") + { + auto fullFilePath = filePath.string() + ".luau"; + if (!std::filesystem::exists(fullFilePath)) + // fall back to .lua if a module with .luau doesn't exist + filePath = filePath.string() + ".lua"; + else + filePath = fullFilePath; + } + + // URI-ify the file path so that its normalised (in particular, the drive letter) + auto uri = Uri::parse(Uri::file(filePath).toString()); + + return Luau::ModuleInfo{fileResolver->getModuleName(uri)}; +} + +std::optional LSPPlatform::resolveModule(const Luau::ModuleInfo* context, Luau::AstExpr* node) +{ + // Handle require("path") for compatibility + if (auto* expr = node->as()) + { + std::string requiredString(expr->value.data, expr->value.size); + return resolveStringRequire(context, requiredString); + } + + return std::nullopt; +} + +std::optional LSPPlatform::completionCallback( + const std::string& tag, std::optional ctx, std::optional contents, const Luau::ModuleName& moduleName) +{ + if (tag == "Require") + { + if (!contents.has_value()) + return std::nullopt; + + auto config = workspaceFolder->client->getConfiguration(workspaceFolder->rootUri); + + Luau::AutocompleteEntryMap result; + + // Include any files in the directory + auto contentsString = contents.value(); + + // We should strip any trailing values until a `/` is found in case autocomplete + // is triggered half-way through. + // E.g., for "Contents/Test|", we should only consider up to "Contents/" to find all files + // For "Mod|", we should only consider an empty string "" + auto separator = contentsString.find_last_of("/\\"); + if (separator == std::string::npos) + contentsString = ""; + else + contentsString = contentsString.substr(0, separator + 1); + + // Populate with custom file aliases + for (const auto& [aliasName, _] : config.require.fileAliases) + { + Luau::AutocompleteEntry entry{Luau::AutocompleteEntryKind::String, workspaceFolder->frontend.builtinTypes->stringType, false, false, + Luau::TypeCorrectKind::Correct}; + entry.tags.push_back("File"); + entry.tags.push_back("Alias"); + result.insert_or_assign(aliasName, entry); + } + + // Populate with custom directory aliases, if we are at the start of a string require + if (contentsString == "") + { + for (const auto& [aliasName, _] : config.require.directoryAliases) + { + Luau::AutocompleteEntry entry{Luau::AutocompleteEntryKind::String, workspaceFolder->frontend.builtinTypes->stringType, false, false, + Luau::TypeCorrectKind::Correct}; + entry.tags.push_back("Directory"); + entry.tags.push_back("Alias"); + result.insert_or_assign(aliasName, entry); + } + } + + // Check if it starts with a directory alias, otherwise resolve with require base path + std::filesystem::path currentDirectory = + resolveDirectoryAlias(workspaceFolder->rootUri.fsPath(), config.require.directoryAliases, contentsString) + .value_or(getRequireBasePath(moduleName).append(contentsString)); + + try + { + for (const auto& dir_entry : std::filesystem::directory_iterator(currentDirectory)) + { + if (dir_entry.is_regular_file() || dir_entry.is_directory()) + { + std::string fileName = dir_entry.path().filename().generic_string(); + Luau::AutocompleteEntry entry{Luau::AutocompleteEntryKind::String, workspaceFolder->frontend.builtinTypes->stringType, false, + false, Luau::TypeCorrectKind::Correct}; + entry.tags.push_back(dir_entry.is_directory() ? "Directory" : "File"); + result.insert_or_assign(fileName, entry); + } + } + + // Add in ".." support + if (currentDirectory.has_parent_path()) + { + Luau::AutocompleteEntry dotdotEntry{Luau::AutocompleteEntryKind::String, workspaceFolder->frontend.builtinTypes->stringType, false, + false, Luau::TypeCorrectKind::Correct}; + dotdotEntry.tags.push_back("Directory"); + result.insert_or_assign("..", dotdotEntry); + } + } + catch (std::exception&) + { + } + + return result; + } + + return std::nullopt; +} diff --git a/src/platform/roblox/RobloxCodeAction.cpp b/src/platform/roblox/RobloxCodeAction.cpp new file mode 100644 index 00000000..0e043556 --- /dev/null +++ b/src/platform/roblox/RobloxCodeAction.cpp @@ -0,0 +1,73 @@ +#include "Platform/RobloxPlatform.hpp" + +#include "LSP/Workspace.hpp" +#include "Luau/Transpiler.h" + +lsp::WorkspaceEdit RobloxPlatform::computeOrganiseServicesEdit(const lsp::DocumentUri& uri) +{ + auto moduleName = fileResolver->getModuleName(uri); + auto textDocument = fileResolver->getTextDocument(uri); + + if (!textDocument) + throw JsonRpcException(lsp::ErrorCode::RequestFailed, "No managed text document for " + uri.toString()); + + workspaceFolder->frontend.parse(moduleName); + + auto sourceModule = workspaceFolder->frontend.getSourceModule(moduleName); + if (!sourceModule) + return {}; + + // Find all `local X = game:GetService("Service")` + RobloxFindImportsVisitor visitor; + visitor.visit(sourceModule->root); + + if (visitor.serviceLineMap.empty()) + return {}; + + // Test to see that if all the services are already sorted -> if they are, then just leave alone + // to prevent clogging the undo history stack + Luau::Location previousServiceLocation{{0, 0}, {0, 0}}; + bool isSorted = true; + for (const auto& [_, stat] : visitor.serviceLineMap) + { + if (stat->location.begin < previousServiceLocation.begin) + { + isSorted = false; + break; + } + previousServiceLocation = stat->location; + } + if (isSorted) + return {}; + + std::vector edits; + // We firstly delete all the previous services, as they will be added later + edits.reserve(visitor.serviceLineMap.size()); + for (const auto& [_, stat] : visitor.serviceLineMap) + edits.emplace_back(lsp::TextEdit{{{stat->location.begin.line, 0}, {stat->location.begin.line + 1, 0}}, ""}); + + // We find the first line to add these services to, and then add them in sorted order + lsp::Range insertLocation{{visitor.firstServiceDefinitionLine.value(), 0}, {visitor.firstServiceDefinitionLine.value(), 0}}; + for (const auto& [serviceName, stat] : visitor.serviceLineMap) + { + // We need to rewrite the statement as we expected it + auto importText = Luau::toString(stat) + "\n"; + edits.emplace_back(lsp::TextEdit{insertLocation, importText}); + } + + lsp::WorkspaceEdit workspaceEdit; + workspaceEdit.changes.emplace(uri.toString(), edits); + return workspaceEdit; +} + +void RobloxPlatform::handleCodeAction(const lsp::CodeActionParams& params, std::vector& items) +{ + if (params.context.wants(lsp::CodeActionKind::Source) || params.context.wants(lsp::CodeActionKind::SourceOrganizeImports)) + { + lsp::CodeAction sortServicesAction; + sortServicesAction.title = "Sort services"; + sortServicesAction.kind = lsp::CodeActionKind::SourceOrganizeImports; + sortServicesAction.edit = computeOrganiseServicesEdit(params.textDocument.uri); + items.emplace_back(sortServicesAction); + } +} diff --git a/src/platform/roblox/RobloxColorProvider.cpp b/src/platform/roblox/RobloxColorProvider.cpp new file mode 100644 index 00000000..97183981 --- /dev/null +++ b/src/platform/roblox/RobloxColorProvider.cpp @@ -0,0 +1,154 @@ +#include "Platform/RobloxPlatform.hpp" + +#include "LSP/ColorProvider.hpp" + +struct RobloxColorVisitor : public Luau::AstVisitor +{ + const TextDocument* textDocument; + std::vector colors{}; + + explicit RobloxColorVisitor(const TextDocument* textDocument) + : textDocument(textDocument) + { + } + + bool visit(Luau::AstExprCall* call) override + { + auto index = call->func->as(); + if (!index) + return true; + + auto global = index->expr->as(); + if (!global || global->name != "Color3") + return true; + + std::array color = {0.0, 0.0, 0.0}; + + if (index->index == "new") + { + size_t argIndex = 0; + for (auto arg : call->args) + { + if (argIndex >= 3) + return true; // Don't create as the colour is not in the right format + if (auto number = arg->as()) + color.at(argIndex) = number->value; + else + return true; // Don't create as we can't parse the full colour + argIndex++; + } + } + else if (index->index == "fromRGB") + { + size_t argIndex = 0; + for (auto arg : call->args) + { + if (argIndex >= 3) + return true; // Don't create as the colour is not in the right format + if (auto number = arg->as()) + color.at(argIndex) = number->value / 255.0; + else + return true; // Don't create as we can't parse the full colour + argIndex++; + } + } + else if (index->index == "fromHSV") + { + size_t argIndex = 0; + for (auto arg : call->args) + { + if (argIndex >= 3) + return true; // Don't create as the colour is not in the right format + if (auto number = arg->as()) + color.at(argIndex) = number->value; + else + return true; // Don't create as we can't parse the full colour + argIndex++; + } + RGB data = hsvToRgb({color[0], color[1], color[2]}); + color[0] = data.r / 255.0; + color[1] = data.g / 255.0; + color[2] = data.b / 255.0; + } + else if (index->index == "fromHex") + { + if (call->args.size != 1) + return true; // Don't create as the colour is not in the right format + + if (auto string = call->args.data[0]->as()) + { + try + { + RGB data = hexToRgb(std::string(string->value.data, string->value.size)); + color[0] = data.r / 255.0; + color[1] = data.g / 255.0; + color[2] = data.b / 255.0; + } + catch (const std::exception&) + { + return true; // Invalid hex string + } + } + else + return true; // Don't create as we can't parse the full colour + } + else + { + return true; + } + + colors.emplace_back( + lsp::ColorInformation{lsp::Range{textDocument->convertPosition(call->location.begin), textDocument->convertPosition(call->location.end)}, + {std::clamp(color[0], 0.0, 1.0), std::clamp(color[1], 0.0, 1.0), std::clamp(color[2], 0.0, 1.0), 1.0}}); + + return true; + } + + bool visit(Luau::AstStatBlock* block) override + { + for (Luau::AstStat* stat : block->body) + { + stat->visit(this); + } + + return false; + } +}; + +lsp::DocumentColorResult RobloxPlatform::documentColor(const TextDocument& textDocument, const Luau::SourceModule& module) +{ + RobloxColorVisitor visitor{&textDocument}; + module.root->visit(&visitor); + return visitor.colors; +} + +lsp::ColorPresentationResult RobloxPlatform::colorPresentation(const lsp::ColorPresentationParams& params) +{ + // Create color presentations + lsp::ColorPresentationResult presentations; + + // Add Color3.new + presentations.emplace_back(lsp::ColorPresentation{"Color3.new(" + std::to_string(params.color.red) + ", " + std::to_string(params.color.green) + + ", " + std::to_string(params.color.blue) + ")"}); + + // Convert to RGB values + RGB rgb{ + (int)std::floor(params.color.red * 255.0), + (int)std::floor(params.color.green * 255.0), + (int)std::floor(params.color.blue * 255.0), + }; + + // Add Color3.fromRGB + presentations.emplace_back( + lsp::ColorPresentation{"Color3.fromRGB(" + std::to_string(rgb.r) + ", " + std::to_string(rgb.g) + ", " + std::to_string(rgb.b) + ")"}); + + // Add Color3.fromHSV + HSV hsv = rgbToHsv(rgb); + presentations.emplace_back( + lsp::ColorPresentation{"Color3.fromHSV(" + std::to_string(hsv.h) + ", " + std::to_string(hsv.s) + ", " + std::to_string(hsv.v) + ")"}); + + // Add Color3.fromHex + presentations.emplace_back(lsp::ColorPresentation{"Color3.fromHex(\"" + rgbToHex(rgb) + "\")"}); + + return presentations; +} diff --git a/src/platform/roblox/RobloxCompletion.cpp b/src/platform/roblox/RobloxCompletion.cpp new file mode 100644 index 00000000..9f5fa98b --- /dev/null +++ b/src/platform/roblox/RobloxCompletion.cpp @@ -0,0 +1,399 @@ +#include "Platform/RobloxPlatform.hpp" + +#include "LSP/Completion.hpp" +#include "LSP/Workspace.hpp" + +static constexpr const char* COMMON_SERVICES[] = { + "Players", + "ReplicatedStorage", + "ServerStorage", + "MessagingService", + "TeleportService", + "HttpService", + "CollectionService", + "DataStoreService", + "ContextActionService", + "UserInputService", + "Teams", + "Chat", + "TextService", + "TextChatService", + "GamepadService", + "VoiceChatService", +}; + +static constexpr const char* COMMON_INSTANCE_PROPERTIES[] = { + "Parent", + "Name", + // Methods + "FindFirstChild", + "IsA", + "Destroy", + "GetAttribute", + "GetChildren", + "GetDescendants", + "WaitForChild", + "Clone", + "SetAttribute", +}; + +static constexpr const char* COMMON_SERVICE_PROVIDER_PROPERTIES[] = { + "GetService", +}; + +static lsp::TextEdit createServiceTextEdit(const std::string& name, size_t lineNumber, bool appendNewline = false) +{ + auto range = lsp::Range{{lineNumber, 0}, {lineNumber, 0}}; + auto importText = "local " + name + " = game:GetService(\"" + name + "\")\n"; + if (appendNewline) + importText += "\n"; + return {range, importText}; +} + +static lsp::CompletionItem createSuggestService(const std::string& service, size_t lineNumber, bool appendNewline = false) +{ + auto textEdit = createServiceTextEdit(service, lineNumber, appendNewline); + + lsp::CompletionItem item; + item.label = service; + item.kind = lsp::CompletionItemKind::Class; + item.detail = "Auto-import"; + item.documentation = {lsp::MarkupKind::Markdown, codeBlock("luau", textEdit.newText)}; + item.insertText = service; + item.sortText = SortText::AutoImports; + + item.additionalTextEdits.emplace_back(textEdit); + + return item; +} + +static lsp::TextEdit createRequireTextEdit(const std::string& name, const std::string& path, size_t lineNumber, bool prependNewline = false) +{ + auto range = lsp::Range{{lineNumber, 0}, {lineNumber, 0}}; + auto importText = "local " + name + " = require(" + path + ")\n"; + if (prependNewline) + importText = "\n" + importText; + return {range, importText}; +} + +static lsp::CompletionItem createSuggestRequire( + const std::string& name, const std::vector& textEdits, const char* sortText, const std::string& path) +{ + std::string documentation; + for (const auto& edit : textEdits) + documentation += edit.newText; + + lsp::CompletionItem item; + item.label = name; + item.kind = lsp::CompletionItemKind::Module; + item.detail = "Auto-import"; + item.documentation = {lsp::MarkupKind::Markdown, codeBlock("luau", documentation) + "\n\n" + path}; + item.insertText = name; + item.sortText = sortText; + + item.additionalTextEdits = textEdits; + + return item; +} + +static size_t getLengthEqual(const std::string& a, const std::string& b) +{ + size_t i = 0; + for (; i < a.size() && i < b.size(); ++i) + { + if (a[i] != b[i]) + break; + } + return i; +} + +static std::string optimiseAbsoluteRequire(const std::string& path) +{ + if (!Luau::startsWith(path, "game/")) + return path; + + auto parts = Luau::split(path, '/'); + if (parts.size() > 2) + { + auto service = std::string(parts[1]); + return service + "/" + Luau::join(std::vector(parts.begin() + 2, parts.end()), "/"); + } + + return path; +} + +std::optional RobloxPlatform::completionCallback( + const std::string& tag, std::optional ctx, std::optional contents, const Luau::ModuleName& moduleName) +{ + if (auto parentResult = LSPPlatform::completionCallback(tag, ctx, contents, moduleName)) + return parentResult; + + std::optional metadata = workspaceFolder->definitionsFileMetadata; + + if (tag == "ClassNames") + { + if (auto instanceType = workspaceFolder->frontend.globals.globalScope->lookupType("Instance")) + { + if (auto* ctv = Luau::get(instanceType->type)) + { + Luau::AutocompleteEntryMap result; + for (auto& [_, ty] : workspaceFolder->frontend.globals.globalScope->exportedTypeBindings) + { + if (auto* c = Luau::get(ty.type)) + { + // Check if the ctv is a subclass of instance + if (Luau::isSubclass(c, ctv)) + + result.insert_or_assign( + c->name, Luau::AutocompleteEntry{Luau::AutocompleteEntryKind::String, + workspaceFolder->frontend.builtinTypes->stringType, false, false, Luau::TypeCorrectKind::Correct}); + } + } + + return result; + } + } + } + else if (tag == "Properties") + { + if (ctx && ctx.value()) + { + Luau::AutocompleteEntryMap result; + auto ctv = ctx.value(); + while (ctv) + { + for (auto& [propName, prop] : ctv->props) + { + // Don't include functions or events + auto ty = Luau::follow(prop.type()); + if (Luau::get(ty) || Luau::isOverloadedFunction(ty)) + continue; + else if (auto ttv = Luau::get(ty); ttv && ttv->name && ttv->name.value() == "RBXScriptSignal") + continue; + + result.insert_or_assign( + propName, Luau::AutocompleteEntry{Luau::AutocompleteEntryKind::String, workspaceFolder->frontend.builtinTypes->stringType, + false, false, Luau::TypeCorrectKind::Correct}); + } + if (ctv->parent) + ctv = Luau::get(*ctv->parent); + else + break; + } + return result; + } + } + else if (tag == "Enums") + { + auto it = workspaceFolder->frontend.globals.globalScope->importedTypeBindings.find("Enum"); + if (it == workspaceFolder->frontend.globals.globalScope->importedTypeBindings.end()) + return std::nullopt; + + Luau::AutocompleteEntryMap result; + for (auto& [enumName, _] : it->second) + result.insert_or_assign(enumName, Luau::AutocompleteEntry{Luau::AutocompleteEntryKind::String, + workspaceFolder->frontend.builtinTypes->stringType, false, false, Luau::TypeCorrectKind::Correct}); + + return result; + } + else if (tag == "CreatableInstances") + { + Luau::AutocompleteEntryMap result; + if (metadata) + { + for (const auto& className : metadata->CREATABLE_INSTANCES) + result.insert_or_assign( + className, Luau::AutocompleteEntry{Luau::AutocompleteEntryKind::String, workspaceFolder->frontend.builtinTypes->stringType, false, + false, Luau::TypeCorrectKind::Correct}); + } + return result; + } + else if (tag == "Services") + { + Luau::AutocompleteEntryMap result; + + // We are autocompleting a `game:GetService("$1")` call, so we set a flag to + // highlight this so that we can prioritise common services first in the list + if (metadata) + { + for (const auto& className : metadata->SERVICES) + result.insert_or_assign( + className, Luau::AutocompleteEntry{Luau::AutocompleteEntryKind::String, workspaceFolder->frontend.builtinTypes->stringType, false, + false, Luau::TypeCorrectKind::Correct}); + } + + return result; + } + + return std::nullopt; +} + +const char* RobloxPlatform::handleSortText( + const Luau::Frontend& frontend, const std::string& name, const Luau::AutocompleteEntry& entry, const std::unordered_set& tags) +{ + // If it's a `game:GetSerivce("$1")` call, then prioritise common services + if (tags.count("Services")) + if (auto it = std::find(std::begin(COMMON_SERVICES), std::end(COMMON_SERVICES), name); it != std::end(COMMON_SERVICES)) + return SortText::PrioritisedSuggestion; + + // If calling a property on ServiceProvider, then prioritise these properties + if (auto dataModelType = frontend.globalsForAutocomplete.globalScope->lookupType("ServiceProvider"); + dataModelType && Luau::get(dataModelType->type) && entry.containingClass && + Luau::isSubclass(entry.containingClass.value(), Luau::get(dataModelType->type)) && !entry.wrongIndexType) + { + if (auto it = std::find(std::begin(COMMON_SERVICE_PROVIDER_PROPERTIES), std::end(COMMON_SERVICE_PROVIDER_PROPERTIES), name); + it != std::end(COMMON_SERVICE_PROVIDER_PROPERTIES)) + return SortText::PrioritisedSuggestion; + } + + // If calling a property on an Instance, then prioritise these properties + else if (auto instanceType = frontend.globalsForAutocomplete.globalScope->lookupType("Instance"); + instanceType && Luau::get(instanceType->type) && entry.containingClass && + Luau::isSubclass(entry.containingClass.value(), Luau::get(instanceType->type)) && !entry.wrongIndexType) + { + if (auto it = std::find(std::begin(COMMON_INSTANCE_PROPERTIES), std::end(COMMON_INSTANCE_PROPERTIES), name); + it != std::end(COMMON_INSTANCE_PROPERTIES)) + return SortText::PrioritisedSuggestion; + } + + return nullptr; +} + +std::optional RobloxPlatform::handleEntryKind(const Luau::AutocompleteEntry& entry) +{ + if (entry.type.has_value()) + { + auto id = Luau::follow(entry.type.value()); + + if (auto ttv = Luau::get(id)) + { + // Special case the RBXScriptSignal type as a connection + if (ttv->name && ttv->name.value() == "RBXScriptSignal") + return lsp::CompletionItemKind::Event; + } + } + + return std::nullopt; +} + +void RobloxPlatform::handleSuggestImports(const TextDocument& textDocument, const Luau::SourceModule& module, const ClientConfiguration& config, + size_t hotCommentsLineNumber, bool completingTypeReferencePrefix, std::vector& items) +{ + // Find all import calls + RobloxFindImportsVisitor importsVisitor; + importsVisitor.visit(module.root); + + if (config.completion.imports.suggestServices && !completingTypeReferencePrefix) + { + std::optional metadata = workspaceFolder->definitionsFileMetadata; + + auto services = metadata.has_value() ? metadata->SERVICES : std::vector{}; + for (auto& service : services) + { + // ASSUMPTION: if the service was defined, it was defined with the exact same name + if (contains(importsVisitor.serviceLineMap, service)) + continue; + + size_t lineNumber = importsVisitor.findBestLineForService(service, hotCommentsLineNumber); + + bool appendNewline = false; + if (config.completion.imports.separateGroupsWithLine && importsVisitor.firstRequireLine && + importsVisitor.firstRequireLine.value() - lineNumber == 0) + appendNewline = true; + + items.emplace_back(createSuggestService(service, lineNumber, appendNewline)); + } + } + + if (config.completion.imports.suggestRequires) + { + size_t minimumLineNumber = hotCommentsLineNumber; + size_t visitorMinimumLine = importsVisitor.getMinimumRequireLine(); + + if (visitorMinimumLine > minimumLineNumber) + minimumLineNumber = visitorMinimumLine; + + if (importsVisitor.firstRequireLine) + minimumLineNumber = *importsVisitor.firstRequireLine >= minimumLineNumber ? (*importsVisitor.firstRequireLine) : minimumLineNumber; + + for (auto& [path, node] : virtualPathsToSourceNodes) + { + auto name = node->name; + replaceAll(name, " ", "_"); + + if (path == module.name || node->className != "ModuleScript" || importsVisitor.containsRequire(name)) + continue; + if (auto scriptFilePath = getRealPathFromSourceNode(node); scriptFilePath && workspaceFolder->isIgnoredFile(*scriptFilePath, config)) + continue; + + std::string requirePath; + std::vector textEdits; + + // Compute the style of require + bool isRelative = false; + auto parent1 = getParentPath(module.name), parent2 = getParentPath(path); + if (config.completion.imports.requireStyle == ImportRequireStyle::AlwaysRelative || + Luau::startsWith(path, "ProjectRoot/") || // All model projects should always require relatively + (config.completion.imports.requireStyle != ImportRequireStyle::AlwaysAbsolute && + (Luau::startsWith(module.name, path) || Luau::startsWith(path, module.name) || parent1 == parent2))) + { + requirePath = "./" + std::filesystem::relative(path, module.name).string(); + isRelative = true; + } + else + requirePath = optimiseAbsoluteRequire(path); + + auto require = convertToScriptPath(requirePath); + + size_t lineNumber = minimumLineNumber; + size_t bestLength = 0; + for (auto& group : importsVisitor.requiresMap) + { + for (auto& [_, stat] : group) + { + auto line = stat->location.end.line; + + // HACK: We read the text of the require argument to sort the lines + // Note: requires may be in the form `require(path) :: any`, so we need to handle that too + Luau::AstExprCall* call = stat->values.data[0]->as(); + if (auto assertion = stat->values.data[0]->as()) + call = assertion->expr->as(); + if (!call) + continue; + + auto location = call->args.data[0]->location; + auto range = lsp::Range{{location.begin.line, location.begin.column}, {location.end.line, location.end.column}}; + auto argText = textDocument.getText(range); + auto length = getLengthEqual(argText, require); + + if (length > bestLength && argText < require && line >= lineNumber) + lineNumber = line + 1; + } + } + + if (isRelative) + { + // Service will be the first part of the path + // If we haven't imported the service already, then we auto-import it + auto service = requirePath.substr(0, requirePath.find('/')); + if (!contains(importsVisitor.serviceLineMap, service)) + { + auto lineNumber = importsVisitor.findBestLineForService(service, hotCommentsLineNumber); + bool appendNewline = false; + // If there is no firstRequireLine, then the require that we insert will become the first require, + // so we use `.value_or(lineNumber)` to ensure it equals 0 and a newline is added + if (config.completion.imports.separateGroupsWithLine && importsVisitor.firstRequireLine.value_or(lineNumber) - lineNumber == 0) + appendNewline = true; + textEdits.emplace_back(createServiceTextEdit(service, lineNumber, appendNewline)); + } + } + + // Whether we need to add a newline before the require to separate it from the services + bool prependNewline = config.completion.imports.separateGroupsWithLine && importsVisitor.shouldPrependNewline(lineNumber); + + textEdits.emplace_back(createRequireTextEdit(node->name, require, lineNumber, prependNewline)); + + items.emplace_back(createSuggestRequire(name, textEdits, isRelative ? SortText::AutoImports : SortText::AutoImportsAbsolute, path)); + } + } +} diff --git a/src/platform/roblox/RobloxFileResolver.cpp b/src/platform/roblox/RobloxFileResolver.cpp new file mode 100644 index 00000000..ef820145 --- /dev/null +++ b/src/platform/roblox/RobloxFileResolver.cpp @@ -0,0 +1,196 @@ +#include "Platform/RobloxPlatform.hpp" + +std::optional RobloxPlatform::resolveToVirtualPath(const std::string& name) const +{ + if (isVirtualPath(name)) + { + return name; + } + else + { + auto sourceNode = getSourceNodeFromRealPath(name); + if (!sourceNode) + return std::nullopt; + return getVirtualPathFromSourceNode(sourceNode.value()); + } +} + +std::optional RobloxPlatform::resolveToRealPath(const Luau::ModuleName& name) const +{ + if (isVirtualPath(name)) + { + if (auto sourceNode = getSourceNodeFromVirtualPath(name)) + { + return getRealPathFromSourceNode(*sourceNode); + } + } + else + { + return name; + } + + return std::nullopt; +} + +Luau::SourceCode::Type RobloxPlatform::sourceCodeTypeFromPath(const std::filesystem::path& path) const +{ + if (auto sourceNode = getSourceNodeFromRealPath(path.generic_string())) + return (*sourceNode)->sourceCodeType(); + + auto filename = path.filename().generic_string(); + + if (endsWith(filename, ".server.lua") || endsWith(filename, ".server.luau")) + { + return Luau::SourceCode::Type::Script; + } + else if (endsWith(filename, ".client.lua") || endsWith(filename, ".client.luau")) + { + return Luau::SourceCode::Type::Local; + } + + return Luau::SourceCode::Type::Module; +} + +static std::string jsonValueToLuau(const json& val) +{ + if (val.is_string() || val.is_number() || val.is_boolean()) + { + return val.dump(); + } + else if (val.is_null()) + { + return "nil"; + } + else if (val.is_array()) + { + std::string out = "{"; + for (auto& elem : val) + { + out += jsonValueToLuau(elem); + out += ";"; + } + + out += "}"; + return out; + } + else if (val.is_object()) + { + std::string out = "{"; + + for (auto& [key, value] : val.items()) + { + out += "[\"" + key + "\"] = "; + out += jsonValueToLuau(value); + out += ";"; + } + + out += "}"; + return out; + } + else + { + return ""; // TODO: should we error here? + } +} + +std::optional RobloxPlatform::readSourceCode(const Luau::ModuleName& name, const std::filesystem::path& path) const +{ + if (auto parentResult = LSPPlatform::readSourceCode(name, path)) + return parentResult; + + auto source = readFile(path); + if (source && path.extension() == ".json") + { + try + { + source = "--!strict\nreturn " + jsonValueToLuau(json::parse(*source)); + } + catch (const std::exception& e) + { + // TODO: display diagnostic? + std::cerr << "Failed to load JSON module: " << path.generic_string() << " - " << e.what() << '\n'; + return std::nullopt; + } + } + + return std::nullopt; +} + +/// Modify the context so that game/Players/LocalPlayer items point to the correct place +static std::string mapContext(const std::string& context) +{ + if (context == "game/Players/LocalPlayer/PlayerScripts") + return "game/StarterPlayer/StarterPlayerScripts"; + else if (context == "game/Players/LocalPlayer/PlayerGui") + return "game/StarterGui"; + else if (context == "game/Players/LocalPlayer/StarterGear") + return "game/StarterPack"; + return context; +} + +std::optional RobloxPlatform::resolveModule(const Luau::ModuleInfo* context, Luau::AstExpr* node) { + + if (auto parentResult = LSPPlatform::resolveModule(context, node)) + return parentResult; + + if (auto* g = node->as()) + { + if (g->name == "game") + return Luau::ModuleInfo{"game"}; + + if (g->name == "script") + { + if (auto virtualPath = resolveToVirtualPath(context->name)) + { + return Luau::ModuleInfo{virtualPath.value()}; + } + } + } + else if (auto* i = node->as()) + { + if (context) + { + if (strcmp(i->index.value, "Parent") == 0) + { + // Pop the name instead + auto parentPath = getParentPath(context->name); + if (parentPath.has_value()) + return Luau::ModuleInfo{parentPath.value(), context->optional}; + } + + return Luau::ModuleInfo{mapContext(context->name) + '/' + i->index.value, context->optional}; + } + } + else if (auto* i_expr = node->as()) + { + if (auto* index = i_expr->index->as()) + { + if (context) + return Luau::ModuleInfo{mapContext(context->name) + '/' + std::string(index->value.data, index->value.size), context->optional}; + } + } + else if (auto* call = node->as(); call && call->self && call->args.size >= 1 && context) + { + if (auto* index = call->args.data[0]->as()) + { + Luau::AstName func = call->func->as()->index; + + if (func == "GetService" && context->name == "game") + { + return Luau::ModuleInfo{"game/" + std::string(index->value.data, index->value.size)}; + } + else if (func == "WaitForChild" || (func == "FindFirstChild" && call->args.size == 1)) // Don't allow recursive FFC + { + return Luau::ModuleInfo{mapContext(context->name) + '/' + std::string(index->value.data, index->value.size), context->optional}; + } + else if (func == "FindFirstAncestor") + { + auto ancestorName = getAncestorPath(context->name, std::string(index->value.data, index->value.size), rootSourceNode); + if (ancestorName) + return Luau::ModuleInfo{*ancestorName, context->optional}; + } + } + } + + return std::nullopt; +} diff --git a/src/platform/roblox/RobloxLanguageServer.cpp b/src/platform/roblox/RobloxLanguageServer.cpp new file mode 100644 index 00000000..cd88235b --- /dev/null +++ b/src/platform/roblox/RobloxLanguageServer.cpp @@ -0,0 +1,32 @@ +#include + +#include + +void RobloxPlatform::onDidChangeWatchedFiles(const lsp::FileEvent& change) +{ + auto filePath = change.uri.fsPath(); + auto config = workspaceFolder->client->getConfiguration(workspaceFolder->rootUri); + + // Flag sourcemap changes + if (filePath.filename() == "sourcemap.json") + { + workspaceFolder->client->sendLogMessage(lsp::MessageType::Info, "Registering sourcemap changed for workspace " + workspaceFolder->name); + updateSourceMap(); + + // Recompute diagnostics + workspaceFolder->recomputeDiagnostics(config); + } +} + +void RobloxPlatform::setupWithConfiguration(const ClientConfiguration& config) +{ + if (config.sourcemap.enabled) + { + workspaceFolder->client->sendTrace("workspace: sourcemap enabled"); + if (!workspaceFolder->isNullWorkspace() && !updateSourceMap()) + { + workspaceFolder->client->sendWindowMessage(lsp::MessageType::Error, + "Failed to load sourcemap.json for workspace '" + workspaceFolder->name + "'. Instance information will not be available"); + } + } +} diff --git a/src/platform/roblox/RobloxLuauExt.cpp b/src/platform/roblox/RobloxLuauExt.cpp new file mode 100644 index 00000000..1aa465b1 --- /dev/null +++ b/src/platform/roblox/RobloxLuauExt.cpp @@ -0,0 +1,532 @@ +#include "Platform/RobloxPlatform.hpp" + +#include "Luau/BuiltinDefinitions.h" +#include "Luau/ConstraintSolver.h" +#include "Luau/TypeInfer.h" + +// Magic function for `Instance:IsA("ClassName")` predicate +std::optional> magicFunctionInstanceIsA(Luau::TypeChecker& typeChecker, const Luau::ScopePtr& scope, + const Luau::AstExprCall& expr, const Luau::WithPredicate& withPredicate) +{ + if (expr.args.size != 1) + return std::nullopt; + + auto index = expr.func->as(); + auto str = expr.args.data[0]->as(); + if (!index || !str) + return std::nullopt; + + std::optional lvalue = tryGetLValue(*index->expr); + if (!lvalue) + return std::nullopt; + + std::string className(str->value.data, str->value.size); + std::optional tfun = typeChecker.globalScope->lookupType(className); + if (!tfun || !tfun->typeParams.empty() || !tfun->typePackParams.empty()) + { + typeChecker.reportError(Luau::TypeError{expr.args.data[0]->location, Luau::UnknownSymbol{className, Luau::UnknownSymbol::Type}}); + return std::nullopt; + } + + auto type = Luau::follow(tfun->type); + + Luau::TypeArena& arena = typeChecker.currentModule->internalTypes; + Luau::TypePackId booleanPack = arena.addTypePack({typeChecker.booleanType}); + return Luau::WithPredicate{booleanPack, {Luau::IsAPredicate{std::move(*lvalue), expr.location, type}}}; +} + +static bool dcrMagicFunctionInstanceIsA(Luau::MagicFunctionCallContext context) +{ + if (context.callSite->args.size != 1) + return false; + + auto index = context.callSite->func->as(); + auto str = context.callSite->args.data[0]->as(); + if (!index || !str) + return false; + + std::string className(str->value.data, str->value.size); + std::optional tfun = context.constraint->scope->lookupType(className); + if (!tfun) + context.solver->reportError( + Luau::TypeError{context.callSite->args.data[0]->location, Luau::UnknownSymbol{className, Luau::UnknownSymbol::Type}}); + + return false; +} + +void dcrMagicRefinementInstanceIsA(const Luau::MagicRefinementContext& context) +{ + if (context.callSite->args.size != 1 || context.discriminantTypes.empty()) + return; + + auto index = context.callSite->func->as(); + auto str = context.callSite->args.data[0]->as(); + if (!index || !str) + return; + + std::optional discriminantTy = context.discriminantTypes[0]; + if (!discriminantTy) + return; + + std::string className(str->value.data, str->value.size); + std::optional tfun = context.scope->lookupType(className); + if (!tfun) + return; + + LUAU_ASSERT(Luau::get(*discriminantTy)); + asMutable(*discriminantTy)->ty.emplace(Luau::follow(tfun->type)); +} + +// Magic function for `instance:Clone()`, so that we return the exact subclass that `instance` is, rather than just a generic Instance +std::optional> magicFunctionInstanceClone(Luau::TypeChecker& typeChecker, const Luau::ScopePtr& scope, + const Luau::AstExprCall& expr, const Luau::WithPredicate& withPredicate) +{ + auto index = expr.func->as(); + if (!index) + return std::nullopt; + + Luau::TypeArena& arena = typeChecker.currentModule->internalTypes; + auto instanceType = typeChecker.checkExpr(scope, *index->expr); + return Luau::WithPredicate{arena.addTypePack({instanceType.type})}; +} + +static bool dcrMagicFunctionInstanceClone(Luau::MagicFunctionCallContext context) +{ + auto index = context.callSite->func->as(); + if (!index) + return false; + + // The cloned type is the self type, i.e. the first argument + auto selfTy = Luau::first(context.arguments); + if (!selfTy) + return false; + + asMutable(context.result)->ty.emplace(context.solver->arena->addTypePack({*selfTy})); + return true; +} + +// Magic function for `Instance:FindFirstChildWhichIsA("ClassName")` and friends +std::optional> magicFunctionFindFirstXWhichIsA(Luau::TypeChecker& typeChecker, const Luau::ScopePtr& scope, + const Luau::AstExprCall& expr, const Luau::WithPredicate& withPredicate) +{ + if (expr.args.size < 1) + return std::nullopt; + + auto str = expr.args.data[0]->as(); + if (!str) + return std::nullopt; + + std::optional tfun = scope->lookupType(std::string(str->value.data, str->value.size)); + if (!tfun || !tfun->typeParams.empty() || !tfun->typePackParams.empty()) + return std::nullopt; + + auto type = Luau::follow(tfun->type); + + Luau::TypeArena& arena = typeChecker.currentModule->internalTypes; + Luau::TypeId nillableClass = Luau::makeOption(typeChecker.builtinTypes, arena, type); + return Luau::WithPredicate{arena.addTypePack({nillableClass})}; +} + +static bool dcrMagicFunctionFindFirstXWhichIsA(Luau::MagicFunctionCallContext context) +{ + if (context.callSite->args.size < 1) + return false; + + auto str = context.callSite->args.data[0]->as(); + if (!str) + return false; + + std::optional tfun = context.constraint->scope->lookupType(std::string(str->value.data, str->value.size)); + if (!tfun || !tfun->typeParams.empty() || !tfun->typePackParams.empty()) + return false; + + auto type = Luau::follow(tfun->type); + + Luau::TypeId nillableClass = Luau::makeOption(context.solver->builtinTypes, *context.solver->arena, type); + asMutable(context.result)->ty.emplace(context.solver->arena->addTypePack({nillableClass})); + return true; +} + +// Magic function for `EnumItem:IsA("EnumType")` predicate +std::optional> magicFunctionEnumItemIsA(Luau::TypeChecker& typeChecker, const Luau::ScopePtr& scope, + const Luau::AstExprCall& expr, const Luau::WithPredicate& withPredicate) +{ + if (expr.args.size != 1) + return std::nullopt; + + auto index = expr.func->as(); + auto str = expr.args.data[0]->as(); + if (!index || !str) + return std::nullopt; + + std::optional lvalue = tryGetLValue(*index->expr); + if (!lvalue) + return std::nullopt; + + std::string enumItem(str->value.data, str->value.size); + std::optional tfun = scope->lookupImportedType("Enum", enumItem); + if (!tfun || !tfun->typeParams.empty() || !tfun->typePackParams.empty()) + { + typeChecker.reportError(Luau::TypeError{expr.args.data[0]->location, Luau::UnknownSymbol{enumItem, Luau::UnknownSymbol::Type}}); + return std::nullopt; + } + + auto type = Luau::follow(tfun->type); + + Luau::TypeArena& arena = typeChecker.currentModule->internalTypes; + Luau::TypePackId booleanPack = arena.addTypePack({typeChecker.booleanType}); + return Luau::WithPredicate{booleanPack, {Luau::IsAPredicate{std::move(*lvalue), expr.location, type}}}; +} + +static bool dcrMagicFunctionEnumItemIsA(Luau::MagicFunctionCallContext context) +{ + if (context.callSite->args.size != 1) + return false; + + auto index = context.callSite->func->as(); + auto str = context.callSite->args.data[0]->as(); + if (!index || !str) + return false; + + std::string enumItem(str->value.data, str->value.size); + std::optional tfun = context.constraint->scope->lookupImportedType("Enum", enumItem); + if (!tfun) + context.solver->reportError( + Luau::TypeError{context.callSite->args.data[0]->location, Luau::UnknownSymbol{enumItem, Luau::UnknownSymbol::Type}}); + + return false; +} + +static void dcrMagicRefinementEnumItemIsA(const Luau::MagicRefinementContext& context) +{ + if (context.callSite->args.size != 1 || context.discriminantTypes.empty()) + return; + + auto index = context.callSite->func->as(); + auto str = context.callSite->args.data[0]->as(); + if (!index || !str) + return; + + std::optional discriminantTy = context.discriminantTypes[0]; + if (!discriminantTy) + return; + + std::string enumItem(str->value.data, str->value.size); + std::optional tfun = context.scope->lookupImportedType("Enum", enumItem); + if (!tfun) + return; + + LUAU_ASSERT(Luau::get(*discriminantTy)); + asMutable(*discriminantTy)->ty.emplace(Luau::follow(tfun->type)); +} + +// Magic function for `instance:GetPropertyChangedSignal()`, so that we can perform type checking on the provided property +static std::optional> magicFunctionGetPropertyChangedSignal(Luau::TypeChecker& typeChecker, + const Luau::ScopePtr& scope, const Luau::AstExprCall& expr, const Luau::WithPredicate& withPredicate) +{ + if (expr.args.size != 1) + return std::nullopt; + + auto index = expr.func->as(); + auto str = expr.args.data[0]->as(); + if (!index || !str) + return std::nullopt; + + + auto instanceType = typeChecker.checkExpr(scope, *index->expr); + auto ctv = Luau::get(Luau::follow(instanceType.type)); + if (!ctv) + return std::nullopt; + + std::string property(str->value.data, str->value.size); + if (!Luau::lookupClassProp(ctv, property)) + { + typeChecker.reportError(Luau::TypeError{expr.args.data[0]->location, Luau::UnknownProperty{instanceType.type, property}}); + return std::nullopt; + } + + return std::nullopt; +} + +static bool dcrMagicFunctionGetPropertyChangedSignal(Luau::MagicFunctionCallContext context) +{ + if (context.callSite->args.size != 1) + return false; + + auto index = context.callSite->func->as(); + auto str = context.callSite->args.data[0]->as(); + if (!index || !str) + return false; + + // The cloned type is the self type, i.e. the first argument + auto selfTy = Luau::first(context.arguments); + if (!selfTy) + return false; + + auto ctv = Luau::get(Luau::follow(selfTy)); + if (!ctv) + return false; + + std::string property(str->value.data, str->value.size); + if (!Luau::lookupClassProp(ctv, property)) + { + context.solver->reportError(Luau::TypeError{context.callSite->args.data[0]->location, Luau::UnknownProperty{*selfTy, property}}); + } + + return false; +} + +// Since in Roblox land, debug is extended to introduce more methods, but the api-docs +// mark the package name as `@luau` instead of `@roblox` +static void fixDebugDocumentationSymbol(Luau::TypeId ty, const std::string& libraryName) +{ + auto mutableTy = Luau::asMutable(ty); + auto newDocumentationSymbol = mutableTy->documentationSymbol.value(); + replace(newDocumentationSymbol, "@roblox", "@luau"); + mutableTy->documentationSymbol = newDocumentationSymbol; + + if (auto* ttv = Luau::getMutable(ty)) + { + ttv->name = "typeof(" + libraryName + ")"; + for (auto& [name, prop] : ttv->props) + { + newDocumentationSymbol = prop.documentationSymbol.value(); + replace(newDocumentationSymbol, "@roblox", "@luau"); + prop.documentationSymbol = newDocumentationSymbol; + } + } +} + +static Luau::MagicFunction createMagicFunctionTypeLookup(const std::vector& lookupList, const std::string& errorMessagePrefix) +{ + return [lookupList, errorMessagePrefix](Luau::TypeChecker& typeChecker, const Luau::ScopePtr& scope, const Luau::AstExprCall& expr, + const Luau::WithPredicate& withPredicate) -> std::optional> + { + if (expr.args.size < 1) + return std::nullopt; + + if (auto str = expr.args.data[0]->as()) + { + auto className = std::string(str->value.data, str->value.size); + if (contains(lookupList, className)) + { + std::optional tfun = typeChecker.globalScope->lookupType(className); + if (!tfun || !tfun->typeParams.empty() || !tfun->typePackParams.empty()) + { + typeChecker.reportError(Luau::TypeError{expr.args.data[0]->location, Luau::UnknownSymbol{className, Luau::UnknownSymbol::Type}}); + return std::nullopt; + } + + auto type = Luau::follow(tfun->type); + + Luau::TypeArena& arena = typeChecker.currentModule->internalTypes; + Luau::TypePackId classTypePack = arena.addTypePack({type}); + return Luau::WithPredicate{classTypePack}; + } + else + { + typeChecker.reportError( + Luau::TypeError{expr.args.data[0]->location, Luau::GenericError{errorMessagePrefix + " '" + className + "'"}}); + } + } + + return std::nullopt; + }; +} + +static auto createDcrMagicFunctionTypeLookup(const std::vector& lookupList, const std::string& errorMessagePrefix) +{ + return [lookupList, errorMessagePrefix](Luau::MagicFunctionCallContext context) -> bool + { + if (context.callSite->args.size < 1) + return false; + + if (auto str = context.callSite->args.data[0]->as()) + { + auto className = std::string(str->value.data, str->value.size); + if (contains(lookupList, className)) + { + // TODO: only check the global scope? + std::optional tfun = context.constraint->scope->lookupType(className); + if (!tfun || !tfun->typeParams.empty() || !tfun->typePackParams.empty()) + { + context.solver->reportError( + Luau::TypeError{context.callSite->args.data[0]->location, Luau::UnknownSymbol{className, Luau::UnknownSymbol::Type}}); + return false; + } + + auto type = Luau::follow(tfun->type); + Luau::TypePackId classTypePack = context.solver->arena->addTypePack({type}); + asMutable(context.result)->ty.emplace(classTypePack); + return true; + } + else + { + context.solver->reportError( + Luau::TypeError{context.callSite->args.data[0]->location, Luau::GenericError{errorMessagePrefix + " '" + className + "'"}}); + } + } + + return false; + }; +} + +void RobloxPlatform::mutateRegisteredDefinitions(Luau::GlobalTypes& globals, std::optional metadata) +{ + // HACK: Mark "debug" using `@luau` symbol instead + if (auto it = globals.globalScope->bindings.find(Luau::AstName("debug")); it != globals.globalScope->bindings.end()) + { + auto newDocumentationSymbol = it->second.documentationSymbol.value(); + replace(newDocumentationSymbol, "@roblox", "@luau"); + it->second.documentationSymbol = newDocumentationSymbol; + fixDebugDocumentationSymbol(it->second.typeId, "debug"); + } + + // HACK: Mark "utf8" using `@luau` symbol instead + if (auto it = globals.globalScope->bindings.find(Luau::AstName("utf8")); it != globals.globalScope->bindings.end()) + { + auto newDocumentationSymbol = it->second.documentationSymbol.value(); + replace(newDocumentationSymbol, "@roblox", "@luau"); + it->second.documentationSymbol = newDocumentationSymbol; + fixDebugDocumentationSymbol(it->second.typeId, "utf8"); + } + + // Extend Instance types + if (auto instanceType = globals.globalScope->lookupType("Instance")) + { + if (auto* ctv = Luau::getMutable(instanceType->type)) + { + Luau::attachMagicFunction(ctv->props["IsA"].type(), magicFunctionInstanceIsA); + Luau::attachMagicFunction(ctv->props["FindFirstChildWhichIsA"].type(), magicFunctionFindFirstXWhichIsA); + Luau::attachMagicFunction(ctv->props["FindFirstChildOfClass"].type(), magicFunctionFindFirstXWhichIsA); + Luau::attachMagicFunction(ctv->props["FindFirstAncestorWhichIsA"].type(), magicFunctionFindFirstXWhichIsA); + Luau::attachMagicFunction(ctv->props["FindFirstAncestorOfClass"].type(), magicFunctionFindFirstXWhichIsA); + Luau::attachMagicFunction(ctv->props["Clone"].type(), magicFunctionInstanceClone); + Luau::attachMagicFunction(ctv->props["GetPropertyChangedSignal"].type(), magicFunctionGetPropertyChangedSignal); + + Luau::attachDcrMagicRefinement(ctv->props["IsA"].type(), dcrMagicRefinementInstanceIsA); + Luau::attachDcrMagicFunction(ctv->props["IsA"].type(), dcrMagicFunctionInstanceIsA); + Luau::attachDcrMagicFunction(ctv->props["FindFirstChildWhichIsA"].type(), dcrMagicFunctionFindFirstXWhichIsA); + Luau::attachDcrMagicFunction(ctv->props["FindFirstChildOfClass"].type(), dcrMagicFunctionFindFirstXWhichIsA); + Luau::attachDcrMagicFunction(ctv->props["FindFirstAncestorWhichIsA"].type(), dcrMagicFunctionFindFirstXWhichIsA); + Luau::attachDcrMagicFunction(ctv->props["FindFirstAncestorOfClass"].type(), dcrMagicFunctionFindFirstXWhichIsA); + Luau::attachDcrMagicFunction(ctv->props["Clone"].type(), dcrMagicFunctionInstanceClone); + Luau::attachDcrMagicFunction(ctv->props["GetPropertyChangedSignal"].type(), dcrMagicFunctionGetPropertyChangedSignal); + + // Autocomplete ClassNames for :IsA("") and counterparts + Luau::attachTag(ctv->props["IsA"].type(), "ClassNames"); + Luau::attachTag(ctv->props["FindFirstChildWhichIsA"].type(), "ClassNames"); + Luau::attachTag(ctv->props["FindFirstChildOfClass"].type(), "ClassNames"); + Luau::attachTag(ctv->props["FindFirstAncestorWhichIsA"].type(), "ClassNames"); + Luau::attachTag(ctv->props["FindFirstAncestorOfClass"].type(), "ClassNames"); + + // Autocomplete Properties for :GetPropertyChangedSignal("") + Luau::attachTag(ctv->props["GetPropertyChangedSignal"].type(), "Properties"); + + // Go through all the defined classes and if they are a subclass of Instance then give them the + // same metatable identity as Instance so that equality comparison works. + // NOTE: This will OVERWRITE any metatables set on these classes! + // We assume that all subclasses of instance don't have any metamethods + for (auto& [_, ty] : globals.globalScope->exportedTypeBindings) + { + if (auto* c = Luau::getMutable(ty.type)) + { + // Check if the ctv is a subclass of instance + if (Luau::isSubclass(c, ctv)) + { + c->metatable = ctv->metatable; + } + } + } + } + } + + std::optional robloxMetadata = metadata; + + // Attach onto Instance.new() + if (robloxMetadata.has_value() && !robloxMetadata->CREATABLE_INSTANCES.empty()) + if (auto instanceGlobal = globals.globalScope->lookup(Luau::AstName("Instance"))) + if (auto ttv = Luau::get(instanceGlobal.value())) + if (auto newFunction = ttv->props.find("new"); + newFunction != ttv->props.end() && Luau::get(newFunction->second.type())) + { + + Luau::attachTag(newFunction->second.type(), "CreatableInstances"); + Luau::attachMagicFunction( + newFunction->second.type(), createMagicFunctionTypeLookup(robloxMetadata->CREATABLE_INSTANCES, "Invalid class name")); + Luau::attachDcrMagicFunction( + newFunction->second.type(), createDcrMagicFunctionTypeLookup(robloxMetadata->CREATABLE_INSTANCES, "Invalid class name")); + } + + // Attach onto `game:GetService()` + if (robloxMetadata.has_value() && !robloxMetadata->SERVICES.empty()) + if (auto serviceProviderType = globals.globalScope->lookupType("ServiceProvider")) + if (auto* ctv = Luau::getMutable(serviceProviderType->type); + ctv && Luau::get(ctv->props["GetService"].type())) + { + Luau::attachTag(ctv->props["GetService"].type(), "Services"); + Luau::attachMagicFunction( + ctv->props["GetService"].type(), createMagicFunctionTypeLookup(robloxMetadata->SERVICES, "Invalid service name")); + Luau::attachDcrMagicFunction( + ctv->props["GetService"].type(), createDcrMagicFunctionTypeLookup(robloxMetadata->SERVICES, "Invalid service name")); + } + + // Move Enums over as imported type bindings + std::unordered_map enumTypes{}; + for (auto it = globals.globalScope->exportedTypeBindings.begin(); it != globals.globalScope->exportedTypeBindings.end();) + { + auto erase = false; + auto ty = it->second.type; + if (auto* ctv = Luau::getMutable(ty)) + { + if (Luau::startsWith(ctv->name, "Enum")) + { + if (ctv->name == "EnumItem") + { + Luau::attachMagicFunction(ctv->props["IsA"].type(), magicFunctionEnumItemIsA); + Luau::attachDcrMagicFunction(ctv->props["IsA"].type(), dcrMagicFunctionEnumItemIsA); + Luau::attachDcrMagicRefinement(ctv->props["IsA"].type(), dcrMagicRefinementEnumItemIsA); + Luau::attachTag(ctv->props["IsA"].type(), "Enums"); + } + else if (ctv->name != "Enum" && ctv->name != "Enums") + { + // Erase the "Enum" at the start + ctv->name = ctv->name.substr(4); + + // Move the enum over to the imported types if it is not internal, otherwise rename the type + if (endsWith(ctv->name, "_INTERNAL")) + { + ctv->name.erase(ctv->name.rfind("_INTERNAL"), 9); + } + else + { + enumTypes.emplace(ctv->name, it->second); + // Erase the metatable for the type, so it can be used in comparison + } + + // Update the documentation symbol + Luau::asMutable(ty)->documentationSymbol = "@roblox/enum/" + ctv->name; + for (auto& [name, prop] : ctv->props) + { + prop.documentationSymbol = "@roblox/enum/" + ctv->name + "." + name; + Luau::attachTag(prop, "EnumItem"); + } + + // Prefix the name (after it has been placed into enumTypes) with "Enum." + ctv->name = "Enum." + ctv->name; + + erase = true; + } + + // Erase the metatable from the type to allow comparison + ctv->metatable = std::nullopt; + } + } + + if (erase) + it = globals.globalScope->exportedTypeBindings.erase(it); + else + ++it; + } + globals.globalScope->importedTypeBindings.emplace("Enum", enumTypes); +} diff --git a/src/Sourcemap.cpp b/src/platform/roblox/RobloxSourceNode.cpp similarity index 54% rename from src/Sourcemap.cpp rename to src/platform/roblox/RobloxSourceNode.cpp index da5db5c2..24405f7a 100644 --- a/src/Sourcemap.cpp +++ b/src/platform/roblox/RobloxSourceNode.cpp @@ -1,14 +1,10 @@ -#include -#include -#include "LSP/Sourcemap.hpp" -#include "LSP/Utils.hpp" +#include "Platform/RobloxPlatform.hpp" bool SourceNode::isScript() { return className == "ModuleScript" || className == "Script" || className == "LocalScript"; } - /// NOTE: Use `WorkspaceFileResolver::getRealPathFromSourceNode()` instead of this function where /// possible, as that will ensure it is relative to the correct workspace root. std::optional SourceNode::getScriptFilePath() @@ -66,60 +62,3 @@ std::optional SourceNode::findAncestor(const std::string& ancesto } return std::nullopt; } - -Luau::SourceCode::Type sourceCodeTypeFromPath(const std::filesystem::path& requirePath) -{ - auto filename = requirePath.filename().generic_string(); - if (endsWith(filename, ".server.lua") || endsWith(filename, ".server.luau")) - { - return Luau::SourceCode::Type::Script; - } - else if (endsWith(filename, ".client.lua") || endsWith(filename, ".client.luau")) - { - return Luau::SourceCode::Type::Local; - } - - return Luau::SourceCode::Type::Module; -} - -std::string jsonValueToLuau(const json& val) -{ - if (val.is_string() || val.is_number() || val.is_boolean()) - { - return val.dump(); - } - else if (val.is_null()) - { - return "nil"; - } - else if (val.is_array()) - { - std::string out = "{"; - for (auto& elem : val) - { - out += jsonValueToLuau(elem); - out += ";"; - } - - out += "}"; - return out; - } - else if (val.is_object()) - { - std::string out = "{"; - - for (auto& [key, value] : val.items()) - { - out += "[\"" + key + "\"] = "; - out += jsonValueToLuau(value); - out += ";"; - } - - out += "}"; - return out; - } - else - { - return ""; // TODO: should we error here? - } -} \ No newline at end of file diff --git a/src/platform/roblox/RobloxSourcemap.cpp b/src/platform/roblox/RobloxSourcemap.cpp new file mode 100644 index 00000000..4145eff4 --- /dev/null +++ b/src/platform/roblox/RobloxSourcemap.cpp @@ -0,0 +1,444 @@ +#include "Platform/RobloxPlatform.hpp" + +#include "LSP/Workspace.hpp" +#include "Luau/BuiltinDefinitions.h" +#include "Luau/ConstraintSolver.h" + +static void mutateSourceNodeWithPluginInfo(SourceNode& sourceNode, const PluginNodePtr& pluginInstance) +{ + // We currently perform purely additive changes where we add in new children + for (const auto& dmChild : pluginInstance->children) + { + if (auto existingChildNode = sourceNode.findChild(dmChild->name)) + { + mutateSourceNodeWithPluginInfo(*existingChildNode.value(), dmChild); + } + else + { + SourceNode childNode; + childNode.name = dmChild->name; + childNode.className = dmChild->className; + mutateSourceNodeWithPluginInfo(childNode, dmChild); + + sourceNode.children.push_back(std::make_shared(childNode)); + } + } +} + +static std::optional getTypeIdForClass(const Luau::ScopePtr& globalScope, std::optional className) +{ + std::optional baseType; + if (className.has_value()) + { + baseType = globalScope->lookupType(className.value()); + } + if (!baseType.has_value()) + { + baseType = globalScope->lookupType("Instance"); + } + + if (baseType.has_value()) + { + return baseType->type; + } + else + { + // If we reach this stage, we couldn't find the class name nor the "Instance" type + // This most likely means a valid definitions file was not provided + return std::nullopt; + } +} + +// Retrieves the corresponding Luau type for a Sourcemap node +// If it does not yet exist, the type is produced +static Luau::TypeId getSourcemapType(const Luau::GlobalTypes& globals, Luau::TypeArena& arena, const SourceNodePtr& node) +{ + // Gets the type corresponding to the sourcemap node if it exists + // Make sure to use the correct ty version (base typeChecker vs autocomplete typeChecker) + if (node->tys.find(&globals) != node->tys.end()) + return node->tys.at(&globals); + + Luau::LazyType ltv( + [&globals, &arena, node](Luau::LazyType& ltv) -> void + { + // Check if the LTV already has an unwrapped type + if (ltv.unwrapped.load()) + return; + + // Handle if the node is no longer valid + if (!node) + { + ltv.unwrapped = globals.builtinTypes->anyType; + return; + } + + auto instanceTy = globals.globalScope->lookupType("Instance"); + if (!instanceTy) + { + ltv.unwrapped = globals.builtinTypes->anyType; + return; + } + + // Look up the base class instance + Luau::TypeId baseTypeId = getTypeIdForClass(globals.globalScope, node->className).value_or(nullptr); + if (!baseTypeId) + { + ltv.unwrapped = globals.builtinTypes->anyType; + return; + } + + // Point the metatable to the metatable of "Instance" so that we allow equality + std::optional instanceMetaIdentity; + if (auto* ctv = Luau::get(instanceTy->type)) + instanceMetaIdentity = ctv->metatable; + + // Create the ClassType representing the instance + std::string typeName = types::getTypeName(baseTypeId).value_or(node->name); + Luau::ClassType baseInstanceCtv{typeName, {}, baseTypeId, instanceMetaIdentity, {}, {}, "@roblox"}; + auto typeId = arena.addType(std::move(baseInstanceCtv)); + + // Attach Parent and Children info + // Get the mutable version of the type var + if (auto* ctv = Luau::getMutable(typeId)) + { + if (auto parentNode = node->parent.lock()) + ctv->props["Parent"] = Luau::makeProperty(getSourcemapType(globals, arena, parentNode)); + + // Add children as properties + for (const auto& child : node->children) + ctv->props[child->name] = Luau::makeProperty(getSourcemapType(globals, arena, child)); + + // Add FindFirstAncestor and FindFirstChild + if (auto instanceType = getTypeIdForClass(globals.globalScope, "Instance")) + { + auto findFirstAncestorFunction = Luau::makeFunction(arena, typeId, {globals.builtinTypes->stringType}, {"name"}, {*instanceType}); + + Luau::attachMagicFunction(findFirstAncestorFunction, + [&arena, &globals, node](Luau::TypeChecker& typeChecker, const Luau::ScopePtr& scope, const Luau::AstExprCall& expr, + const Luau::WithPredicate& withPredicate) -> std::optional> + { + if (expr.args.size < 1) + return std::nullopt; + + auto str = expr.args.data[0]->as(); + if (!str) + return std::nullopt; + + // This is a O(n) search, not great! + if (auto ancestor = node->findAncestor(std::string(str->value.data, str->value.size))) + { + return Luau::WithPredicate{arena.addTypePack({getSourcemapType(globals, arena, *ancestor)})}; + } + + return std::nullopt; + }); + Luau::attachDcrMagicFunction(findFirstAncestorFunction, + [node, &arena, &globals](Luau::MagicFunctionCallContext context) -> bool + { + if (context.callSite->args.size < 1) + return false; + + auto str = context.callSite->args.data[0]->as(); + if (!str) + return false; + + if (auto ancestor = node->findAncestor(std::string(str->value.data, str->value.size))) + { + asMutable(context.result) + ->ty.emplace( + context.solver->arena->addTypePack({getSourcemapType(globals, arena, *ancestor)})); + return true; + } + return false; + }); + ctv->props["FindFirstAncestor"] = Luau::makeProperty(findFirstAncestorFunction, "@roblox/globaltype/Instance.FindFirstAncestor"); + + auto findFirstChildFunction = Luau::makeFunction(arena, typeId, {globals.builtinTypes->stringType}, {"name"}, {*instanceType}); + Luau::attachMagicFunction(findFirstChildFunction, + [node, &arena, &globals](Luau::TypeChecker& typeChecker, const Luau::ScopePtr& scope, const Luau::AstExprCall& expr, + const Luau::WithPredicate& withPredicate) -> std::optional> + { + if (expr.args.size < 1) + return std::nullopt; + + auto str = expr.args.data[0]->as(); + if (!str) + return std::nullopt; + + if (auto child = node->findChild(std::string(str->value.data, str->value.size))) + return Luau::WithPredicate{arena.addTypePack({getSourcemapType(globals, arena, *child)})}; + + return std::nullopt; + }); + Luau::attachDcrMagicFunction(findFirstChildFunction, + [node, &arena, &globals](Luau::MagicFunctionCallContext context) -> bool + { + if (context.callSite->args.size < 1) + return false; + + auto str = context.callSite->args.data[0]->as(); + if (!str) + return false; + + if (auto child = node->findChild(std::string(str->value.data, str->value.size))) + { + asMutable(context.result) + ->ty.emplace(context.solver->arena->addTypePack({getSourcemapType(globals, arena, *child)})); + return true; + } + return false; + }); + ctv->props["FindFirstChild"] = Luau::makeProperty(findFirstChildFunction, "@roblox/globaltype/Instance.FindFirstChild"); + } + } + + ltv.unwrapped = typeId; + return; + }); + auto ty = arena.addType(std::move(ltv)); + node->tys.insert_or_assign(&globals, ty); + + return ty; +} + +void addChildrenToCTV(const Luau::GlobalTypes& globals, Luau::TypeArena& arena, const Luau::TypeId& ty, const SourceNodePtr& node) +{ + if (auto* ctv = Luau::getMutable(ty)) + { + // Clear out all the old registered children + for (auto it = ctv->props.begin(); it != ctv->props.end();) + { + if (hasTag(it->second, "@sourcemap-generated")) + it = ctv->props.erase(it); + else + ++it; + } + + + // Extend the props to include the children + for (const auto& child : node->children) + { + ctv->props[child->name] = Luau::Property{ + getSourcemapType(globals, arena, child), + /* deprecated */ false, + /* deprecatedSuggestion */ {}, + /* location */ std::nullopt, + /* tags */ {"@sourcemap-generated"}, + /* documentationSymbol*/ std::nullopt, + }; + } + } +} + +bool RobloxPlatform::updateSourceMap() +{ + auto sourcemapPath = workspaceFolder->rootUri.fsPath() / "sourcemap.json"; + workspaceFolder->client->sendTrace("Updating sourcemap contents from " + sourcemapPath.generic_string()); + + // Read in the sourcemap + // TODO: we assume a sourcemap.json file in the workspace root + if (auto sourceMapContents = readFile(sourcemapPath)) + { + workspaceFolder->frontend.clear(); + updateSourceNodeMap(sourceMapContents.value()); + + auto config = workspaceFolder->client->getConfiguration(workspaceFolder->rootUri); + bool expressiveTypes = config.diagnostics.strictDatamodelTypes; + + // NOTE: expressive types is always enabled for autocomplete, regardless of the setting! + // We pass the same setting even when we are registering autocomplete globals since + // the setting impacts what happens to diagnostics (as both calls overwrite frontend.prepareModuleScope) + handleSourcemapUpdate(workspaceFolder->frontend, workspaceFolder->frontend.globals, expressiveTypes); + handleSourcemapUpdate(workspaceFolder->frontend, workspaceFolder->frontend.globalsForAutocomplete, expressiveTypes); + + return true; + } + else + { + return false; + } +} + +void RobloxPlatform::writePathsToMap(const SourceNodePtr& node, const std::string& base) +{ + node->virtualPath = base; + virtualPathsToSourceNodes[base] = node; + + if (auto realPath = node->getScriptFilePath()) + { + std::error_code ec; + auto canonicalName = std::filesystem::weakly_canonical(fileResolver->rootUri.fsPath() / *realPath, ec); + if (ec.value() != 0) + canonicalName = *realPath; + realPathsToSourceNodes[canonicalName.generic_string()] = node; + } + + for (auto& child : node->children) + { + child->parent = node; + writePathsToMap(child, base + "/" + child->name); + } +} + +void RobloxPlatform::updateSourceNodeMap(const std::string& sourceMapContents) +{ + realPathsToSourceNodes.clear(); + virtualPathsToSourceNodes.clear(); + + try + { + auto j = json::parse(sourceMapContents); + rootSourceNode = std::make_shared(j.get()); + + // Write paths + std::string base = rootSourceNode->className == "DataModel" ? "game" : "ProjectRoot"; + writePathsToMap(rootSourceNode, base); + } + catch (const std::exception& e) + { + // TODO: log message? + std::cerr << e.what() << '\n'; + } +} + +// TODO: expressiveTypes is used because of a Luau issue where we can't cast a most specific Instance type (which we create here) +// to another type. For the time being, we therefore make all our DataModel instance types marked as "any". +// Remove this once Luau has improved +void RobloxPlatform::handleSourcemapUpdate(Luau::Frontend& frontend, const Luau::GlobalTypes& globals, bool expressiveTypes) +{ + if (!rootSourceNode) + return; + + // Mutate with plugin info + if (pluginInfo) + { + if (rootSourceNode->className == "DataModel") + { + mutateSourceNodeWithPluginInfo(*rootSourceNode, pluginInfo); + } + else + { + std::cerr << "Attempted to update plugin information for a non-DM instance" << '\n'; + } + } + + // Recreate instance types + instanceTypes.clear(); + + // Create a type for the root source node + getSourcemapType(globals, instanceTypes, rootSourceNode); + + // Modify sourcemap types + if (rootSourceNode->className == "DataModel") + { + // Mutate DataModel with its children + if (auto dataModelType = globals.globalScope->lookupType("DataModel")) + addChildrenToCTV(globals, instanceTypes, dataModelType->type, rootSourceNode); + + // Mutate globally-registered Services to include children information (so it's available through :GetService) + for (const auto& service : rootSourceNode->children) + { + auto serviceName = service->className; // We know it must be a service of the same class name + if (auto serviceType = globals.globalScope->lookupType(serviceName)) + addChildrenToCTV(globals, instanceTypes, serviceType->type, service); + } + + // Add containers to player and copy over instances + // TODO: Player.Character should contain StarterCharacter instances + if (auto playerType = globals.globalScope->lookupType("Player")) + { + if (auto* ctv = Luau::getMutable(playerType->type)) + { + // Player.Backpack should be defined + if (auto backpackType = globals.globalScope->lookupType("Backpack")) + { + ctv->props["Backpack"] = Luau::makeProperty(backpackType->type); + // TODO: should we duplicate StarterPack into here as well? Is that a reasonable assumption to make? + } + + // Player.PlayerGui should contain StarterGui instances + if (auto playerGuiType = globals.globalScope->lookupType("PlayerGui")) + { + if (auto starterGui = rootSourceNode->findChild("StarterGui")) + addChildrenToCTV(globals, instanceTypes, playerGuiType->type, *starterGui); + ctv->props["PlayerGui"] = Luau::makeProperty(playerGuiType->type); + } + + // Player.StarterGear should contain StarterPack instances + if (auto starterGearType = globals.globalScope->lookupType("StarterGear")) + { + if (auto starterPack = rootSourceNode->findChild("StarterPack")) + addChildrenToCTV(globals, instanceTypes, starterGearType->type, *starterPack); + + ctv->props["StarterGear"] = Luau::makeProperty(starterGearType->type); + } + + // Player.PlayerScripts should contain StarterPlayerScripts instances + if (auto playerScriptsType = globals.globalScope->lookupType("PlayerScripts")) + { + if (auto starterPlayer = rootSourceNode->findChild("StarterPlayer")) + { + if (auto starterPlayerScripts = starterPlayer.value()->findChild("StarterPlayerScripts")) + { + addChildrenToCTV(globals, instanceTypes, playerScriptsType->type, *starterPlayerScripts); + } + } + ctv->props["PlayerScripts"] = Luau::makeProperty(playerScriptsType->type); + } + } + } + } + + // Prepare module scope so that we can dynamically reassign the type of "script" to retrieve instance info + frontend.prepareModuleScope = [this, &frontend, expressiveTypes](const Luau::ModuleName& name, const Luau::ScopePtr& scope, bool forAutocomplete) + { + Luau::GlobalTypes& globals = forAutocomplete ? frontend.globalsForAutocomplete : frontend.globals; + + // TODO: we hope to remove these in future! + if (!expressiveTypes && !forAutocomplete) + { + scope->bindings[Luau::AstName("script")] = Luau::Binding{globals.builtinTypes->anyType}; + scope->bindings[Luau::AstName("workspace")] = Luau::Binding{globals.builtinTypes->anyType}; + scope->bindings[Luau::AstName("game")] = Luau::Binding{globals.builtinTypes->anyType}; + } + + if (expressiveTypes || forAutocomplete) + if (auto node = isVirtualPath(name) ? getSourceNodeFromVirtualPath(name) : getSourceNodeFromRealPath(name)) + scope->bindings[Luau::AstName("script")] = Luau::Binding{getSourcemapType(globals, instanceTypes, node.value())}; + }; +} + +std::optional RobloxPlatform::getSourceNodeFromVirtualPath(const Luau::ModuleName& name) const +{ + if (virtualPathsToSourceNodes.find(name) == virtualPathsToSourceNodes.end()) + return std::nullopt; + return virtualPathsToSourceNodes.at(name); +} + +std::optional RobloxPlatform::getSourceNodeFromRealPath(const std::string& name) const +{ + std::error_code ec; + auto canonicalName = std::filesystem::weakly_canonical(name, ec); + if (ec.value() != 0) + canonicalName = name; + auto strName = canonicalName.generic_string(); + if (realPathsToSourceNodes.find(strName) == realPathsToSourceNodes.end()) + return std::nullopt; + return realPathsToSourceNodes.at(strName); +} + +Luau::ModuleName RobloxPlatform::getVirtualPathFromSourceNode(const SourceNodePtr& sourceNode) +{ + return sourceNode->virtualPath; +} + +std::optional RobloxPlatform::getRealPathFromSourceNode(const SourceNodePtr& sourceNode) const +{ + // NOTE: this filepath is generated by the sourcemap, which is relative to the cwd where the sourcemap + // command was run from. Hence, we concatenate it to the end of the workspace path + // TODO: make sure this is correct once we make sourcemap.json generic + auto filePath = sourceNode->getScriptFilePath(); + if (filePath) + return fileResolver->rootUri.fsPath() / *filePath; + return std::nullopt; +} diff --git a/src/platform/roblox/RobloxStudioPlugin.cpp b/src/platform/roblox/RobloxStudioPlugin.cpp new file mode 100644 index 00000000..787700c4 --- /dev/null +++ b/src/platform/roblox/RobloxStudioPlugin.cpp @@ -0,0 +1,40 @@ +#include "Platform/RobloxPlatform.hpp" + +#include "LSP/LanguageServer.hpp" +#include "LSP/Workspace.hpp" + +void RobloxPlatform::onStudioPluginFullChange(const PluginNode& dataModel) +{ + workspaceFolder->client->sendLogMessage(lsp::MessageType::Info, "received full change from studio plugin"); + + // TODO: properly handle multi-workspace setup + pluginInfo = std::make_shared(dataModel); + + // Mutate the sourcemap with the new information + updateSourceMap(); +} + +void RobloxPlatform::onStudioPluginClear() +{ + workspaceFolder->client->sendLogMessage(lsp::MessageType::Info, "received clear from studio plugin"); + + // TODO: properly handle multi-workspace setup + pluginInfo = nullptr; + + // Mutate the sourcemap with the new information + updateSourceMap(); +} + +bool RobloxPlatform::handleNotification(const std::string& method, std::optional params) +{ + if (method == "$/plugin/full") + { + onStudioPluginFullChange(JSON_REQUIRED_PARAMS(params, "$/plugin/full")); + } + else if (method == "$/plugin/clear") + { + onStudioPluginClear(); + } + + return false; +} diff --git a/tests/Fixture.cpp b/tests/Fixture.cpp index f54ccd0c..b48cde04 100644 --- a/tests/Fixture.cpp +++ b/tests/Fixture.cpp @@ -1,6 +1,7 @@ // This file is part of the Luau programming language and is licensed under MIT License; see LICENSE.txt for details #include "Fixture.h" +#include "Platform/RobloxPlatform.hpp" #include "Luau/Parser.h" #include "Luau/BuiltinDefinitions.h" #include "LSP/LuauExt.hpp" @@ -111,10 +112,13 @@ Luau::TypeId Fixture::requireType(const std::string& name) Luau::LoadDefinitionFileResult Fixture::loadDefinition(const std::string& source, bool forAutocomplete) { + RobloxPlatform platform; + auto& globals = forAutocomplete ? workspace.frontend.globalsForAutocomplete : workspace.frontend.globals; Luau::unfreeze(globals.globalTypes); Luau::LoadDefinitionFileResult result = types::registerDefinitions(workspace.frontend, globals, source, forAutocomplete); + platform.mutateRegisteredDefinitions(globals, std::nullopt); Luau::freeze(globals.globalTypes); REQUIRE_MESSAGE(result.success, "loadDefinition: unable to load definition file"); diff --git a/tests/LuauExt.test.cpp b/tests/LuauExt.test.cpp index 340476da..2ee3d3ef 100644 --- a/tests/LuauExt.test.cpp +++ b/tests/LuauExt.test.cpp @@ -1,6 +1,7 @@ #include "doctest.h" #include "Fixture.h" #include "LSP/LuauExt.hpp" +#include "Platform/RobloxPlatform.hpp" TEST_SUITE_BEGIN("Luau Extensions"); @@ -11,7 +12,7 @@ TEST_CASE_FIXTURE(Fixture, "FindImports service location 1") )"); REQUIRE(block); - FindImportsVisitor visitor; + RobloxFindImportsVisitor visitor; visitor.visit(block); CHECK_EQ(visitor.findBestLineForService("CollectionService", 0), 1); @@ -26,7 +27,7 @@ TEST_CASE_FIXTURE(Fixture, "FindImports service location 2") )"); REQUIRE(block); - FindImportsVisitor visitor; + RobloxFindImportsVisitor visitor; visitor.visit(block); CHECK_EQ(visitor.findBestLineForService("CollectionService", 0), 1); @@ -42,7 +43,7 @@ TEST_CASE_FIXTURE(Fixture, "FindImports service location 3") )"); REQUIRE(block); - FindImportsVisitor visitor; + RobloxFindImportsVisitor visitor; visitor.visit(block); CHECK_EQ(visitor.findBestLineForService("Workspace", 0), 3); @@ -83,4 +84,4 @@ TEST_CASE_FIXTURE(Fixture, "FindImports require multiline") CHECK_EQ(visitor.requiresMap[0].begin()->second->location.end.line, 2); } -TEST_SUITE_END(); \ No newline at end of file +TEST_SUITE_END(); diff --git a/tests/Sourcemap.test.cpp b/tests/Sourcemap.test.cpp index b45dc9d0..8d6a3ffd 100644 --- a/tests/Sourcemap.test.cpp +++ b/tests/Sourcemap.test.cpp @@ -1,5 +1,5 @@ #include "doctest.h" -#include "LSP/Sourcemap.hpp" +#include "Platform/RobloxPlatform.hpp" TEST_SUITE_BEGIN("SourcemapTests"); @@ -27,4 +27,4 @@ TEST_CASE("getScriptFilePath doesn't pick .meta.json") CHECK_EQ(node.getScriptFilePath(), "init.lua"); } -TEST_SUITE_END(); \ No newline at end of file +TEST_SUITE_END(); diff --git a/tests/Utils.test.cpp b/tests/Utils.test.cpp index 5ee57ba9..feaa2f4f 100644 --- a/tests/Utils.test.cpp +++ b/tests/Utils.test.cpp @@ -1,6 +1,6 @@ #include "doctest.h" #include "LSP/Utils.hpp" -#include "LSP/Sourcemap.hpp" +#include "Platform/RobloxPlatform.hpp" TEST_SUITE_BEGIN("UtilsTest"); @@ -108,4 +108,4 @@ TEST_CASE("getFirstLine returns string when there is no newline") CHECK_EQ(getFirstLine("testing"), "testing"); } -TEST_SUITE_END(); \ No newline at end of file +TEST_SUITE_END(); diff --git a/tests/WorkspaceFileResolver.test.cpp b/tests/WorkspaceFileResolver.test.cpp index ede2825d..5babef57 100644 --- a/tests/WorkspaceFileResolver.test.cpp +++ b/tests/WorkspaceFileResolver.test.cpp @@ -1,6 +1,7 @@ #include "doctest.h" #include "Fixture.h" #include "LSP/WorkspaceFileResolver.hpp" +#include "Platform/RobloxPlatform.hpp" #include "Luau/Ast.h" #include "Luau/FileResolver.h" @@ -9,6 +10,8 @@ TEST_SUITE_BEGIN("WorkspaceFileResolverTests"); TEST_CASE("resolveModule handles LocalPlayer PlayerScripts") { WorkspaceFileResolver fileResolver; + RobloxPlatform platform{&fileResolver}; + fileResolver.platform = &platform; Luau::ModuleInfo baseContext{"game/Players/LocalPlayer/PlayerScripts"}; auto expr = Luau::AstExprIndexName(Luau::Location(), nullptr, Luau::AstName("PurchaseClient"), Luau::Location(), Luau::Position(0, 0), '.'); @@ -21,6 +24,8 @@ TEST_CASE("resolveModule handles LocalPlayer PlayerScripts") TEST_CASE("resolveModule handles LocalPlayer PlayerGui") { WorkspaceFileResolver fileResolver; + RobloxPlatform platform{&fileResolver}; + fileResolver.platform = &platform; Luau::ModuleInfo baseContext{"game/Players/LocalPlayer/PlayerGui"}; auto expr = Luau::AstExprIndexName(Luau::Location(), nullptr, Luau::AstName("GuiScript"), Luau::Location(), Luau::Position(0, 0), '.'); @@ -33,6 +38,8 @@ TEST_CASE("resolveModule handles LocalPlayer PlayerGui") TEST_CASE("resolveModule handles LocalPlayer StarterGear") { WorkspaceFileResolver fileResolver; + RobloxPlatform platform{&fileResolver}; + fileResolver.platform = &platform; Luau::ModuleInfo baseContext{"game/Players/LocalPlayer/StarterGear"}; auto expr = Luau::AstExprIndexName(Luau::Location(), nullptr, Luau::AstName("GearScript"), Luau::Location(), Luau::Position(0, 0), '.'); @@ -45,6 +52,8 @@ TEST_CASE("resolveModule handles LocalPlayer StarterGear") TEST_CASE_FIXTURE(Fixture, "resolveModule handles FindFirstChild") { WorkspaceFileResolver fileResolver; + RobloxPlatform platform{&fileResolver}; + fileResolver.platform = &platform; Luau::ModuleInfo baseContext{"game/ReplicatedStorage"}; @@ -67,6 +76,8 @@ TEST_CASE_FIXTURE(Fixture, "resolveModule handles FindFirstChild") TEST_CASE_FIXTURE(Fixture, "resolveModule fails on FindFirstChild with recursive enabled") { WorkspaceFileResolver fileResolver; + RobloxPlatform platform{&fileResolver}; + fileResolver.platform = &platform; Luau::ModuleInfo baseContext{"game/ReplicatedStorage"}; @@ -91,7 +102,10 @@ TEST_CASE_FIXTURE(Fixture, "resolveModule handles FindFirstAncestor") sourceNode.name = "Foo"; WorkspaceFileResolver fileResolver; - fileResolver.rootSourceNode = std::make_shared(sourceNode); + RobloxPlatform platform{&fileResolver}; + fileResolver.platform = &platform; + + platform.rootSourceNode = std::make_shared(sourceNode); Luau::ModuleInfo baseContext{"ProjectRoot/Bar"}; @@ -141,9 +155,11 @@ TEST_CASE_FIXTURE(Fixture, "resolveDirectoryAliases") TEST_CASE_FIXTURE(Fixture, "string require doesn't add file extension if already exists") { WorkspaceFileResolver fileResolver; + LSPPlatform platform{&fileResolver}; + fileResolver.platform = &platform; Luau::ModuleInfo baseContext{}; - auto resolved = fileResolver.resolveStringRequire(&baseContext, "Module.luau"); + auto resolved = platform.resolveStringRequire(&baseContext, "Module.luau"); REQUIRE(resolved.has_value()); CHECK_EQ(resolved->name, "/Module.luau"); @@ -152,9 +168,11 @@ TEST_CASE_FIXTURE(Fixture, "string require doesn't add file extension if already TEST_CASE_FIXTURE(Fixture, "string require doesn't replace a non-luau/lua extension") { WorkspaceFileResolver fileResolver; + LSPPlatform platform{&fileResolver}; + fileResolver.platform = &platform; Luau::ModuleInfo baseContext{}; - auto resolved = fileResolver.resolveStringRequire(&baseContext, "Module.mod"); + auto resolved = platform.resolveStringRequire(&baseContext, "Module.mod"); REQUIRE(resolved.has_value()); CHECK_EQ(resolved->name, "/Module.mod.lua");