diff --git a/.vscode/fa-plugin.lua b/.vscode/fa-plugin.lua deleted file mode 100644 index 331196cddb..0000000000 --- a/.vscode/fa-plugin.lua +++ /dev/null @@ -1,95 +0,0 @@ -local StringFind = string.find -local StringGmatch = string.gmatch -local StringMatch = string.match - -local IoOpen = io.open - -print('[FA Plugin] FA plugin starting...') - --- repository to use for hook file intellisense. Same as the one in the dev init file. -local initPath = 'C:/ProgramData/FAForever/bin/init_local_development.lua' - -local initFile, err = IoOpen(initPath, 'r') -local locationOfRepository -if initFile then - for line in initFile:lines() do - local start, finish, repoPath = StringFind(line, 'locationOfRepository = [\'"]([%w:/]+)[\'"]') - if repoPath then - locationOfRepository = repoPath - print('[FA Plugin] Using repository path: ' .. repoPath) - break - end - end - if not locationOfRepository then - print('[FA Plugin] Could not find repository path in init file:' .. initPath) - end -else - print('[FA plugin] Could not open init file: ' .. tostring(initPath) - .. '\n[FA plugin] Error message: ' .. tostring(err) - ) -end - ----@class diff ----@field start integer # The number of bytes at the beginning of the replacement ----@field finish integer # The number of bytes at the end of the replacement ----@field text string # What to replace - ----@param uri string # The uri of file ----@param text string # The content of file ----@return nil|diff[] -function OnSetText(uri, text) - ---@type diff[] - local diffs = {} - -- Change `#` (valid Supcom Lua comment) to `--` (valid in language server's lua) - -- get first line - local pos = StringMatch(text, '^%s*()#') - if pos ~= nil then - diffs[#diffs + 1] = { - start = pos, - finish = pos, - text = '--' - } - end - -- get any lines after that - for pos in StringGmatch(text, '\r\n%s*()#') do - diffs[#diffs + 1] = { - start = pos, - finish = pos, - text = '--' - } - end - -- Change `{&1 &1}` (valid Supcom Lua) to `{}` (valid in language server's lua) - -- It's changed inside comments too, but lua regex doesn't make that easy to fix. - for start, finish in StringGmatch(text, '(){&%d+ &%d+}()') do - diffs[#diffs + 1] = { - start = start, - finish = finish - 1, - text = '{}', - } - end - - if locationOfRepository then - -- prepend the content of hooked files just like the game would - local first, last, hookDir = StringFind(uri, '/hook(/.*%.lua)') - if hookDir then - local repoFile = IoOpen(locationOfRepository .. hookDir) - if repoFile then - local repoContent = repoFile:read("*a") - repoFile:close() - -- all diagnostics registered in /server/script/proto/diagnostic.lua - local diagnostics = 'exp-in-action,unused-local,unused-function,unused-label,unused-vararg,trailing-space,redundant-return,empty-block,code-after-break,unreachable-code,redundant-value,unbalanced-assignments,redundant-parameter,missing-parameter,missing-return-value,redundant-return-value,missing-return,need-check-nil,undefined-field,cast-local-type,assign-type-mismatch,param-type-mismatch,cast-type-mismatch,return-type-mismatch,duplicate-doc-alias,undefined-doc-class,undefined-doc-name,circle-doc-class,undefined-doc-param,duplicate-doc-param,doc-field-no-class,duplicate-doc-field,unknown-diag-code,unknown-cast-variable,unknown-operator,codestyle-check,spell-check,newline-call,newfield-call,ambiguity-1,count-down-loop,different-requires,await-in-sync,not-yieldable,no-unknown,redefined-local,undefined-global,global-in-nil-env,lowercase-global,undefined-env-child,duplicate-index,duplicate-set-field,close-non-object,deprecated,discard-returns,unicode-name' - local diagnosticDisable = '---@diagnostic disable:' .. diagnostics .. '\n' - local diagnosticEnable = '\n---@diagnostic enable:' .. diagnostics .. '\n' - diffs[#diffs + 1] = { - start = 1, - finish = 1, - text = diagnosticDisable .. repoContent .. diagnosticEnable .. text:sub(1, 1) - } - end - end - end - - return diffs -end - -print('[FA Plugin] FA plugin started') diff --git a/.vscode/settings.json b/.vscode/settings.json index ad1eb6ad34..aea50805f5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,9 +10,10 @@ "Lua.telemetry.enable": false, "Lua.workspace.checkThirdParty": false, "Lua.runtime.path": ["/?"], - "Lua.runtime.plugin": ".vscode/fa-plugin.lua", + "Lua.runtime.plugin": "./lua-ls-addon/plugin.lua", "Lua.workspace.ignoreDir": [ ".vscode/", + "lua-ls-addon/", "loc/", "*.bp", "lua/ui/lobby/changelog/generated/", @@ -31,17 +32,9 @@ "Lua.completion.autoRequire": false, "Lua.diagnostics.globals": [ "ScenarioInfo", + "Scenario", "__moduleinfo", - "sortedpairs", - "sort_by", - "sort_down_by", - "safecall", - "printField", "__diskwatch", - "repr", - "repru", - "reprs", - "reprsl", "__language", "__installedlanguages", "__blueprints" @@ -492,5 +485,9 @@ "ZockyZock", "zthuee", "Zulip" + ], + "Lua.diagnostics.disable": [ + "inject-field", + "unsupport-symbol" ] } diff --git a/engine/Library.lua b/engine/Library.lua index a77dc167d0..1126edfe54 100644 --- a/engine/Library.lua +++ b/engine/Library.lua @@ -130,6 +130,19 @@ end function table.empty2(table) end +---@generic T +---@param list any +---@param callback fun(key: integer, value: any):T|nil +---@return T|nil +function table.foreachi(list, callback) end + +---@generic T +---@param list T[] +---@return integer +---@nodiscard +function table.getn(list) +end + --- Returns the size of a list ---@param list table ---@return integer diff --git a/lua-ls-addon/README.md b/lua-ls-addon/README.md new file mode 100644 index 0000000000..70b7d099a9 --- /dev/null +++ b/lua-ls-addon/README.md @@ -0,0 +1,52 @@ +# fa-lua-addon + +A [lua-language-server](https://github.com/LuaLS/lua-language-server) workspace plugin that +teaches LuaLS to read Supreme Commander: Forged Alliance's Lua dialect. SupCom actually runs a +GPG-modified **Lua 5.0** - confirmed by [FAForever/lua-lang](https://github.com/FAForever/lua-lang), +a buildable reference implementation of it - and that modified dialect bends the language in +several ways: a preprocessor comment marker, table-size hints, an OOP call convention, an +implicit module system - none of it valid standard Lua. This addon rewrites each quirk into +something LuaLS already understands, in the source text, before the real parser ever sees it. +It's a from-scratch replacement for the outdated +[FAForever/fa-lua-language-server](https://github.com/FAForever/fa-lua-language-server) fork, +built as a plugin against mainline LuaLS instead of a fork of it. + +The addon still configures `Lua.runtime.version = 'Lua 5.1'`, not 5.0: LuaLS doesn't support a +5.0 runtime at all, and 5.1 is both the earliest version it does support and the closest one to +SupCom's actual dialect. + +## Setup + +Point a workspace's `.vscode/settings.json` at `plugin.lua`: + +```json +"Lua.runtime.plugin": "../fa-lua-addon/plugin.lua" +``` + +`config.lua` is picked up automatically by LuaLS's third-party config system for any matching +workspace - no separate wiring needed. + +## How it works + +`plugin.lua` runs a chain of scanners over every file's text and merges their results into one +diff list for LuaLS to parse instead of the raw source. Each scanner is a single-purpose module; +its own file header explains the specific problem it solves and why, in more depth than fits +here. + +| File | Solves | +|---|---| +| `hash-comments.lua` | `#` as a comment marker | +| `table-hints.lua` | `{&N &N}` table-preallocation hints | +| `class-support.lua` | `Class()`-family OOP sugar | +| `for-in-pairs.lua` | untyped bare-table `for` loops | +| `export-env.lua` | FA's implicit module system (bare top-level exports, forward references) | +| `hook-files.lua` | SupCom mod "hook" file target detection | +| `config.lua` | workspace settings: non-standard tokens, engine globals, require/path conventions | +| `plugin.lua` | orchestrates the above, and safely merges their diffs | + +## Design constraints + +This is a set of heuristic text scanners, not a real parser - each one documents its own known +false-positive/false-negative edge cases and the reasoning behind them. Where a rewrite carries +real risk (e.g. `export-env.lua`'s reference rewriting under name shadowing), the trade-off is +verified against the full FA source tree and documented in that file, not just asserted. diff --git a/lua-ls-addon/class-support.lua b/lua-ls-addon/class-support.lua new file mode 100644 index 0000000000..35dbe6bde1 --- /dev/null +++ b/lua-ls-addon/class-support.lua @@ -0,0 +1,716 @@ +-- Makes FA's `Class()`-family OOP sugar readable as a real class to LuaLS, by stripping the +-- wrapper call down to a plain table and adding the `---@class` doc it implies. +-- +-- FA classes are defined as `Name = ClassFn(Base1, Base2, ...) { specs }` (or the no-base +-- `Name = ClassFn { specs }` form), per lua/system/class.lua. Even though class.lua carries +-- `---@generic T / @param specs T / @return T` annotations aimed at propagating the spec +-- table's fields through the call, LuaLS's class-doc-to-declaration binding only reliably +-- merges fields from a table constructor assigned *directly* - not one arriving through an +-- opaque (if generically-annotated) function call. So we strip the `ClassFn(...)` wrapper +-- text entirely, leaving a plain `Name = { specs }`, and inject a `---@class Name: Bases` +-- doc comment when the file doesn't already have one for that name - both are things LuaLS +-- already handles natively and reliably once the call is out of the way. Blanking the wrapper +-- also erases the only reference to any bare-identifier base argument (`Class(NullShell) {}`), +-- which would otherwise make its `local NullShell = ...` declaration look unused - a harmless +-- `local _ = NullShell` keep-alive line is inserted alongside to prevent that (see the +-- comment at its call site for why `_` specifically, and why it reads `M.Name` instead of the +-- bare name when the base is one of the file's own exports). A base that's itself a local +-- import-alias with a different name than what it holds (`local Foo = Module.Bar`, then +-- `Class(Foo)`) gets resolved to `Bar` for the doc specifically - see buildReferenceMaps. A +-- NESTED class (one inside another class's own spec table, e.g. a state override) is named +-- `_` rather than the bare field name, matching FA's own +-- hand-written convention (`DefaultProjectileWeapon_IdleState`) - this both avoids every file's +-- undocumented same-named nested state (`IdleState`, `Start`, ...) merging into one shared +-- global type, and lets an `Owner.Field` base reference (`State(DefaultBeamWeapon.IdleState)`, +-- FA's own "override the parent's state" pattern) resolve to exactly that name - see +-- sanitizeBase/buildReferenceMaps. +-- +-- Known limitations: only the `Name = ClassFn(...) { ... }` assignment shape is handled +-- (bare identifier target only, no dotted targets like `t.Name = ...`); an anonymous +-- `ClassFn(...) {}` with nothing assignable to its left is left untouched. + +local M = {} + +local CLASS_FUNCTIONS = { + Class = true, ClassSimple = true, ClassUI = true, ClassShield = true, + ClassProjectile = true, ClassDummyProjectile = true, ClassUnit = true, + ClassDummyUnit = true, ClassWeapon = true, ClassTrashBag = true, State = true, +} + +local BLOCK_OPEN = { ['function'] = true, ['if'] = true, ['do'] = true, ['repeat'] = true } +local BLOCK_CLOSE = { ['end'] = true, ['until'] = true } +local LOOP_HEADER = { ['for'] = true, ['while'] = true } +local KEYWORDS = { + ['and'] = true, ['break'] = true, ['do'] = true, ['else'] = true, ['elseif'] = true, + ['end'] = true, ['false'] = true, ['for'] = true, ['function'] = true, ['if'] = true, + ['in'] = true, ['local'] = true, ['nil'] = true, ['not'] = true, ['or'] = true, + ['repeat'] = true, ['return'] = true, ['then'] = true, ['true'] = true, ['until'] = true, + ['while'] = true, +} + +---@param text string +---@param i integer position of '[' +---@param n integer +---@return integer? closeEnd +local function skipLongBracket(text, i, n) + local eqs = text:match('^%[(=*)%[', i) + if not eqs then + return nil + end + local _, closeEnd = text:find(']' .. eqs .. ']', i + 2 + #eqs, true) + return closeEnd and (closeEnd + 1) or (n + 1) +end + +---@param text string +---@param j integer index to start skipping a string at (points at the closing quote's opener) +---@param n integer +---@param quote string +---@return integer position right after the closing quote +local function skipString(text, j, n, quote) + j = j + 1 + while j <= n do + local cj = text:sub(j, j) + if cj == '\\' then + j = j + 2 + elseif cj == quote then + return j + 1 + else + j = j + 1 + end + end + return j +end + +--- Finds the bare identifier immediately assigned right before `pos` (i.e. `Name =` ending +--- right at `pos`, skipping whitespace, and rejecting `==`/`~=`/`<=`/`>=`). +---@param text string +---@param pos integer +---@return integer? nameStart +---@return string? name +local function findPrecedingName(text, pos) + local j = pos - 1 + while j >= 1 and text:sub(j, j):match('%s') do + j = j - 1 + end + if j < 1 or text:sub(j, j) ~= '=' then + return nil + end + j = j - 1 + if j >= 1 and text:sub(j, j):match('[=~<>]') then + return nil + end + while j >= 1 and text:sub(j, j):match('%s') do + j = j - 1 + end + local nameEnd = j + while j >= 1 and text:sub(j, j):match('[%w_]') do + j = j - 1 + end + local nameStart = j + 1 + if nameStart > nameEnd or not text:sub(nameStart, nameStart):match('[%a_]') then + return nil + end + return nameStart, text:sub(nameStart, nameEnd) +end + +--- If `local` immediately precedes `nameStart` as a whole word (i.e. `local Name = ...`), +--- returns the position of that `local` instead - the TRUE start of the statement. Otherwise +--- returns `nameStart` unchanged. Needed because findPrecedingName only ever returns the bare +--- name's own position, never accounting for an optional `local` prefix - inserting a new +--- statement right at `nameStart` when `local` precedes it produces `local Name = ...`, +--- i.e. a literal double `local` and a real syntax error (reproduced on +--- lua/maui/layouthelpers.lua's `local LayouterAttributeEditor = Class(LayouterAttributeFont) +--- {` - an internal, local-scoped helper class, not FA's usual bare-global export). +---@param text string +---@param nameStart integer +---@return integer +local function findStatementStart(text, nameStart) + local j = nameStart - 1 + while j >= 1 and text:sub(j, j):match('%s') do + j = j - 1 + end + if j >= 5 and text:sub(j - 4, j) == 'local' then + local before = j - 5 + if before < 1 or not text:sub(before, before):match('[%w_]') then + return j - 4 + end + end + return nameStart +end + +--- Walks backward from `pos` through a contiguous run of `--` comment lines (stopping at a +--- blank line or real code - same walk as hasImmediatePrecedingClassDoc below), returning the +--- start position of the topmost comment line in that run, or `pos` itself if no such block +--- immediately precedes it. Lets a synthetic statement be inserted BEFORE any doc-comment block +--- (hand-written or injected) instead of between it and the statement it documents, which would +--- break a LuaDoc annotation's "immediately precedes" binding requirement - see the base-class +--- keep-alive in stripWrappers below for why that matters here. +---@param text string +---@param pos integer +---@return integer +local function findCommentBlockStart(text, pos) + local boundary = pos + -- `pos` itself may be indented (any nested class field, e.g. ` RackSalvoChargeState = + -- State {`) - `pos - 2` alone would land inside THAT indentation rather than the actual + -- previous line, misreading it as blank. Find pos's own line start first (tolerating its + -- indentation), then step back over the newline before it to reach the true previous line. + local ownLineStart = text:sub(1, pos - 1):match('.*\n()') or 1 + local lineEnd = ownLineStart - 2 + while lineEnd >= 1 do + local lineStart = text:sub(1, lineEnd):match('.*\n()') or 1 + local line = text:sub(lineStart, lineEnd) + local trimmed = line:match('^%s*(.-)%s*$') + if trimmed == '' then + break + elseif trimmed:match('^%-%-') then + boundary = lineStart + lineEnd = lineStart - 2 + else + break + end + end + return boundary +end + +--- Checks whether a `---@class` line (any name) already sits in the contiguous block of +--- comment lines immediately above `pos` - i.e. whether *this* statement already has a class +--- doc, regardless of what name it uses. FA often names the doc differently than the +--- assigned variable on purpose (e.g. `---@class EasyAIBrain: ...` directly above +--- `AIBrain = Class(...) {...}`, specifically so multiple files' generic `AIBrain` locals +--- don't collide globally) - relying only on "does a doc named after the variable exist +--- anywhere in the file" misses that and injects a second, conflicting same-named class that +--- merges fields across every file that hits this pattern. Walks backward line by line: a +--- blank line or real code stops the scan (no doc found); a comment line that isn't +--- `---@class` keeps walking (covers `---@field`/description lines that sit between +--- `---@class` and the code, as in the real examples above). +---@param text string +---@param pos integer +---@return boolean +local function hasImmediatePrecedingClassDoc(text, pos) + -- Same indentation pitfall as findCommentBlockStart above: find pos's own line start first + -- (tolerating its indentation), then step back over the newline before it to reach the true + -- previous line, instead of assuming pos sits at column 1. Confirmed real: + -- lua/sim/weapons/DefaultProjectileWeapon.lua's ` RackSalvoChargeState = State {` (an + -- indented field, already hand-annotated with `---@class + -- DefaultProjectileWeapon_RackSalvoChargeState : DefaultProjectileWeapon, State` directly + -- above it) - assuming pos-2 landed on the doc line, when it actually landed inside + -- RackSalvoChargeState's own leading indentation, misread as a blank line. That made this + -- return false, so stripWrappers injected a second, competing, base-less `---@class + -- RackSalvoChargeState` doc that (being textually closer) won the LuaDoc binding over the + -- real one - losing the `: State` relationship entirely and breaking `ChangeState(self, + -- self.RackSalvoChargeState)`'s type check ("Cannot assign RackSalvoChargeState to parameter + -- State"). Top-level (unindented) statements were never affected - there, pos already sits + -- at column 1, so ownLineStart below equals pos and this is a no-op. + local ownLineStart = text:sub(1, pos - 1):match('.*\n()') or 1 + local lineEnd = ownLineStart - 2 + while lineEnd >= 1 do + local lineStart = text:sub(1, lineEnd):match('.*\n()') or 1 + local line = text:sub(lineStart, lineEnd) + local trimmed = line:match('^%s*(.-)%s*$') + if trimmed == '' then + return false + elseif trimmed:match('^%-%-%-@class') then + return true + elseif trimmed:match('^%-%-') then + lineEnd = lineStart - 2 + else + return false + end + end + return false +end + +--- Splits a `(...)` argument-list body on top-level commas (paren/string aware). +---@param argsText string +---@return string[] +local function splitTopLevelArgs(argsText) + local parts = {} + local n = #argsText + local depth = 0 + local start = 1 + local j = 1 + while j <= n do + local c = argsText:sub(j, j) + if c == '"' or c == "'" then + j = skipString(argsText, j, n, c) - 1 + elseif c == '(' then + depth = depth + 1 + elseif c == ')' then + depth = depth - 1 + elseif c == ',' and depth == 0 then + parts[#parts + 1] = argsText:sub(start, j - 1):match('^%s*(.-)%s*$') + start = j + 1 + end + j = j + 1 + end + local last = argsText:sub(start):match('^%s*(.-)%s*$') + if last ~= '' then + parts[#parts + 1] = last + end + return parts +end + +---@param s string +---@return boolean +local function isSimpleTypeName(s) + if s == '' then + return false + end + for segment in (s .. '.'):gmatch('([^.]*)%.') do + if not segment:match('^[%a_][%w_]*$') then + return false + end + end + return true +end + +--- Returns the identifier before the first `.` in a dotted chain (or the whole string, if +--- there's no dot) - e.g. `"Shield.OnState"` -> `"Shield"`. Used to decide whether a base's +--- keep-alive (see stripWrappers below) needs an `M.` prefix: only the base's own OUTERMOST +--- name can possibly be one of this file's own exports. +---@param s string +---@return string +local function firstSegment(s) + local dot = s:find('.', 1, true) + if dot then + return s:sub(1, dot - 1) + end + return s +end + +--- One pass, extending the original alias-only scan, collecting THREE maps used to resolve a +--- Class()/State() base argument (see sanitizeBase below): +--- +--- `aliasMap`: TOP-LEVEL `local Alias = RHS` declarations where RHS is either `import(...).Name` +--- or a bare dotted-identifier chain not immediately followed by `(` (a real reference, not a +--- function call), resolved to a canonical name that differs from Alias's own - i.e. cases where +--- FA renamed an import at the point of use. Depth-tracked (block keywords + braces, same shape +--- as export-env.lua's own scan) so nested function-body locals - which commonly look like +--- `local brain = self` or `local bp = unit.Enhancements` and have nothing to do with file-level +--- import aliasing - are never mistaken for one; Lua keywords (`local dialog = false`) are +--- excluded from being treated as a type name either as the alias or the resolved name. +--- Confirmed real on units/XSL0001/XSL0001_script.lua, where `local +--- SDFChronotronOverChargeCannonWeapon = SWeapons.SDFChronotronCannonOverChargeWeapon` is later +--- used as a Class() base by its own (differently-spelled) name - the real, registered type is +--- `SDFChronotronCannonOverChargeWeapon` (confirmed: lua/seraphimweapons.lua's own export uses +--- that exact spelling), so injecting the local's own name into the doc produces "Undefined +--- class". +--- +--- `moduleImports`: names bound via `local X = import(path)` with NO trailing `.Name` - a WHOLE +--- MODULE table (e.g. `local SWeapon = import("/lua/seraphimweapons.lua")`). Distinguishes a +--- module-table reference (`X.Field` means "the module's own top-level export Field") from a +--- class reference below. +--- +--- `classOwners`: names bound via `local X = import(path).Name` (a SINGLE class import, e.g. +--- `local DefaultProjectileWeapon = import(...).DefaultProjectileWeapon`) OR a bare top-level +--- `X = ClassFn(...) { ... }` defined in this same file (a self-reference, e.g. `Shield = +--- ClassShield(...) { ... OnState = State(Shield.OnState) {...} ... }` in lua/shield.lua, +--- confirmed real). For these, `X.Field` means "X's own nested Field state" - see sanitizeBase. +---@param text string +---@return table aliasMap +---@return table moduleImports +---@return table classOwners +local function buildReferenceMaps(text) + local n = #text + local i = 1 + local depth = 0 + local loopHeaderDepth = 0 + local aliasMap = {} + local moduleImports = {} + local classOwners = {} + while i <= n do + local c = text:sub(i, i) + if c == '-' and text:sub(i, i + 1) == '--' then + local afterDashes = i + 2 + local eqs = text:match('^%[(=*)%[', afterDashes) + if eqs then + local _, closeEnd = text:find(']' .. eqs .. ']', afterDashes + 2 + #eqs, true) + i = closeEnd and (closeEnd + 1) or (n + 1) + else + local nl = text:find('\n', i, true) + i = nl and (nl + 1) or (n + 1) + end + elseif c == '"' or c == "'" then + i = skipString(text, i, n, c) + elseif c == '[' then + i = skipLongBracket(text, i, n) or (i + 1) + elseif c:match('[%a_]') then + local wordStart = i + local _, e, word = text:find('^([%a_][%w_]*)', i) + i = e + 1 + + if word == 'function' then + depth = depth + 1 + elseif BLOCK_OPEN[word] then + depth = depth + 1 + if word == 'do' and loopHeaderDepth > 0 then + loopHeaderDepth = loopHeaderDepth - 1 + end + elseif BLOCK_CLOSE[word] then + depth = math.max(0, depth - 1) + elseif LOOP_HEADER[word] then + loopHeaderDepth = loopHeaderDepth + 1 + elseif word == 'local' and depth == 0 and loopHeaderDepth == 0 then + local _, _, aliasName = text:find('^%s*([%a_][%w_]*)%s*=%s*', i) + if aliasName then + local _, afterEqEnd = text:find('^%s*[%a_][%w_]*%s*=%s*', i) + local afterEq = afterEqEnd + 1 + local canonical = nil + if text:match('^import%s*%b()', afterEq) then + local importName = text:match('^import%s*%b()%.([%a_][%w_]*)', afterEq) + if importName then + canonical = importName + classOwners[aliasName] = true + else + moduleImports[aliasName] = true + end + else + local rhs = text:match('^[%a_][%w_%.]*', afterEq) + if rhs and isSimpleTypeName(rhs) and not KEYWORDS[firstSegment(rhs)] then + local rhsEnd = afterEq + #rhs + local _, afterSpaceEnd = text:find('^%s*', rhsEnd) + local afterSpace = afterSpaceEnd + 1 + if text:sub(afterSpace, afterSpace) ~= '(' then + canonical = rhs:match('([%a_][%w_]*)$') + end + end + end + if canonical and KEYWORDS[canonical] then + canonical = nil + end + if canonical and canonical ~= aliasName then + aliasMap[aliasName] = canonical + end + end + elseif CLASS_FUNCTIONS[word] and depth == 0 and loopHeaderDepth == 0 then + -- A bare top-level `X = ClassFn(...) { ... }` in THIS file self-defines a class + -- owner - same word/preceding-name checks stripWrappers itself uses below. + local j = wordStart - 1 + while j >= 1 and text:sub(j, j):match('%s') do + j = j - 1 + end + local prevChar = j >= 1 and text:sub(j, j) or nil + if prevChar ~= '.' and prevChar ~= ':' then + local _, className = findPrecedingName(text, wordStart) + if className then + classOwners[className] = true + end + end + end + elseif c == '{' then + depth = depth + 1 + i = i + 1 + elseif c == '}' then + depth = math.max(0, depth - 1) + i = i + 1 + else + i = i + 1 + end + end + return aliasMap, moduleImports, classOwners +end + +--- Classifies a raw `Class(...)` base-argument's source text into something usable in a +--- `---@class X: Base` doc. Only a bare type-name (dotted-identifier chain, e.g. +--- `moho.unit_methods`) is a valid LuaLS type reference; anything else - a call, indexing on a +--- call result, etc. - isn't, and dropping the raw text in verbatim (e.g. +--- `import("/lua/sim/prop.lua").Prop`) produces a malformed reference LuaLS reports as an +--- undefined class (confirmed: env/*/Props/*/*_script.lua's recurring `Class(import(path).Name)` +--- pattern). That specific shape is common enough to special-case: extract just `Name` and use +--- that - correct whenever the target file kept its default, unrenamed class name, which fails +--- safe even when wrong (a missing inherited-member completion, not a new diagnostic). Anything +--- else unrecognised is dropped rather than guessed at. +--- +--- `aliasMap` (from buildReferenceMaps) is checked first: if `baseText` is itself a local alias +--- for something with a different canonical name, that canonical name is used instead - see +--- buildReferenceMaps' own comment for the real example this fixes. +--- +--- Otherwise, if `baseText` is a dotted `Owner.Field` chain, `moduleImports`/`classOwners` (also +--- from buildReferenceMaps) decide what it means: a module-table export access +--- (`SWeapon.SDFShriekerCannon` -> `SDFShriekerCannon`, dropping the module prefix - same +--- resolution as the `import(...).Name` handling below, just via a pre-bound local instead of an +--- inline call) or a nested state/class override (`DefaultBeamWeapon.IdleState` -> +--- `DefaultBeamWeapon_IdleState`, dots replaced with underscores - matching the SAME +--- owner-qualified naming stripWrappers below now uses for its own auto-generated nested-class +--- docs, so a reference from another file resolves to exactly what the owner's own file +--- produces, with no need to read that file). Confirmed real: lua/sim/weapons/cybran/ +--- CAMZapperWeapon.lua's `State(DefaultBeamWeapon.IdleState)` and lua/shield.lua's +--- self-referential `State(Shield.OnState)` (Shield.lua's own `Shield = ClassShield(...) {...}` +--- makes `Shield` a classOwner of itself). Neither map matching (e.g. `moho.unit_methods`, a +--- genuine namespaced engine type, never a local import) falls through to the dotted text +--- unchanged, same as before this resolution existed. +---@param baseText string +---@param aliasMap table +---@param moduleImports table +---@param classOwners table +---@return string? +local function sanitizeBase(baseText, aliasMap, moduleImports, classOwners) + if aliasMap[baseText] then + return aliasMap[baseText] + end + if isSimpleTypeName(baseText) then + local owner = firstSegment(baseText) + if owner ~= baseText then + if moduleImports[owner] then + return baseText:match('([%a_][%w_]*)$') + elseif classOwners[owner] then + return (baseText:gsub('%.', '_')) + end + end + return baseText + end + return baseText:match('^import%s*%b()%.([%a_][%w_]*)$') +end + +--- `safeNames` is the set of this file's own bare top-level exports that export-env.lua will +--- rewrite *every* occurrence of (definition included) to `M.Name` - see the base-class +--- keep-alive below for why stripWrappers needs to know this. Pass an empty table (or omit) for +--- text that never goes through export-env's rewrite at all (e.g. a hook file's stitched +--- target - see plugin.lua's OnSetText). +---@param text string +---@param safeNames? table +---@return fa.diff[] +function M.stripWrappers(text, safeNames) + safeNames = safeNames or {} + local n = #text + local i = 1 + local diffs = {} + local lastWord = nil + local prevChar = nil + -- Counts ONLY '{'/'}' - tells us whether we're inside some table constructor's field list, + -- where a synthetic *statement* (the base-class keep-alive below) can never legally go, vs + -- a real statement context (top level, or inside a function/if/for/while/do body - all of + -- which allow statements). The blanking/doc-injection logic below stays depth-independent + -- on purpose (already correct for nested classes, e.g. a class nested in another's spec + -- table); only the keep-alive needs this. + local braceDepth = 0 + -- Stack of top-level (braceDepth 0) class names currently "open" - only ever has 0 or 1 + -- entries (a new top-level class only starts once the previous one has fully closed back to + -- braceDepth 0), used to owner-qualify any NESTED class doc this scan auto-generates. See + -- the docClassName computation below. + local topLevelStack = {} + + local existingClassDocs = {} + for name in text:gmatch('%-%-%-@class%s+([%a_][%w_%.]*)') do + existingClassDocs[name] = true + end + local aliasMap, moduleImports, classOwners = buildReferenceMaps(text) + + while i <= n do + local c = text:sub(i, i) + + if c == '-' and text:sub(i, i + 1) == '--' then + local afterDashes = i + 2 + local eqs = text:match('^%[(=*)%[', afterDashes) + if eqs then + local _, closeEnd = text:find(']' .. eqs .. ']', afterDashes + 2 + #eqs, true) + i = closeEnd and (closeEnd + 1) or (n + 1) + else + local nl = text:find('\n', i, true) + i = nl and (nl + 1) or (n + 1) + end + lastWord = nil + prevChar = nil + + elseif c == '"' or c == "'" then + i = skipString(text, i, n, c) + prevChar = c + lastWord = nil + + elseif c == '[' then + i = skipLongBracket(text, i, n) or (i + 1) + prevChar = ']' + lastWord = nil + + elseif c:match('%s') then + i = i + 1 + + elseif c:match('[%a_]') then + local wordStart = i + local _, e, word = text:find('^([%a_][%w_]*)', i) + i = e + 1 + + if CLASS_FUNCTIONS[word] and prevChar ~= '.' and prevChar ~= ':' and lastWord ~= 'function' then + local k = i + local _, we1 = text:find('^%s*', k) + k = we1 + 1 + + local bases = nil + local rawBases = nil + if text:sub(k, k) == '(' then + local depth = 0 + local j = k + while j <= n do + local cj = text:sub(j, j) + if cj == '"' or cj == "'" then + j = skipString(text, j, n, cj) - 1 + elseif cj == '(' then + depth = depth + 1 + elseif cj == ')' then + depth = depth - 1 + if depth == 0 then + break + end + end + j = j + 1 + end + if j <= n then + rawBases = splitTopLevelArgs(text:sub(k + 1, j - 1)) + bases = {} + for _, raw in ipairs(rawBases) do + local sanitized = sanitizeBase(raw, aliasMap, moduleImports, classOwners) + if sanitized then + bases[#bases + 1] = sanitized + end + end + k = j + 1 + local _, we2 = text:find('^%s*', k) + k = we2 + 1 + else + k = nil -- unbalanced parens, bail on this occurrence + end + end + + if k and text:sub(k, k) == '{' then + diffs[#diffs + 1] = { + start = wordStart, + finish = k - 1, + text = (' '):rep(k - wordStart), + } + + local nameStart, className = findPrecedingName(text, wordStart) + -- The TRUE start of the statement - accounts for an optional `local` prefix + -- (`local Name = ClassFn(...)`, e.g. lua/maui/layouthelpers.lua's internal + -- helper classes) that `nameStart` alone doesn't. Both insertions below use + -- this, not `nameStart` directly, to avoid landing between `local` and the + -- name it declares. + local statementStart = nameStart and findStatementStart(text, nameStart) + + -- Blanking `ClassFn(Bases...)` above erases the only reference to a + -- bare-identifier base like `NullShell` from LuaLS's view - if it came from + -- a `local NullShell = ...` declaration, that local now looks unused + -- (confirmed real: effects/**/*_script.lua files routinely do + -- `local NullShell = import(...).NullShell` then `Class(NullShell) {...}` - + -- and it's common: 324/600 files in a random fa-repo sample hit this). A + -- harmless `local _ = NullShell` right before the class statement "reads" it + -- again - `_` is itself exempt from the unused-local check + -- (script/core/diagnostics/unused-local.lua checks `name == '_'` and skips), + -- so this satisfies the original local without ever tripping its own + -- warning. Inserted at the *top* of any immediately-preceding comment block + -- (findCommentBlockStart), not at statementStart directly - otherwise it + -- would land between an existing hand-written `---@class` doc and the class + -- statement it documents, breaking that doc's binding. + -- + -- If the base's own outermost name is one of THIS file's exports (`safeNames`), + -- export-env.lua rewrites *every* occurrence of it - including its own + -- definition - to `M.Name`, so the bare name never exists anywhere in the + -- transformed output; the keep-alive has to read `M.Name` instead, or it + -- becomes an undefined-global itself (confirmed real: + -- lua/terranprojectiles.lua's `TDFGaussCannonProjectile = ClassProjectile( + -- TDFGeneralGaussCannonProjectile) {...}`, where the base is this same + -- file's own earlier-defined class). An imported/non-exported local (like + -- `NullShell` above) isn't in `safeNames` and keeps the bare reference. + -- + -- Only at braceDepth 0: `OnState = State(Shield.OnState) {...}` nested as a + -- FIELD inside an outer class's own table constructor (a self-referential + -- state-override pattern, confirmed real: lua/shield.lua:1135) has no legal + -- place for a synthetic *statement* - a table constructor's field list only + -- accepts `[expr]=v`/`name=v`/`v` entries, never a `local` statement. + -- Reproduced: inserting one there produced " cannot be used as + -- name" (the parser hitting `local` where a field name was expected). Nested + -- bases also don't need the keep-alive in the first place - they're + -- typically dotted references into the very class being defined + -- (`Shield.OnState`), not a separate local at risk of going unused. + if statementStart and rawBases and braceDepth == 0 then + local keepAlive = {} + for _, raw in ipairs(rawBases) do + if isSimpleTypeName(raw) then + if safeNames[firstSegment(raw)] then + keepAlive[#keepAlive + 1] = 'local _ = M.' .. raw + else + keepAlive[#keepAlive + 1] = 'local _ = ' .. raw + end + end + end + if #keepAlive > 0 then + local insertAt = findCommentBlockStart(text, statementStart) + diffs[#diffs + 1] = { + start = insertAt, + finish = insertAt - 1, + text = table.concat(keepAlive, '; ') .. '\n', + } + end + end + + -- A NESTED class (braceDepth > 0, e.g. `IdleState = State {...}` inside + -- `DefaultBeamWeapon`'s own spec table) gets its auto-generated doc name + -- qualified with the innermost currently-open TOP-LEVEL class's own name - + -- `DefaultBeamWeapon_IdleState`, not bare `IdleState` - matching FA's own + -- hand-written convention for nested state overrides (e.g. + -- `DefaultProjectileWeapon_IdleState`). Without this, every file's + -- undocumented nested `IdleState`/`Start`/`Error`/etc. shares the exact same + -- bare global name, and LuaLS merges same-named classes workspace-wide (the + -- very `AIBrain`/`EasyAIBrain` collision hasImmediatePrecedingClassDoc's own + -- comment above already warns about) - silently smearing dozens of unrelated + -- files' same-named nested states into one shared type. It also makes an + -- `Owner.Field` base reference (see sanitizeBase) resolvable at all: nothing + -- was ever registered under that dotted name before this. Top-level + -- (braceDepth 0) classes are unaffected (docClassName == className). + local docClassName = className + if braceDepth > 0 and #topLevelStack > 0 and className then + docClassName = topLevelStack[#topLevelStack] .. '_' .. className + end + + if docClassName + and statementStart + and not existingClassDocs[docClassName] + and not hasImmediatePrecedingClassDoc(text, statementStart) then + local doc + if bases and #bases > 0 then + doc = '---@class ' .. docClassName .. ': ' .. table.concat(bases, ', ') .. '\n' + else + doc = '---@class ' .. docClassName .. '\n' + end + diffs[#diffs + 1] = { + start = statementStart, + finish = statementStart - 1, + text = doc, + } + existingClassDocs[docClassName] = true + end + + if braceDepth == 0 and className then + topLevelStack[#topLevelStack + 1] = className + end + end + end + + lastWord = word + prevChar = nil + + elseif c == '{' then + braceDepth = braceDepth + 1 + prevChar = nil + lastWord = nil + i = i + 1 + + elseif c == '}' then + braceDepth = math.max(0, braceDepth - 1) + if braceDepth == 0 and #topLevelStack > 0 then + topLevelStack[#topLevelStack] = nil + end + prevChar = nil + lastWord = nil + i = i + 1 + + else + prevChar = (c == '.' or c == ':') and c or nil + lastWord = nil + i = i + 1 + end + end + + return diffs +end + +return M diff --git a/lua-ls-addon/config.lua b/lua-ls-addon/config.lua new file mode 100644 index 0000000000..a87a386a66 --- /dev/null +++ b/lua-ls-addon/config.lua @@ -0,0 +1,93 @@ +-- Applies the workspace settings FA's dialect needs but no scanner rewrites - SupCom-only +-- tokens, engine globals, and require/path conventions - automatically to any matching project. +-- +-- Loaded by LuaLS's third-party config system (script/library.lua) whenever the workspace name +-- or an open file matches `words` below; each entry in `configs` is then applied to the user's +-- own settings once, the first time a match is found. + +-- if not set, the folder name will be used +name = 'Forged Alliance' +-- match any word to load +words = {'.'} +-- list of settings to be changed +---@type config.change[] +configs = { + { + key = 'Lua.runtime.version', + action = 'set', + value = 'Lua 5.1', + }, + { + key = 'Lua.runtime.path', + action = 'add', + value = '/?', + }, + { + key = 'Lua.completion.showWord', + action = 'set', + value = 'Disable', + }, + { + key = 'Lua.runtime.special', + action = 'prop', + prop = 'import', + value = 'require', + }, + { + key = 'Lua.runtime.special', + action = 'prop', + prop = 'doscript', + value = 'require', + }, + { + key = 'Lua.runtime.nonstandardSymbol', + action = 'add', + value = 'continue', + }, + { + key = 'Lua.runtime.nonstandardSymbol', + action = 'add', + value = '!=', + }, + { + key = 'Lua.completion.requireSeparator', + action = 'set', + value = '/', + }, + { + key = 'Lua.runtime.pathStrict', + action = 'set', + value = false, + }, + { + -- `<<`/`>>` (bitwise shift) are version-gated in script/parser/compile.lua to + -- Lua 5.3+/LuaJIT, not controllable via nonstandardSymbol - but SupCom's engine + -- supports them despite everything else here needing Lua.runtime.version = 'Lua 5.1'. + -- Parsing itself isn't affected (the version check only pushes a diagnostic, parsing + -- continues either way), so just silence it. + key = 'Lua.diagnostics.disable', + action = 'add', + value = 'unsupport-symbol', + }, + { + -- inject-field's "is this class exact" escape hatch (script/core/diagnostics/ + -- inject-field.lua) depends on guide.getSelfNode, which only recognizes the implicit + -- `self` colon-sugar creates (function Foo:Method()) - not FA's own convention of + -- writing `self` out as an explicit parameter (Method = function(self) ... end). That + -- makes vm.getDefinedClass return nil for every FA method's `self`, which skips the + -- "class isn't exact, allow it" check entirely and falls into a stricter path that + -- flags any field first assigned via self.X = ... without a matching ---@field. Since + -- that's FA's normal way of initializing instance fields (e.g. BaseManager:Create()), + -- this fires constantly for correct code - disable rather than hand-document every field. + key = 'Lua.diagnostics.disable', + action = 'add', + value = 'inject-field', + }, +} +for _, name in ipairs {'moho'} do + configs[#configs+1] = { + key = 'Lua.diagnostics.globals', + action = 'add', + value = name, + } +end diff --git a/lua-ls-addon/export-env.lua b/lua-ls-addon/export-env.lua new file mode 100644 index 0000000000..880a2072fc --- /dev/null +++ b/lua-ls-addon/export-env.lua @@ -0,0 +1,445 @@ +-- Makes FA's implicit module system visible to LuaLS: finds each file's bare top-level +-- declarations and rewrites the file into a real Lua module, so every export resolves +-- correctly wherever it's referenced from - including forward references, from inside a +-- function defined earlier in the same file. +-- +-- This is a heuristic pre-parse scanner, not a real parser. FA files never `return {...}` - a +-- file's bare top-level assignments and functions (no `local` keyword), such as +-- `Name = ...` / `function Name(...)`, ARE its exports, resolved by `import()` at runtime. We +-- rewrite the whole file into a classic Lua module: `local M = {}`, every export reachable as +-- `M.Name`, and the file ends with `return M`. That's what makes forward/mutual references +-- between exports resolve correctly, including from *inside* an earlier-defined function's +-- body: `M` itself is assigned exactly once (`local M = {}`), so its own local-scoping is +-- trivially unambiguous regardless of where in the file it's referenced from, and `M.Name` +-- field resolution (unlike a bare local) isn't restricted to "last assignment before this +-- textual position" - LuaLS resolves it by merging every known `M.Name = ...` in the file, +-- wherever it sits. This also sidesteps Lua 5.1's 200-local-per-chunk cap entirely for exported +-- names, since table fields don't consume it - only forward-declared *unsafe* names (see below) +-- do. +-- +-- Depth tracks both block keywords (function/if/do/repeat...end/until) AND `{`/`}`, since +-- FA classes are one big table constructor (`Unit = ClassUnit(...) { Foo = function(self) +-- ... end, ... }`) - without counting braces too, every `Key = function(...) end,` entry +-- inside it looks exactly like a bare top-level export the moment its own function/end +-- balances back to depth 0, and gets wrongly captured (verified against real FA files: +-- lua/sim/Unit.lua alone went from 3 genuine top-level names to 329 false ones without this). +-- +-- Rewriting every REFERENCE (not just each definition) to `M.Name` is only safe for a name +-- that isn't shadowed anywhere in the file - a `local`, function parameter, or `for` loop +-- variable sharing the same name would have every bare use *inside its own scope* wrongly +-- redirected to the module field otherwise (confirmed with a real example: +-- lua/system/profile.lua does `checkpoint = debug.profiledata` at the top level, then +-- `function checkpoint_to_table(checkpoint)` - the parameter shadows the export for that +-- function's whole body). A file-wide "does this name appear as a local/param/loop-var +-- anywhere" scan is a conservative, whole-file-granularity substitute for real scope +-- resolution: names it flags ("unsafe") keep today's exact behaviour (forward-declared as a +-- real `local`, bridged into `M` with one `M.Name = Name` assignment before `return M`) rather +-- than risk a wrong rewrite. Verified empirically negligible: across all 2,801 *.lua files in +-- the fa repo (102,668 top-level exports total), only 24 files / 57 names are ever shadowed +-- anywhere in their own file. +-- +-- Known limitations (acceptable trade-off for a regex/state-machine scanner +-- instead of a full parser): spaced-out member access (`t . Name = x`) isn't +-- recognised as a member access and could false-positive. + +local M = {} + +local BLOCK_OPEN = { ['function'] = true, ['if'] = true, ['do'] = true, ['repeat'] = true } +local BLOCK_CLOSE = { ['end'] = true, ['until'] = true } +local LOOP_HEADER = { ['for'] = true, ['while'] = true } + +-- Files marked `---@declare-global` (or `---@meta`) are declaring real globals, not FA +-- module exports - e.g. the engine API stubs. Leave them as plain, untransformed Lua. +local function optsOut(text) + return text:find('%-%-%-@declare%-global') ~= nil + or text:find('%-%-%-@meta') ~= nil +end + +--- Given the position right after an identifier, checks whether it's followed by +--- `(, )* =` (not `==`) - i.e. whether this identifier is one entry in a +--- comma-separated name-list that ultimately ends in a bare assignment. Used so each name in +--- `A, B, C = 1, 2, 3` can independently confirm its own membership, regardless of position. +---@param text string +---@param pos integer +---@param n integer +---@return boolean +local function isNameListThenEquals(text, pos, n) + local k = pos + while true do + local _, we = text:find('^%s*', k) + k = we + 1 + if text:sub(k, k) ~= ',' then + return false + end + k = k + 1 + local _, we2 = text:find('^%s*', k) + k = we2 + 1 + local ns, ne = text:find('^[%a_][%w_]*', k) + if not ns then + return false + end + k = ne + 1 + local _, we3 = text:find('^%s*', k) + k = we3 + 1 + if text:sub(k, k) == '=' and text:sub(k, k + 1) ~= '==' then + return true + end + -- else loop again, expecting another ", name" + end +end + +--- Given the position right after `function`, finds and returns the parameter-list body text +--- (between the parens), covering `function Name(...)`, `function Obj.Name(...)`, +--- `function Obj:Method(...)` and anonymous `function(...)` alike - the name/dotted/colon +--- chain in front of the parens (if any) doesn't matter, only the parens themselves do. +---@param text string +---@param pos integer position right after the `function` keyword +---@return string? +local function findParamsText(text, pos) + local j = pos + local n = #text + while j <= n and text:sub(j, j):match('[%s%w_%.%:]') do + j = j + 1 + end + if text:sub(j, j) ~= '(' then + return nil + end + local closeParen = text:find(')', j, true) + if not closeParen then + return nil + end + return text:sub(j + 1, closeParen - 1) +end + +--- Splits a `for` loop header's variable-name list (numeric or generic form) starting right +--- after the `for` keyword, returning every comma-separated name up to (not including) the +--- `=` or `in` that ends the list. These are implicitly local for the loop body. +---@param text string +---@param pos integer position right after the `for` keyword +---@return string[] +local function findForLoopVars(text, pos) + local names = {} + local namesText = text:match('^%s*([%a_][%w_%s,]-)%s*=', pos) + or text:match('^%s*([%a_][%w_%s,]-)%s+in%f[%A]', pos) + if namesText then + for nm in namesText:gmatch('[%a_][%w_]*') do + names[#names + 1] = nm + end + end + return names +end + +---@param text string +---@return string[] exports ordered, de-duplicated bare top-level names +---@return boolean hasTopReturn whether the file already has a top-level `return` +---@return integer topLevelLocalCount count of the file's own pre-existing top-level locals +---@return table unsafeNames export names shadowed by a local/param/loop-var +--- somewhere in the file - unsafe to rewrite every reference of +function M.scan(text) + if optsOut(text) then + return {}, false, 0, {} + end + + local n = #text + local i = 1 + local depth = 0 + local loopHeaderDepth = 0 + local lastWord = nil + local prevChar = nil + local hasTopReturn = false + -- Persists across the commas in a `local a, b, c = ...` name-list, unlike `lastWord` + -- (which resets on every comma) - needed so e.g. `local pairs, ipairs = pairs, ipairs` + -- doesn't wrongly treat `ipairs` as a bare export just because the comma before it wiped + -- `lastWord` back to nil. + local localList = false + -- Counts every top-level `local` name (including `local function Name()`) that already + -- exists in the file, so the caller can tell whether adding more locals (for unsafe names) + -- would cross Lua's 200-local-per-chunk cap. + local topLevelLocalCount = 0 + + -- Every name introduced as a `local` (any depth), a function parameter (any depth, any + -- function), or a `for` loop variable (any depth) anywhere in the file - a superset used + -- below to flag which exports are unsafe to rewrite at every reference site. + local localOrParamNames = {} + + local exportsSet = {} + local exports = {} + local function addExport(name) + if not exportsSet[name] then + exportsSet[name] = true + exports[#exports + 1] = name + end + end + + while i <= n do + local c = text:sub(i, i) + + if c == '-' and text:sub(i, i + 1) == '--' then + local afterDashes = i + 2 + local eqs = text:match('^%[(=*)%[', afterDashes) + if eqs then + local _, closeEnd = text:find(']' .. eqs .. ']', afterDashes + 2 + #eqs, true) + i = closeEnd and (closeEnd + 1) or (n + 1) + else + local nl = text:find('\n', i, true) + i = nl and (nl + 1) or (n + 1) + end + + elseif c == '"' or c == "'" then + local quote = c + local j = i + 1 + while j <= n do + local cj = text:sub(j, j) + if cj == '\\' then + j = j + 2 + elseif cj == quote then + j = j + 1 + break + else + j = j + 1 + end + end + i = j + prevChar = quote + lastWord = nil + + elseif c == '[' then + local eqs = text:match('^%[(=*)%[', i) + if eqs then + local _, closeEnd = text:find(']' .. eqs .. ']', i + 2 + #eqs, true) + i = closeEnd and (closeEnd + 1) or (n + 1) + else + i = i + 1 + end + prevChar = ']' + lastWord = nil + + elseif c:match('%s') then + i = i + 1 + + elseif c:match('[%a_]') then + local _, e, word = text:find('^([%a_][%w_]*)', i) + i = e + 1 + + local k = i + while k <= n and text:sub(k, k):match('%s') do + k = k + 1 + end + local nextChar = text:sub(k, k) + + if word == 'function' then + depth = depth + 1 + if depth == 1 and loopHeaderDepth == 0 then + if lastWord == 'local' then + topLevelLocalCount = topLevelLocalCount + 1 + else + local ns, _, fname = text:find('^%s*([%a_][%w_]*)%s*%(', i) + if ns then + addExport(fname) + end + end + end + -- this function's own parameters are locally-scoped to its body, at any depth + local paramsText = findParamsText(text, i) + if paramsText then + -- Lua 5.4 (the runtime LuaLS executes plugins under) makes generic-for + -- control variables implicitly const - reassigning `p` directly here is a + -- compile error ("attempt to assign to const variable 'p'") that fails this + -- whole module's `require`, taking every other scanner down with it (the + -- exact cause of a prior "everything is broken" regression - confirmed via + -- the LuaLS output log's stack traceback pointing at this line). + for rawParam in paramsText:gmatch('[^,]+') do + local p = rawParam:match('^%s*(.-)%s*$') + if p:match('^[%a_][%w_]*$') then + localOrParamNames[p] = true + end + end + end + elseif BLOCK_OPEN[word] then + depth = depth + 1 + if word == 'do' and loopHeaderDepth > 0 then + loopHeaderDepth = loopHeaderDepth - 1 + end + elseif BLOCK_CLOSE[word] then + depth = math.max(0, depth - 1) + elseif LOOP_HEADER[word] then + loopHeaderDepth = loopHeaderDepth + 1 + if word == 'for' then + for _, nm in ipairs(findForLoopVars(text, i)) do + localOrParamNames[nm] = true + end + end + elseif word == 'return' then + if depth == 0 and loopHeaderDepth == 0 then + hasTopReturn = true + end + elseif word == 'local' then + localList = true + elseif depth == 0 and loopHeaderDepth == 0 + and prevChar ~= '.' and prevChar ~= ':' then + if localList then + topLevelLocalCount = topLevelLocalCount + 1 + elseif nextChar == '=' and text:sub(k, k + 1) ~= '==' then + addExport(word) + elseif nextChar == ',' and isNameListThenEquals(text, i, n) then + addExport(word) + end + end + + if localList then + localOrParamNames[word] = true + end + + if word ~= 'local' then + localList = localList and nextChar == ',' + end + + lastWord = word + prevChar = nil + + elseif c == '{' then + depth = depth + 1 + prevChar = nil + lastWord = nil + i = i + 1 + + elseif c == '}' then + depth = math.max(0, depth - 1) + prevChar = nil + lastWord = nil + i = i + 1 + + else + prevChar = (c == '.' or c == ':') and c or nil + lastWord = nil + i = i + 1 + end + end + + local unsafeNames = {} + for _, name in ipairs(exports) do + if localOrParamNames[name] then + unsafeNames[name] = true + end + end + + return exports, hasTopReturn, topLevelLocalCount, unsafeNames +end + +--- Second pass: for every bare occurrence of a name in `safeNames`, not preceded by `.`/`:` +--- (member access on something else), produces a diff inserting `M.` immediately before it. +--- Handles both definition sites (`Name = ...` -> `M.Name = ...`, `function Name(...)` -> +--- `function M.Name(...)`) and every later reference uniformly - the same insert-before-token +--- diff does both jobs, no separate "is this the definition" tracking needed. +--- +--- A bare `Name` immediately followed by `=` (not `==`) is only rewritten at `depth == 0`: at +--- `depth > 0` that shape is a table-constructor key (`{ Name = value }`), not an assignment - +--- exactly the same hazard, and the same depth-based fix, as the original brace-depth bug in +--- `M.scan` above. A `Name` NOT followed by `=` is always a genuine read, safe to rewrite at +--- any depth. +---@param text string +---@param safeNames table +---@return fa.diff[] +function M.rewriteReferences(text, safeNames) + local n = #text + local i = 1 + local depth = 0 + local loopHeaderDepth = 0 + local prevChar = nil + local diffs = {} + + while i <= n do + local c = text:sub(i, i) + + if c == '-' and text:sub(i, i + 1) == '--' then + local afterDashes = i + 2 + local eqs = text:match('^%[(=*)%[', afterDashes) + if eqs then + local _, closeEnd = text:find(']' .. eqs .. ']', afterDashes + 2 + #eqs, true) + i = closeEnd and (closeEnd + 1) or (n + 1) + else + local nl = text:find('\n', i, true) + i = nl and (nl + 1) or (n + 1) + end + + elseif c == '"' or c == "'" then + local quote = c + local j = i + 1 + while j <= n do + local cj = text:sub(j, j) + if cj == '\\' then + j = j + 2 + elseif cj == quote then + j = j + 1 + break + else + j = j + 1 + end + end + i = j + prevChar = quote + + elseif c == '[' then + local eqs = text:match('^%[(=*)%[', i) + if eqs then + local _, closeEnd = text:find(']' .. eqs .. ']', i + 2 + #eqs, true) + i = closeEnd and (closeEnd + 1) or (n + 1) + else + i = i + 1 + end + prevChar = ']' + + elseif c:match('%s') then + i = i + 1 + + elseif c:match('[%a_]') then + local wordStart = i + local _, e, word = text:find('^([%a_][%w_]*)', i) + i = e + 1 + + local k = i + while k <= n and text:sub(k, k):match('%s') do + k = k + 1 + end + local nextChar = text:sub(k, k) + + if word == 'function' then + depth = depth + 1 + elseif BLOCK_OPEN[word] then + depth = depth + 1 + if word == 'do' and loopHeaderDepth > 0 then + loopHeaderDepth = loopHeaderDepth - 1 + end + elseif BLOCK_CLOSE[word] then + depth = math.max(0, depth - 1) + elseif LOOP_HEADER[word] then + loopHeaderDepth = loopHeaderDepth + 1 + elseif safeNames[word] and prevChar ~= '.' and prevChar ~= ':' then + local isEqTarget = nextChar == '=' and text:sub(k, k + 1) ~= '==' + if not isEqTarget or depth == 0 then + diffs[#diffs + 1] = { start = wordStart, finish = wordStart - 1, text = 'M.' } + end + end + + prevChar = nil + + elseif c == '{' then + depth = depth + 1 + prevChar = nil + i = i + 1 + + elseif c == '}' then + depth = math.max(0, depth - 1) + prevChar = nil + i = i + 1 + + else + prevChar = (c == '.' or c == ':') and c or nil + i = i + 1 + end + end + + return diffs +end + +return M diff --git a/lua-ls-addon/for-in-pairs.lua b/lua-ls-addon/for-in-pairs.lua new file mode 100644 index 0000000000..64c4b157bb --- /dev/null +++ b/lua-ls-addon/for-in-pairs.lua @@ -0,0 +1,183 @@ +-- Types SupCom's bare-table `for` loops by giving LuaLS a real iterator call to read a +-- signature from. +-- +-- SupCom's engine lets `for a, b in someTable do ... end` iterate a bare table directly +-- (confirmed: same semantics as pairs()) - no pairs()/ipairs()/custom iterator call needed. +-- That's not a parse error (a bare table is a perfectly valid `in`-clause expression +-- syntactically; only calling it as an iterator function is what real Lua would reject at +-- runtime), so LuaLS parses these loops fine - it just has no function call to read a +-- signature off of, so `a`/`b` end up untyped. Rewriting `in someTable do` to +-- `in pairs(someTable) do` gives LuaLS exactly what it already knows how to type, via +-- pairs()'s own (generic, not name-hardcoded) signature. +-- +-- Known limitations: only a *single* `in`-expression with no top-level comma is rewritten, +-- so explicit multi-value iterator forms (`for k, v in next, t do`) are left untouched, as +-- is anything already ending in a call (`pairs(t)`, `ipairs(t)`, `SomeIterator()`) - and a +-- merely-parenthesized bare table (`in (someTable) do`) is mistaken for a call and skipped. + +local M = {} + +---@param text string +---@param i integer position of '[' +---@param n integer +---@return integer? closeEnd +local function skipLongBracket(text, i, n) + local eqs = text:match('^%[(=*)%[', i) + if not eqs then + return nil + end + local _, closeEnd = text:find(']' .. eqs .. ']', i + 2 + #eqs, true) + return closeEnd and (closeEnd + 1) or (n + 1) +end + +---@param text string +---@param i integer position right at the opening quote +---@param n integer +---@param quote string +---@return integer position right after the closing quote +local function skipString(text, i, n, quote) + local j = i + 1 + while j <= n do + local cj = text:sub(j, j) + if cj == '\\' then + j = j + 2 + elseif cj == quote then + return j + 1 + else + j = j + 1 + end + end + return j +end + +---Skips a `--` comment (and long comments) starting at `i`. +---@param text string +---@param i integer +---@param n integer +---@return integer +local function skipComment(text, i, n) + local afterDashes = i + 2 + local eqs = text:match('^%[(=*)%[', afterDashes) + if eqs then + local _, closeEnd = text:find(']' .. eqs .. ']', afterDashes + 2 + #eqs, true) + return closeEnd and (closeEnd + 1) or (n + 1) + end + local nl = text:find('\n', i, true) + return nl and (nl + 1) or (n + 1) +end + +---Parses ` (, )*` starting at `pos`, then checks what follows. +---@param text string +---@param pos integer +---@param n integer +---@return integer? exprStart position right after `in`, or nil if this isn't a generic-for +local function parseNameListThenIn(text, pos, n) + local k = pos + while true do + local _, we = text:find('^%s*', k) + k = we + 1 + local ns, ne = text:find('^[%a_][%w_]*', k) + if not ns then + return nil + end + k = ne + 1 + local _, we2 = text:find('^%s*', k) + k = we2 + 1 + local ch = text:sub(k, k) + if ch == ',' then + k = k + 1 + elseif ch == '=' and text:sub(k, k + 1) ~= '==' then + return nil -- numeric for + elseif text:sub(k, k + 1) == 'in' and not text:sub(k + 2, k + 2):match('[%w_]') then + return k + 2 + else + return nil + end + end +end + +---@param text string +---@return fa.diff[] +function M.wrapBareIterators(text) + local n = #text + local i = 1 + local diffs = {} + + while i <= n do + local c = text:sub(i, i) + + if c == '-' and text:sub(i, i + 1) == '--' then + i = skipComment(text, i, n) + + elseif c == '"' or c == "'" then + i = skipString(text, i, n, c) + + elseif c == '[' then + i = skipLongBracket(text, i, n) or (i + 1) + + elseif c:match('[%a_]') then + local _, e, word = text:find('^([%a_][%w_]*)', i) + i = e + 1 + + if word == 'for' then + local exprStart = parseNameListThenIn(text, i, n) + if exprStart then + local depth = 0 + local hasTopComma = false + local doStart + local j = exprStart + while j <= n do + local cj = text:sub(j, j) + if cj == '-' and text:sub(j, j + 1) == '--' then + j = skipComment(text, j, n) - 1 + elseif cj == '"' or cj == "'" then + j = skipString(text, j, n, cj) - 1 + elseif cj == '[' then + local close = skipLongBracket(text, j, n) + j = close and (close - 1) or j + if not close then + depth = depth + 1 + end + elseif cj == '(' or cj == '{' then + depth = depth + 1 + elseif cj == ')' or cj == '}' or cj == ']' then + depth = depth - 1 + elseif cj == ',' and depth == 0 then + hasTopComma = true + elseif cj:match('[%a_]') then + local _, we3, w3 = text:find('^([%a_][%w_]*)', j) + if w3 == 'do' and depth == 0 then + doStart = j + break + end + j = we3 + end + j = j + 1 + end + + if doStart and not hasTopComma then + local exprEnd = doStart - 1 + while exprEnd >= exprStart and text:sub(exprEnd, exprEnd):match('%s') do + exprEnd = exprEnd - 1 + end + local trimStart = exprStart + while trimStart <= exprEnd and text:sub(trimStart, trimStart):match('%s') do + trimStart = trimStart + 1 + end + if trimStart <= exprEnd and text:sub(exprEnd, exprEnd) ~= ')' then + diffs[#diffs + 1] = { start = trimStart, finish = trimStart - 1, text = 'pairs(' } + diffs[#diffs + 1] = { start = exprEnd + 1, finish = exprEnd, text = ')' } + end + end + end + end + + else + i = i + 1 + end + end + + return diffs +end + +return M diff --git a/lua-ls-addon/hash-comments.lua b/lua-ls-addon/hash-comments.lua new file mode 100644 index 0000000000..0063503285 --- /dev/null +++ b/lua-ls-addon/hash-comments.lua @@ -0,0 +1,91 @@ +-- Converts SupCom's `#` comment marker into standard Lua `--`, without touching a `#` that's +-- just an ordinary character. +-- +-- SupCom's Lua preprocessor treats a bare `#` as a comment-start (equivalent to `--`), which +-- isn't valid standard Lua syntax, so it has to be blanked out before parsing. But `#` outside +-- of that convention is still an ordinary character: it appears inside 171+ real FA string +-- literals (confirmed, e.g. shared/DebugFunction.lua:101's `"%0#" .. ...` format-width +-- pattern, and dozens of changelog strings literally starting `"# Patch ..."`), and inside +-- already-valid `--` comments, including `--#region`/`--#endregion` folding markers +-- (script/core/folding.lua:99-106 matches on the comment text starting with `#region` - +-- blindly rewriting the `#` there to `--` turns it into `----region`, which no longer +-- matches, silently breaking code folding). FA source never uses `#` as Lua's length +-- operator (checked: not a single occurrence outside strings/comments in a repo-wide sample - +-- consistent with `table.getn` being used everywhere instead, presumably because `#` can't +-- mean "length" and "comment" at once), so it's safe to treat every remaining `#` outside a +-- string or an existing comment as a SupCom comment-start. + +local M = {} + +---@param text string +---@param i integer position of '[' +---@param n integer +---@return integer? closeEnd +local function skipLongBracket(text, i, n) + local eqs = text:match('^%[(=*)%[', i) + if not eqs then + return nil + end + local _, closeEnd = text:find(']' .. eqs .. ']', i + 2 + #eqs, true) + return closeEnd and (closeEnd + 1) or (n + 1) +end + +---@param text string +---@return fa.diff[] +function M.stripHashComments(text) + local n = #text + local i = 1 + local diffs = {} + + while i <= n do + local c = text:sub(i, i) + + if c == '-' and text:sub(i, i + 1) == '--' then + -- already a real comment (possibly a #region/#endregion folding marker) - skip + -- the whole thing untouched, converting any '#' inside would only ever corrupt it + local afterDashes = i + 2 + local eqs = text:match('^%[(=*)%[', afterDashes) + if eqs then + local _, closeEnd = text:find(']' .. eqs .. ']', afterDashes + 2 + #eqs, true) + i = closeEnd and (closeEnd + 1) or (n + 1) + else + local nl = text:find('\n', i, true) + i = nl and (nl + 1) or (n + 1) + end + + elseif c == '"' or c == "'" then + local quote = c + local j = i + 1 + while j <= n do + local cj = text:sub(j, j) + if cj == '\\' then + j = j + 2 + elseif cj == quote then + j = j + 1 + break + else + j = j + 1 + end + end + i = j + + elseif c == '[' then + i = skipLongBracket(text, i, n) or (i + 1) + + elseif c == '#' then + diffs[#diffs + 1] = { + start = i, + finish = i, + text = '--', + } + i = i + 1 + + else + i = i + 1 + end + end + + return diffs +end + +return M diff --git a/lua-ls-addon/hook-files.lua b/lua-ls-addon/hook-files.lua new file mode 100644 index 0000000000..cc59612229 --- /dev/null +++ b/lua-ls-addon/hook-files.lua @@ -0,0 +1,28 @@ +-- Identifies which target file a SupCom mod "hook" file extends, so plugin.lua can stitch the +-- two together before LuaLS sees either. +-- +-- SupCom mods can define "hook" files under a mod's hookdir (a folder mirroring the game's own +-- file structure - MODS.LUA:150-198, default `/hook`, configurable per-mod). After the target +-- script loads, a matching hook file's content is concatenated to the END of it, and the +-- combined chunk is what actually runs - sharing the target's top-level scope (that's how the +-- documented `local oldFn = Fn` / `Cls = Class(oldCls) {...}` override patterns work at all). +-- +-- Detection: prefer an explicit `---@declare-hook ` annotation (works for any hookdir); +-- fall back to the `/hook/` path convention (covers the documented default with zero +-- annotation effort, but misses mods with a custom hookdir). + +local M = {} + +---@param uri string +---@param text string +---@return string? relPath workspace-relative path of the file this hooks, or nil if this +--- doesn't look like a hook file at all +function M.findTarget(uri, text) + local explicit = text:match('%-%-%-@declare%-hook%s+(%S+)') + if explicit then + return explicit + end + return uri:match('/[Hh]ook/(.+)$') +end + +return M diff --git a/lua-ls-addon/plugin.lua b/lua-ls-addon/plugin.lua new file mode 100644 index 0000000000..22cfad6991 --- /dev/null +++ b/lua-ls-addon/plugin.lua @@ -0,0 +1,429 @@ +-- Orchestrates every SupCom-Lua-to-standard-Lua translation this addon performs, and wires the +-- addon into LuaLS as a workspace plugin. +-- +-- Each dialect quirk (comments, table hints, classes, bare iteration, the implicit module +-- system, mod hook files) has its own scanner module, each returning a list of +-- `{start, finish, text}` diffs against the *original* source text - see that module's own +-- header for what it solves and why. `OnSetText` below runs every scanner over each file LuaLS +-- opens, merges their diffs into one list, and hands LuaLS the merged result to parse instead +-- of the raw file. Merging independently-computed diffs safely - two scanners agreeing on the +-- same position, or one scanner's diff accidentally overlapping another's - turned out to need +-- real care of its own; see `mergeSameStartDiffs` and `resolveOverlappingDiffs` below. +-- `ResolveRequire` separately teaches LuaLS to follow FA's root-relative `import()`/ +-- `doscript()` paths - and excludes mod hook files from ever being a resolved import target, +-- since a hook shares its target's chunk/scope at runtime (MODS.LUA:150-198) rather than being +-- an importable module in its own right. Hook-file merging itself is bidirectional: a hook +-- file's own `OnSetText` prepends its target's raw content (so the hook's code can reference the +-- target's globals while you're editing it), and - via `hookIndex`, a reverse `targetUri -> +-- {hookUri, ...}` lookup, filled in incrementally as each hook file's own OnSetText registers +-- itself (see hookIndex's own comment for why not an eager scan) - a target file's own +-- `OnSetText` appends every hook that targets it, computing its exports across the combined set, +-- so `import()`ing the target's path from anywhere else in the mod sees the union of both, not +-- just whichever file the import happened to resolve to. + +local files = require 'files' +local furi = require 'file-uri' +local smerger = require 'string-merger' +local exportEnv = require 'export-env' +local tableHints = require 'table-hints' +local classSupport = require 'class-support' +local forInPairs = require 'for-in-pairs' +local hookFiles = require 'hook-files' +local hashComments = require 'hash-comments' + +--- Finds every file in the workspace whose path ends with `/` (extension-optional, +--- case-insensitive) - shared by ResolveRequire and the hook-file target lookup below. +---@param uri string +---@param relPath string +---@return string[] +local function findUrisBySuffix(uri, relPath) + local target = relPath:gsub('\\', '/'):gsub('^/', ''):lower() + if not target:match('%.lua$') then + target = target .. '.lua' + end + local suffix = '/' .. target + + local results = {} + for fileUri in files.eachFile(uri) do + local filePath = furi.decode(fileUri) + if filePath then + local normalized = filePath:gsub('\\', '/'):lower() + if normalized:sub(-#suffix) == suffix then + results[#results + 1] = fileUri + end + end + end + return results +end + +--- Reverse of hookFiles.findTarget: targetUri -> {hookUri, ...}, which hook file(s) apply to a +--- given target. Filled in incrementally as OnSetText processes each file over the course of the +--- session - NOT via an eager, one-time, full-workspace scan (tried first, found broken): a +--- `Lua.workspace.library` setup can span multiple repos with a target in one and its hook in +--- another (confirmed real: a "missions" workspace with `Lua.workspace.library = {"../fa", +--- "../fa-coop"}`, target in fa, hook in fa-coop's `mods/coop/hook/...`), and there's no +--- guarantee the hook's owning library has finished loading into files.eachFile's visible set by +--- the time the very first OnSetText call fires and would have built the index - fa's own +--- ~2800 files alone make it near-certain the scan fires early, with fa-coop's much smaller file +--- count not loaded yet, and a *cached-forever* index built at that moment stays wrong for the +--- rest of the session no matter how much later fa-coop's files actually appear. Registering as +--- each file's own OnSetText runs sidesteps this entirely: every file in the workspace - +--- including every hook file - naturally gets its own OnSetText call at some point during normal +--- preload (LuaLS analyzes the whole project, not just reachable-by-import files), which is +--- exactly when it registers itself below, with no proactive scan needed at all. A target file +--- whose own OnSetText happens to run before its hook has registered will only briefly miss it - +--- self-corrects the next time that target file is itself opened/edited, by which point preload +--- has settled - same "reload/retouch to pick up a workspace-wide fact" pattern the rest of this +--- addon already relies on. +local hookIndex = {} + +--- mergeDiff (script/string-merger.lua) sorts diffs by `start`, and table.sort isn't stable +--- for ties, so two diffs that both target the same position race: which one "wins" is +--- undefined and can corrupt output (verified by hand against mergeDiff's cur/buf bookkeeping). +--- Since we run four independent scanners into one diff list, collapse any diffs that land on +--- the same `start` into one before returning, instead of trying to keep each scanner from +--- ever colliding with the others. +---@param diffs fa.diff[] +---@return fa.diff[] +local function mergeSameStartDiffs(diffs) + local groups, order = {}, {} + for _, d in ipairs(diffs) do + if not groups[d.start] then + groups[d.start] = {} + order[#order + 1] = d.start + end + table.insert(groups[d.start], d) + end + + local merged = {} + for _, start in ipairs(order) do + local group = groups[start] + if #group == 1 then + merged[#merged + 1] = group[1] + else + local finish = start - 1 + local text = {} + for _, d in ipairs(group) do + if d.finish >= start then + finish = math.max(finish, d.finish) + end + text[#text + 1] = d.text + end + merged[#merged + 1] = { start = start, finish = finish, text = table.concat(text) } + end + end + return merged +end + +--- mergeDiff tracks a single `cur` position through diffs *sorted by `start`*, advancing +--- `cur = diff.finish + 1` after each - it assumes diffs never overlap in range. +--- mergeSameStartDiffs only ever collapsed diffs sharing the exact same `start`; it never +--- handled two diffs with *different* starts whose ranges overlap (e.g. class-support.lua's +--- diff blanking out an entire `ClassFn(Bases...)` call, and a nested diff landing on a +--- base-class argument that happens to reference another export of the same file - a real +--- FA pattern: a file defining a base class, then deriving another class from it). When that +--- happens, the nested diff's `finish` is *less* than what `cur` already advanced to from the +--- wider diff, so `cur` moves backward - the next `text:sub(cur, nextDiff.start - 1)` then +--- re-emits already-consumed source text, corrupting everything processed afterward in that +--- file (confirmed by hand against mergeDiff's cur/buf bookkeeping, and reproduced/fixed in +--- the scratchpad Python port before writing this). +--- Resolves this by dropping any diff whose `start` falls at or before the highest `finish` +--- an already-accepted diff claimed - safe unconditionally, since that range's original +--- content is being replaced by the wider diff regardless, so a nested insertion inside it is +--- always moot, never wanted. Call this *after* mergeSameStartDiffs, so same-start collisions +--- (e.g. a `---@class` doc and an `M.` prefix landing on the same position) still combine into +--- one diff first, in append order, before this resolves genuinely overlapping ranges. +---@param diffs fa.diff[] +---@return fa.diff[] +local function resolveOverlappingDiffs(diffs) + table.sort(diffs, function(a, b) return a.start < b.start end) + local result = {} + local claimedUntil = 0 + for _, d in ipairs(diffs) do + if d.start > claimedUntil then + result[#result + 1] = d + claimedUntil = math.max(claimedUntil, d.finish) + end + end + return result +end + +--- Runs every scanner over `text` (hash-comments, table-hints, class-support, for-in-pairs, in +--- that order - see each module's own header for what it fixes), then export-env's module-wrap +--- pass, then merges everything into one diff list for LuaLS to apply. Order only matters where +--- two scanners could plausibly touch the same text; otherwise each runs independently against +--- the *original* `text`, never against another scanner's output. +---@param uri string +---@param text string +---@return nil|fa.diff[] +function OnSetText(uri, text) + -- Any hook file(s) that target ME (the reverse of the hookTarget lookup below) - a mod's + -- hook file shares the target's chunk/scope at runtime (MODS.LUA:150-198), so `import()`ing + -- this file's own path should see the union of my own top-level declarations and every + -- applicable hook's. See "Hook files don't merge into import() targets" for the full + -- reasoning; without this, only a hook file's *own* OnSetText ever knew about its target + -- (one direction only), never the other way around. + local hooksApplyingToMe = hookIndex[uri] + local hookTexts = {} + if hooksApplyingToMe then + for _, hookUri in ipairs(hooksApplyingToMe) do + local hookText = files.getOriginText(hookUri) + if hookText then + -- Same BOM concern as the target-side handling below. + hookTexts[#hookTexts + 1] = hookText:gsub('^\239\187\191', '') + end + end + end + + -- Computed up front, before classSupport runs, because it needs to know which bare names + -- are this file's own exports - see the comment on the base-class keep-alive inside + -- class-support.lua's stripWrappers for why. Combined across this file AND every hook that + -- targets it (if any), not just `text` alone - a name a hook adds or overrides is exactly as + -- much a real top-level declaration of the combined runtime chunk as one this file defines + -- itself. + local allExports, seenExport, unsafeNamesUnion = {}, {}, {} + local hasTopReturn, topLevelLocalCount = false, 0 + local scanParts = { text } + for _, hookText in ipairs(hookTexts) do + scanParts[#scanParts + 1] = hookText + end + for _, part in ipairs(scanParts) do + local exports, partHasTopReturn, partTopLevelLocalCount, unsafeNames = exportEnv.scan(part) + hasTopReturn = hasTopReturn or partHasTopReturn + topLevelLocalCount = topLevelLocalCount + partTopLevelLocalCount + for _, name in ipairs(exports) do + if not seenExport[name] then + seenExport[name] = true + allExports[#allExports + 1] = name + end + if unsafeNames[name] then + unsafeNamesUnion[name] = true + end + end + end + local safeNames = {} + local unsafeList = {} + for _, name in ipairs(allExports) do + if unsafeNamesUnion[name] then + unsafeList[#unsafeList + 1] = name + else + safeNames[name] = true + end + end + + local diffs = {} + for _, diff in ipairs(hashComments.stripHashComments(text)) do + diffs[#diffs + 1] = diff + end + + for _, diff in ipairs(tableHints.stripHints(text)) do + diffs[#diffs + 1] = diff + end + + for _, diff in ipairs(classSupport.stripWrappers(text, safeNames)) do + diffs[#diffs + 1] = diff + end + + for _, diff in ipairs(forInPairs.wrapBareIterators(text)) do + diffs[#diffs + 1] = diff + end + + -- Hook files (MODS.LUA:150-198): the mod's file is concatenated to the END of the target + -- at runtime, sharing its top-level scope. We reproduce that by prepending the target's + -- content here. Deliberately use the target's RAW text (files.getOriginText), not its own + -- transformed text: the target's own export-env pass would have turned its top-level names + -- into locals scoped to nothing but itself, plus appended a `return {...}` - concatenated + -- in front of the hook's own code, a mid-chunk `return` is an actual syntax error. Raw text + -- keeps the target's names as the real globals the hook is supposed to see, and we still + -- run the (non-scope-changing) table-hints/class-support/for-in-pairs passes over it so + -- `Cls = Class(oldCls) {...}`-style overrides still get proper class typing. + local hookTarget = hookFiles.findTarget(uri, text) + if hookTarget then + local targetUri + for _, candidate in ipairs(findUrisBySuffix(uri, hookTarget)) do + if candidate ~= uri then + targetUri = candidate + break + end + end + -- Register myself into the reverse hookIndex right here, reusing the targetUri just + -- resolved above - this is the ONLY place a hook file's relationship to its target gets + -- recorded (see hookIndex's own comment for why this incremental approach, not an eager + -- scan). A later OnSetText call for `targetUri` (its own, or triggered by re-opening it) + -- will then see me in `hooksApplyingToMe` and merge my content in. + if targetUri then + hookIndex[targetUri] = hookIndex[targetUri] or {} + local alreadyRegistered = false + for _, existingHookUri in ipairs(hookIndex[targetUri]) do + if existingHookUri == uri then + alreadyRegistered = true + break + end + end + if not alreadyRegistered then + hookIndex[targetUri][#hookIndex[targetUri] + 1] = uri + end + end + local targetText = targetUri and files.getOriginText(targetUri) + if targetText then + -- script/encoder/init.lua's decode() is a no-op for utf8, so a target file with a + -- literal UTF-8 BOM (confirmed on loc/*/strings_db.lua, e.g.) keeps those 3 raw + -- bytes in files.getOriginText's result. Harmless at a real position-1 file start + -- (editors strip it before it ever reaches didOpen), but here it'd land mid-stream, + -- right where the target's first real line is expected - not valid Lua syntax + -- anywhere but the very start of a file, so it has to go. + targetText = targetText:gsub('^\239\187\191', '') + local targetDiffs = {} + for _, d in ipairs(hashComments.stripHashComments(targetText)) do + targetDiffs[#targetDiffs + 1] = d + end + for _, d in ipairs(tableHints.stripHints(targetText)) do + targetDiffs[#targetDiffs + 1] = d + end + for _, d in ipairs(classSupport.stripWrappers(targetText)) do + targetDiffs[#targetDiffs + 1] = d + end + for _, d in ipairs(forInPairs.wrapBareIterators(targetText)) do + targetDiffs[#targetDiffs + 1] = d + end + local transformedTarget = smerger.mergeDiff(targetText, resolveOverlappingDiffs(mergeSameStartDiffs(targetDiffs))) + diffs[#diffs + 1] = { + start = 1, + finish = 0, + text = '---@diagnostic disable\n' .. transformedTarget .. '\n---@diagnostic enable\n', + } + end + end + + -- The reverse direction: hook(s) that target ME (hooksApplyingToMe/hookTexts, computed at + -- the top of this function) get their own transformed content appended to the END of mine - + -- matching the real engine's own concatenation order (MODS.LUA:150-198) and the exports + -- computed above. Unlike the forward (hookTarget) case above, this DOES run + -- exportEnv.rewriteReferences on the hook's own text (using the same combined `safeNames` + -- this file uses) - so a hook's own override or addition becomes part of the SAME shared `M` + -- below, not a separate one, which is the entire point of this fix: importing this file's + -- own path should see the union. + for _, hookText in ipairs(hookTexts) do + local hookDiffs = {} + for _, d in ipairs(hashComments.stripHashComments(hookText)) do + hookDiffs[#hookDiffs + 1] = d + end + for _, d in ipairs(tableHints.stripHints(hookText)) do + hookDiffs[#hookDiffs + 1] = d + end + for _, d in ipairs(classSupport.stripWrappers(hookText, safeNames)) do + hookDiffs[#hookDiffs + 1] = d + end + for _, d in ipairs(forInPairs.wrapBareIterators(hookText)) do + hookDiffs[#hookDiffs + 1] = d + end + for _, d in ipairs(exportEnv.rewriteReferences(hookText, safeNames)) do + hookDiffs[#hookDiffs + 1] = d + end + local transformedHook = smerger.mergeDiff(hookText, resolveOverlappingDiffs(mergeSameStartDiffs(hookDiffs))) + diffs[#diffs + 1] = { + start = #text + 1, + finish = #text, + text = '\n---@diagnostic disable\n' .. transformedHook .. '\n---@diagnostic enable\n', + } + end + + if #allExports > 0 and not hasTopReturn then + -- Turn the file into a classic module: `local M = {}`, every export becomes + -- `M.Name = ...`, `return M`. Unlike forward-declared locals, `M.Name` field + -- resolution isn't restricted to "last assignment before this textual position" - it + -- resolves correctly from inside an earlier-defined function's body too, and doesn't + -- consume any of Lua 5.1's 200-local-per-chunk budget (see export-env.lua's header for + -- the full reasoning and the vm.getTableValue verification behind it). + -- + -- A name that's shadowed anywhere in the file (a local/param/loop-var of the same + -- name - `unsafeNames`, from exportEnv.scan) can't safely have every *reference* + -- rewritten to `M.Name`, since a text scanner can't otherwise tell which bare + -- occurrence means what inside that shadowing scope (confirmed real, not just + -- theoretical: lua/system/profile.lua's `checkpoint` export is shadowed by + -- `function checkpoint_to_table(checkpoint)`'s own parameter). Those names keep + -- today's exact behaviour instead - forward-declared as a real `local`, then bridged + -- into `M` with one `M.Name = Name` assignment before `return M` - so import() + -- consumers still see a single, consistent table either way. Verified empirically + -- negligible: 57 names total, across 24 files, in the whole fa repo's 102,668 + -- top-level exports. + -- + -- `safeNames`/`unsafeList` were already computed at the top of this function, combined + -- across this file and any hook that targets it (before classSupport ran, which needs + -- `safeNames` too). + + -- Appended to `diffs` BEFORE the reference-rewrite diffs below, not after: if the file's + -- very first byte is itself the start of a safe export's name (no leading comment/blank + -- line), both this diff and that name's own `M.`-prefix diff target the same position-1 + -- insertion point, and mergeSameStartDiffs concatenates same-start diffs in append order - + -- this header must land first, or the output would start with `M.local M = {}...`. + local header = 'local M = {}\n' + if #unsafeList > 0 then + -- Same 200-local-cap safety margin as before, now scoped to just the unsafe + -- subset - in practice always small enough that this never trips. + local MAX_TOTAL_TOP_LEVEL_LOCALS = 190 + if (#unsafeList + topLevelLocalCount) <= MAX_TOTAL_TOP_LEVEL_LOCALS then + header = header .. 'local ' .. table.concat(unsafeList, ', ') .. '\n' + end + end + diffs[#diffs + 1] = { + start = 1, + finish = 0, + text = header, + } + + for _, diff in ipairs(exportEnv.rewriteReferences(text, safeNames)) do + diffs[#diffs + 1] = diff + end + + local footer = { '\n' } + for _, name in ipairs(unsafeList) do + footer[#footer + 1] = ('M.%s = %s\n'):format(name, name) + end + footer[#footer + 1] = 'return M\n' + diffs[#diffs + 1] = { + start = #text + 1, + finish = #text, + text = table.concat(footer), + } + end + + return resolveOverlappingDiffs(mergeSameStartDiffs(diffs)) +end + +-- import()/doscript() take root-relative paths like '/lua/sim/Weapon.lua', optionally without +-- the extension and with inconsistent case/slash direction. Only take over resolution for +-- names that look like a path; anything else (plain `require 'foo'`) falls through untouched +-- by returning nil. +---@param uri string +---@param name string +---@param suri string +---@return uri[]|nil +function ResolveRequire(uri, name, suri) + if not name:find('[/\\]') then + return nil + end + + local results = findUrisBySuffix(uri, name) + -- A hook file (`/hook/lua/sim/SomeFile.lua`) mirrors the target's own path suffix + -- (`/lua/sim/SomeFile.lua`), so it always matches too - but nobody writes `import()`s + -- pointing at a hookdir path; every import means "give me the target's module", hooks + -- included (see OnSetText's hooksApplyingToMe/hookIndex handling above for the merge + -- itself). Without this filter, which candidate "wins" - target or hook - depends on + -- files.eachFile's iteration order, not anything meaningful (confirmed: LuaLS only ever + -- uses the first entry of a multi-candidate ResolveRequire result, never a union). + local filtered = {} + for _, candidateUri in ipairs(results) do + local candidateText = files.getOriginText(candidateUri) + if not (candidateText and hookFiles.findTarget(candidateUri, candidateText)) then + filtered[#filtered + 1] = candidateUri + end + end + if #filtered > 0 then + return filtered + end + if #results > 0 then + return results + end + return nil +end diff --git a/lua-ls-addon/table-hints.lua b/lua-ls-addon/table-hints.lua new file mode 100644 index 0000000000..91193b022f --- /dev/null +++ b/lua-ls-addon/table-hints.lua @@ -0,0 +1,111 @@ +-- Strips SupCom's `{&N &N ...}` table-preallocation hints, which aren't valid Lua 5.1 syntax +-- at all. +-- +-- SupCom's Lua fork allows a table constructor to start with preallocation hints, +-- e.g. `{&15 &4}` = preallocate 15 hash-part slots, 4 array-part slots. `&` isn't a +-- Lua 5.1 token at all (no bitwise operators in 5.1), so this is a genuine parse +-- error, not just a diagnostic - has to be stripped from the source text before the +-- real parser ever sees it, same as the `#` comment handling. +-- +-- Assumed grammar (only a prefix, right after `{`; count and separator can vary): +-- one or more `&` tokens, each optionally followed by whitespace and/or a +-- single `,`/`;`, ending wherever the next `&` doesn't follow. The hints +-- carry no type/value information, so the whole span is just blanked out. + +local M = {} + +---@class fa.diff +---@field start integer +---@field finish integer +---@field text string + +---@param text string +---@param i integer position of '[' +---@param n integer +---@return integer? closeEnd +local function skipLongBracket(text, i, n) + local eqs = text:match('^%[(=*)%[', i) + if not eqs then + return nil + end + local _, closeEnd = text:find(']' .. eqs .. ']', i + 2 + #eqs, true) + return closeEnd and (closeEnd + 1) or (n + 1) +end + +---@param text string +---@return fa.diff[] +function M.stripHints(text) + local n = #text + local i = 1 + local diffs = {} + + while i <= n do + local c = text:sub(i, i) + + if c == '-' and text:sub(i, i + 1) == '--' then + local afterDashes = i + 2 + local eqs = text:match('^%[(=*)%[', afterDashes) + if eqs then + local _, closeEnd = text:find(']' .. eqs .. ']', afterDashes + 2 + #eqs, true) + i = closeEnd and (closeEnd + 1) or (n + 1) + else + local nl = text:find('\n', i, true) + i = nl and (nl + 1) or (n + 1) + end + + elseif c == '"' or c == "'" then + local quote = c + local j = i + 1 + while j <= n do + local cj = text:sub(j, j) + if cj == '\\' then + j = j + 2 + elseif cj == quote then + j = j + 1 + break + else + j = j + 1 + end + end + i = j + + elseif c == '[' then + i = skipLongBracket(text, i, n) or (i + 1) + + elseif c == '{' then + local k = i + 1 + local sawHint = false + while true do + local _, we1 = text:find('^%s*', k) + k = we1 + 1 + local hs, he = text:find('^&%d+', k) + if not hs then + break + end + sawHint = true + k = he + 1 + local _, we2 = text:find('^%s*', k) + k = we2 + 1 + local _, ce = text:find('^[,;]', k) + if ce then + k = ce + 1 + end + end + if sawHint then + diffs[#diffs + 1] = { + start = i + 1, + finish = k - 1, + text = (' '):rep(k - i - 1), + } + end + i = i + 1 + + else + i = i + 1 + end + end + + return diffs +end + +return M