Skip to content

Conversation

@JohnnyMorganz
Copy link
Owner

No description provided.

Introduces a plugin system that allows Luau scripts to transform source
code before type checking, with automatic position mapping for LSP features.

Key components:
- PluginRuntime: Sandboxed Luau VM execution with timeout support
- SourceMapping: Bidirectional position mapping from text edits
- PluginTextDocument: TextDocument subclass with transparent position mapping
- PluginManager: Orchestrates multiple plugins with overlap detection

Features:
- Plugins return text edits (not transformed source) for simpler API
- Multiple plugins receive original source; edits are combined
- Overlapping edits from different plugins are rejected with error
- transformSource is optional - plugins can load without implementing it
- Wall-clock timeout using Luau::TimeTrace::getClock()
- VS Code extension settings for plugin configuration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
@cloudflare-workers-and-pages
Copy link

cloudflare-workers-and-pages bot commented Jan 3, 2026

Deploying luau-lsp with  Cloudflare Pages  Cloudflare Pages

Latest commit: 61d9c8c
Status: ✅  Deploy successful!
Preview URL: https://c86c3057.luau-lsp.pages.dev
Branch Preview URL: https://plugins.luau-lsp.pages.dev

View logs

JohnnyMorganz and others added 4 commits January 3, 2026 18:44
Introduces a sandboxed filesystem API that allows plugins to read files
within the workspace:

- lsp.workspace.getRootUri() - get workspace root as Uri
- lsp.fs.readFile(uri) - read file contents (workspace-only)
- lsp.Uri.parse(str) / lsp.Uri.file(path) - create Uri objects
- Uri userdata with properties (scheme, path, fsPath) and methods
  (joinPath, toString)

Security: Only files within the workspace can be accessed. Controlled by
luau-lsp.plugins.fileSystem.enabled setting (default: false).

Also fixes lua_resume -> lua_pcall for transformSource calls to prevent
crashes when plugins error.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Exposes lsp.client.sendLogMessage(type, message) to plugins for sending
log messages to the LSP client. Message types are strings: "error",
"warning", "info", "log".

Also overrides the global print() function to redirect output to
sendLogMessage with "info" level, allowing familiar debugging patterns.

All messages are automatically prefixed with [Plugin {path}] for easy
identification in logs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Adds lsp.json.deserialize(string) function that parses JSON strings
and returns the corresponding Lua values. Supports all JSON types:
null, boolean, number, string, arrays (1-indexed), and objects.

Parse errors throw Lua errors with descriptive messages.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Register an LSPPlugin environment that provides type information for
the plugin Lua API (lsp.fs, lsp.client, lsp.workspace, lsp.json, Uri).
This enables autocomplete and type checking when developing plugins.

Key changes:
- Add PluginDefinitions with embedded type definitions using
  'declare extern type' syntax for Uri userdata
- Register LSPPlugin environment via registerBuiltinDefinition and
  applyBuiltinDefinitionToEnvironment pattern
- Implement getEnvironmentForModule to return LSPPlugin for plugin files
- Store plugin Uri (resolved) instead of string path for correct
  path comparison regardless of relative/absolute paths in settings
- Store definitionsSourceModule for documentation lookup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
@JohnnyMorganz
Copy link
Owner Author

Sample plugin implementation of #976 in its current state:

type SourcemapNode = {
	name: string,
	className: string,
	filePaths: { string }?,
	children: SourcemapNode
}

local function startsWith(str: string, prefix: string)
	return str:sub(1, #prefix) == prefix
end

local function endsWith(str: string, suffix: string)
	return str:sub(-#suffix) == suffix
end

local REQUIRE_PATTERN = [[require%("(%w+)"%)]]

local root = lsp.workspace.getRootUri()
local sourcemapContents = lsp.fs.readFile(root:joinPath("sourcemap.json"))
local sourcemap = lsp.json.deserialize(sourcemapContents) :: SourcemapNode

local function getScriptFilePath(node: SourcemapNode): string?
	if not node.filePaths then
		return nil
	end

	for _, path in node.filePaths do
		if endsWith(path, ".luau") or endsWith(path, ".lua") then
			return path
		end
	end

	return nil
end

-- Populate module names to file paths map
local moduleNamesToFilePaths = {}
local queue = {sourcemap}
while #queue > 0 do
	local node = table.remove(queue, 1)
	assert(node ~= nil)

	local filepath = getScriptFilePath(node)
	if filepath then
		moduleNamesToFilePaths[node.name] = root:joinPath(filepath)
	end

	if node.children then
		for _, child in node.children do
			table.insert(queue, child)
		end
	end
end

return {
	transformSource = function(source: string, context: PluginContext): { TextEdit }?
		local edits = {}
		local lines = string.split(source, '\n')

		for i, lineContents in lines do
			local lineNumber = i - 1

			-- Comment out the require override to prevent it from impacting intellisense
			if startsWith(lineContents, "local require = ") then
				table.insert(edits, {
					range = {
						start = { line = lineNumber, column = 0 },
						["end"] = { line = lineNumber, column = 0},
					},
					newText = "-- "
				})
			else
				local startIndex, endIndex = string.find(lineContents, REQUIRE_PATTERN)
				if startIndex then
					local REQUIRE_START_LEN = string.len('require("')
					local REQUIRE_END_LEN = string.len('")')

					local nameStartIndex = startIndex + REQUIRE_START_LEN
					local nameEndIndex = endIndex - REQUIRE_END_LEN

					local requireName = lineContents:sub(nameStartIndex, nameEndIndex)

					if moduleNamesToFilePaths[requireName] then
						table.insert(edits, {
							range = {
								-- column is 0 indexed
								start = { line = lineNumber, column = nameStartIndex - 1 },
								["end"] = { line = lineNumber, column = nameEndIndex },
							},
							newText = moduleNamesToFilePaths[requireName].fsPath
						})
					end
				end
			end
		end

		return edits
	end,
}

JohnnyMorganz and others added 2 commits January 3, 2026 22:54
Use lua_Integer instead of int64_t when pushing integers to avoid
C4244 warning about narrowing conversion on Windows.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Adds a new LSP request `luau-lsp/debug/viewInternalSource` that returns
the internal source representation of a document after plugin
transformations have been applied. This helps plugin developers debug
how their plugins modify source code before type checking.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants