Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions examples/transformee.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
local x = math.sqrt(-1)
-- x ~= x should become math.isnan(x)
local b = x ~= x
88 changes: 88 additions & 0 deletions examples/transformer.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
local parser = require("@std/syntax/parser")
local printer = require("@std/syntax/printer")
local visitor = require("@std/syntax/visitor")
local luau = require("@lute/luau")

function transformation(ctx)
local parsedResult = parser.parsefile(ctx.source)

local v = visitor.createVisitor()

v.visitBinary = function(node: luau.AstExprBinary)
if node.operator.text ~= "~=" then
return true
end

if node.lhsoperand.tag ~= "local" or node.rhsoperand.tag ~= "local" then
return true
end

if node.lhsoperand.token.text ~= node.rhsoperand.token.text then
return true
end

local operand: luau.AstExprLocal = node.lhsoperand
operand.token.leadingtrivia = {}
operand.token.trailingtrivia = {}
local openingPosition: luau.Position = operand.token.position;

-- transform node into an AstExprCall
(node :: any).operator = nil
(node :: any).lhsoperand = nil
(node :: any).rhsoperand = nil

((node :: any) :: luau.AstExprCall).tag = "call"

local func: luau.AstExpr = {
tag = "global",
name = {
leadingtrivia = {},
position = openingPosition,
text = "math.isnan",
trailingtrivia = {},
},
}
((node :: any) :: luau.AstExprCall).func = func

