diff --git a/changes/lua-plugin-parser-compat.md b/changes/lua-plugin-parser-compat.md new file mode 100644 index 00000000..70dc19ea --- /dev/null +++ b/changes/lua-plugin-parser-compat.md @@ -0,0 +1,4 @@ +type: fixed +area: mpv-plugin + +- Fixed the mpv Lua plugin so hover and environment modules no longer use the `goto continue` pattern that can fail to parse on some user Lua runtimes. diff --git a/package.json b/package.json index 4dc21d26..2cce9017 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/integrations.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts src/generate-config-example.test.ts src/verify-config-example.test.ts", "test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/integrations.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js dist/generate-config-example.test.js dist/verify-config-example.test.js", "test:config:smoke:dist": "bun test dist/config/path-resolution.test.js", - "test:plugin:src": "lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-binary-windows.lua", + "test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-binary-windows.lua", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts", diff --git a/plugin/subminer/environment.lua b/plugin/subminer/environment.lua index 3aa6f796..a6e4d20c 100644 --- a/plugin/subminer/environment.lua +++ b/plugin/subminer/environment.lua @@ -55,33 +55,26 @@ function M.create(ctx) if not image then image = line:match('^"([^"]+)"') end - if not image then - goto continue - end - if image == "subminer" or image == "subminer.exe" or image == "subminer.appimage" or image == "subminer.app" then - return true - end - if image:find("subminer", 1, true) and not image:find(".lua", 1, true) then - return true + if image then + if image == "subminer" or image == "subminer.exe" or image == "subminer.appimage" or image == "subminer.app" then + return true + end + if image:find("subminer", 1, true) and not image:find(".lua", 1, true) then + return true + end end else local argv0 = line:match('^"([^"]+)"') or line:match("^%s*([^%s]+)") - if not argv0 then - goto continue - end - if argv0:find("subminer.lua", 1, true) or argv0:find("subminer.conf", 1, true) then - goto continue - end - local exe = argv0:match("([^/\\]+)$") or argv0 - if exe == "SubMiner" or exe == "SubMiner.AppImage" or exe == "SubMiner.exe" or exe == "subminer" or exe == "subminer.appimage" or exe == "subminer.exe" then - return true - end - if exe:find("subminer", 1, true) and exe:find("%.lua", 1, true) == nil and exe:find("%.app", 1, true) == nil then - return true + if argv0 and not argv0:find("subminer.lua", 1, true) and not argv0:find("subminer.conf", 1, true) then + local exe = argv0:match("([^/\\]+)$") or argv0 + if exe == "SubMiner" or exe == "SubMiner.AppImage" or exe == "SubMiner.exe" or exe == "subminer" or exe == "subminer.appimage" or exe == "subminer.exe" then + return true + end + if exe:find("subminer", 1, true) and exe:find("%.lua", 1, true) == nil and exe:find("%.app", 1, true) == nil then + return true + end end end - - ::continue:: end return false end diff --git a/plugin/subminer/hover.lua b/plugin/subminer/hover.lua index 6a24e414..5d4ab106 100644 --- a/plugin/subminer/hover.lua +++ b/plugin/subminer/hover.lua @@ -189,41 +189,37 @@ function M.create(ctx) local source_len = #plain local cursor = 1 for _, token in ipairs(payload.tokens or {}) do - if type(token) ~= "table" or type(token.text) ~= "string" or token.text == "" then - goto continue - end + if type(token) == "table" and type(token.text) == "string" and token.text ~= "" then + local token_text = token.text + local start_pos = nil + local end_pos = nil - local token_text = token.text - local start_pos = nil - local end_pos = nil - - if type(token.startPos) == "number" and type(token.endPos) == "number" then - if token.startPos >= 0 and token.endPos >= token.startPos then - local candidate_start = token.startPos + 1 - local candidate_stop = token.endPos - if candidate_start >= 1 and candidate_stop <= source_len and candidate_stop >= candidate_start and plain:sub(candidate_start, candidate_stop) == token_text then - start_pos = candidate_start - end_pos = candidate_stop + if type(token.startPos) == "number" and type(token.endPos) == "number" then + if token.startPos >= 0 and token.endPos >= token.startPos then + local candidate_start = token.startPos + 1 + local candidate_stop = token.endPos + if candidate_start >= 1 and candidate_stop <= source_len and candidate_stop >= candidate_start and plain:sub(candidate_start, candidate_stop) == token_text then + start_pos = candidate_start + end_pos = candidate_stop + end end end - end - if not start_pos or not end_pos then - local fallback_start, fallback_stop = plain:find(token_text, cursor, true) - if not fallback_start then - fallback_start, fallback_stop = plain:find(token_text, 1, true) + if not start_pos or not end_pos then + local fallback_start, fallback_stop = plain:find(token_text, cursor, true) + if not fallback_start then + fallback_start, fallback_stop = plain:find(token_text, 1, true) + end + start_pos, end_pos = fallback_start, fallback_stop end - start_pos, end_pos = fallback_start, fallback_stop - end - if start_pos and end_pos then - if token.index == payload.hoveredTokenIndex then - return start_pos, end_pos + if start_pos and end_pos then + if token.index == payload.hoveredTokenIndex then + return start_pos, end_pos + end + cursor = end_pos + 1 end - cursor = end_pos + 1 end - - ::continue:: end return nil diff --git a/scripts/test-plugin-lua-compat.lua b/scripts/test-plugin-lua-compat.lua new file mode 100644 index 00000000..45fc20fa --- /dev/null +++ b/scripts/test-plugin-lua-compat.lua @@ -0,0 +1,141 @@ +local MODULE_PATHS = { + "plugin/subminer/hover.lua", + "plugin/subminer/environment.lua", +} + +local LEGACY_PARSER_CANDIDATES = { + "luajit", + "lua5.1", + "lua51", +} + +local function assert_true(condition, message) + if condition then + return + end + error(message or "assert_true failed") +end + +local function read_file(path) + local file = assert(io.open(path, "r"), "failed to open " .. path) + local content = file:read("*a") + file:close() + return content +end + +local function find_legacy_incompatible_continue(source) + local goto_start, goto_end = source:find("%f[%a]goto%s+continue%f[%A]") + if goto_start then + return "goto continue", goto_start, goto_end + end + + local label_start, label_end = source:find("::continue::", 1, true) + if label_start then + return "::continue::", label_start, label_end + end + + return nil +end + +local function assert_no_legacy_incompatible_continue(path) + local source = read_file(path) + local match = find_legacy_incompatible_continue(source) + assert_true(match == nil, path .. " still contains legacy-incompatible continue control flow: " .. tostring(match)) +end + +local function assert_loadfile_ok(path) + local chunk, err = loadfile(path) + assert_true(chunk ~= nil, "loadfile failed for " .. path .. ": " .. tostring(err)) +end + +local function normalize_execute_result(ok, why, code) + if type(ok) == "number" then + return ok == 0, ok + end + if type(ok) == "boolean" then + if ok then + return true, code or 0 + end + return false, code or 1 + end + return false, code or 1 +end + +local function command_succeeds(command) + local ok, why, code = os.execute(command) + return normalize_execute_result(ok, why, code) +end + +local function command_exists(command) + local shell = package.config:sub(1, 1) == "\\" and "where " or "command -v " + local redirect = package.config:sub(1, 1) == "\\" and " >NUL 2>NUL" or " >/dev/null 2>&1" + local escaped = command + local success = command_succeeds(shell .. escaped .. redirect) + return success +end + +local function find_legacy_parser() + for _, command in ipairs(LEGACY_PARSER_CANDIDATES) do + if command_exists(command) then + return command + end + end + return nil +end + +local function shell_redirect() + if package.config:sub(1, 1) == "\\" then + return " >NUL 2>NUL" + end + return " >/dev/null 2>&1" +end + +local function assert_parser_accepts_file(parser, path) + local command = string.format("%s -e %q%s", parser, "assert(loadfile(" .. string.format("%q", path) .. "))", shell_redirect()) + local success = command_succeeds(command) + assert_true(success, parser .. " failed to parse " .. path) +end + +local function assert_parser_rejects_legacy_fixture(parser) + local legacy_fixture = [[ +local tokens = {} +for _, token in ipairs(tokens or {}) do + if type(token) ~= "table" then + goto continue + end + ::continue:: +end +]] + local command = string.format("%s -e %q%s", parser, legacy_fixture, shell_redirect()) + local success = command_succeeds(command) + assert_true(not success, parser .. " unexpectedly accepted legacy goto/label continue fixture") +end + +do + local legacy_fixture = [[ +for _, token in ipairs(tokens or {}) do + if type(token) ~= "table" then + goto continue + end + ::continue:: +end +]] + local match = find_legacy_incompatible_continue(legacy_fixture) + assert_true(match ~= nil, "legacy fixture should trigger incompatible continue detector") +end + +for _, path in ipairs(MODULE_PATHS) do + assert_no_legacy_incompatible_continue(path) + assert_loadfile_ok(path) +end + +local parser = find_legacy_parser() +if parser then + assert_parser_rejects_legacy_fixture(parser) + for _, path in ipairs(MODULE_PATHS) do + assert_parser_accepts_file(parser, path) + end + print("plugin lua compatibility regression tests: OK (" .. parser .. ")") +else + print("plugin lua compatibility regression tests: OK (legacy parser unavailable; structural checks only)") +end