local openparens: luau.Token<"("> = {
leadingtrivia = {},
position = { line = openingPosition.line, column = openingPosition.column + #"math.isnan" },
text = "(",
trailingtrivia = {},
}
((node :: any) :: luau.AstExprCall).openparens = openparens

local arguments: luau.Punctuated<luau.AstExpr> = { { node = operand } }
((node :: any) :: luau.AstExprCall).arguments = arguments

local closeparens: luau.Token<")"> = {
leadingtrivia = {},
position = {
line = openingPosition.line,
column = openingPosition.column + #"math.isnan(" + #operand.token.text,
},
text = ")",
trailingtrivia = {},
}
((node :: any) :: luau.AstExprCall).closeparens = closeparens;

((node :: any) :: luau.AstExprCall).self = false

local argLocation: luau.Location = {
begin = { line = openingPosition.line, column = openingPosition.column + #"math.isnan(" },
["end"] = {
line = openingPosition.line,
column = openingPosition.column + #"math.isnan(" + #operand.token.text,
},
}
((node :: any) :: luau.AstExprCall).argLocation = argLocation

return false
end

visitor.visitBlock(parsedResult.root, v)

return printer.printfile(parsedResult)
end

return transformation
164 changes: 164 additions & 0 deletions lute/cli/commands/transform/init.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
local fs = require("@lute/fs")
local luau = require("@lute/luau")
local pathLib = require("@std/path")

local arguments = require("@self/lib/arguments")
local files = require("@self/lib/files")
local types = require("@self/lib/types")

local function exhaustiveMatch(value: never): never
error(`Unknown value in exhaustive match: {value}`)
end

local function loadFromPath(path: string): ...any
local migrationHandle = fs.open(path, "r")
assert(migrationHandle ~= nil, `Failed to open migration file at '{path}'`)

local migrationBytecode = luau.compile(fs.read(migrationHandle))

fs.close(migrationHandle)

return luau.load(migrationBytecode, path)()
end

local function loadMigration(path: string): types.Migration
local success, loaded = pcall(loadFromPath, path)
assert(success, `{path} failed to require: {loaded}`)
assert(loaded, `{path} is missing a return`)

if typeof(loaded) == "function" then
local migration = {}
migration.transform = loaded
return migration :: types.Migration -- FIXME: Luau
elseif typeof(loaded) == "table" then
-- FIXME(Luau): https://github.com/luau-lang/luau/issues/1803 for the type assertions
assert(
typeof((loaded :: any).transform) == "function",
`{path} must contain a 'transform' property of type function`
)
if (loaded :: any).options then
assert(typeof(loaded.options) == "table", `Expected return of {path}.options to be a table`)
for _, option in loaded.options do
assert(typeof(option.name) == "string", `Expected option.name to be a string`)
assert(
option.kind == "string" or option.kind == "boolean",
`Expected option.kind to be either 'string' or 'boolean', got {option.kind}`
)
end
end
if (loaded :: any).initialize then
assert(typeof(loaded.initialize) == "function", `Expected {path}.initialize to be a function`)
end
return loaded :: types.Migration
else
error(`Expected '{path}' to return a function or table, got {typeof(loaded)}`)
end
end

local function processMigrationOptions(migration: types.Migration, options: { [string]: string }): { [string]: any }
if migration.options == nil then
return table.freeze({}) :: any -- FIXME(Luau): https://github.com/luau-lang/luau/issues/1802
end

local processedOptions: { [string]: string | boolean } = {}

for _, option in migration.options do
local value = options[option.name]
if value then
if option.kind == "boolean" then
local lowered = value:lower()
assert(
lowered == "true" or lowered == "false",
`Option '--{option.name}' expects a boolean value 'true' or 'false'`
)
processedOptions[option.name] = lowered == "true"
elseif option.kind == "string" then
processedOptions[option.name] = value
else
exhaustiveMatch(option.kind)
end
else
assert(option.default ~= nil, `Missing required option '--{option.name}'`)
processedOptions[option.name] = option.default
end
end

return table.freeze(processedOptions)
end

local function applyMigration(
migration: types.Migration,
paths: { pathLib.path },
options: { [string]: string | boolean },
dryRun: boolean
)
local deletedPaths = {}

if migration.initialize then
print("Initializing migration")
local ctx = {
options = options,
}
migration.initialize(ctx)
end

for _, pathObj in paths do
local pathStr = pathLib.format(pathObj)

print(`Applying to {pathStr}`)

local source = fs.readfiletostring(pathStr)

local ctx = {
path = pathStr,
source = source,
options = options,
}

-- TODO: should we wrap in pcall? For now we don't do this to preserve stack trace
local result = migration.transform(ctx)

assert(typeof(result) == "string", `Transformed expected to return string, but got {typeof(result)}`)

if result == types.DELETION_MARKER then
print(`Marking {pathStr} for deletion`)
table.insert(deletedPaths, pathStr)
elseif result ~= source and not dryRun then
fs.writestringtofile(pathStr, result)
end
end

if not dryRun then
for _, pathStr in deletedPaths do
print(`Deleting {pathStr}`)
fs.remove(pathStr)
end
end
end

local function main(...: string)
local args = arguments.parse({ ... })

if args.dryRun then
print("Executing in dry run mode")
end

print(`Loading migration '{args.migrationPath}'`)
local migration = loadMigration(args.migrationPath)

print("Finding source files") -- todo: might type error
local files = files.getSourceFiles(args.filePaths)
if #files == 0 then
error("error: no source files provided")
end

print("Processing migration options")
local migrationOptions = processMigrationOptions(migration, args.migrationOptions)

print("Applying migration")
applyMigration(migration, files, migrationOptions, args.dryRun)

print(`Processed {#files} files!`)
end

main(...)
69 changes: 69 additions & 0 deletions lute/cli/commands/transform/lib/arguments.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
local string = require("@std/string")

type Config = {
--- Prevent making changes to files
dryRun: boolean,
--- Path to the migration module to execute
migrationPath: string,
--- Configuration flags to pass to the migration
migrationOptions: { [string]: string },
--- List of file paths to process
--- Note: no directory traversal or filtering has been applied on this list
filePaths: { string },
}

local function parse(arguments: { string }): Config
-- TODO: replace with proper CLI system
-- For now, first argument is the transformer script, whilst the rest are either flags or files to apply it on

if #arguments < 2 then
error("Usage: lute transform <transformer script> [options...] <files...>")
end

local config = {
dryRun = false,
migrationPath = nil :: string?,
migrationOptions = {},
filePaths = {},
}

local i = 1
while i <= #arguments do
local argument = arguments[i]

if string.startswith(argument, "--") then
local name = string.removeprefix(argument, "--")

if config.migrationPath == nil then
-- Options before the codemod file are parsed as options for the tool itself
if name == "dry-run" then
config.dryRun = true
else
error(`Unknown flag '--{name}'`)
end
else
i += 1
assert(i <= #arguments, `Missing value for '{name}'`)
local value = arguments[i]

config.migrationOptions[name] = value
end
else
if config.migrationPath == nil then
config.migrationPath = argument
else
table.insert(config.filePaths, argument)
end
end

i += 1
end

assert(config.migrationPath, "ASSERTION FAILED: codemodPath ~= nil")

return config
end

return {
parse = parse,
}
68 changes: 68 additions & 0 deletions lute/cli/commands/transform/lib/files.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
local fs = require("@lute/fs")
local process = require("@lute/process")

local ignore = require("./ignore")
local path = require("@std/path")
local string = require("@std/string")

local function extend<T>(tbl: { T }, other: { T })
for _, entry in other do
table.insert(tbl, entry)
end
end

local function traverseDirectoryRecursive(directory: path.path, ignoreResolver: ignore.IgnoreResolver): { path.path }
local results = {}

for _, entry in fs.listdir(path.format(directory)) do
local fullPath = path.join(directory, entry.name)

if entry.type == "dir" then
if ignoreResolver:isIgnoredDirectory(fullPath) then
print("Skipping", fullPath)
continue
end

extend(results, traverseDirectoryRecursive(fullPath, ignoreResolver))
else
-- TODO: allow customisation of the filter when traversing a directory. e.g., globs? file endings? ignore paths?
if string.endswith(entry.name, ".luau") or string.endswith(entry.name, ".lua") then
if ignoreResolver:isIgnoredFile(fullPath) then
print("Skipping", fullPath)
continue
end
table.insert(results, fullPath)
end
end
end

return results
end

local function getSourceFiles(paths: { string }): { path.path }
local files = {}

local ignoreResolver = ignore.new()

for _, filepath in paths do
local pathObj = path.parse(filepath)

if not path.isabsolute(pathObj) then
pathObj = path.join(process.cwd(), pathObj)
end

filepath = path.format(pathObj)
print(filepath)
if fs.type(filepath) == "dir" then
extend(files, traverseDirectoryRecursive(pathObj, ignoreResolver))
else
table.insert(files, pathObj)
end
end

return files
end

return {
getSourceFiles = getSourceFiles,
}
Loading