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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 61 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
"activationEvents": [
"workspaceContains:**/*.rpy",
"workspaceContains:**/_ren.py",
"onDebugResolve:renpy"
"onDebugResolve:renpy",
"onStartupFinished"
],
"main": "./dist/extension",
"browser": "./dist/extension.js",
Expand Down Expand Up @@ -311,7 +312,65 @@
"renpy.renpyExecutableLocation": {
"type": "string",
"default": "",
"description": "Location of Ren'Py installation. Should be .exe on Windows and .sh on Mac/Linux."
"description": "Ren'Py launch command. You can provide an executable path (renpy.exe on Windows, renpy.sh on Linux/macOS), or an executable plus startup args (for example: /path/lib/py3-windows-x86_64/python.exe renpy.py)."
},
"renpy.testing.renpyExecutableOverride": {
"type": "string",
"default": "",
"description": "Override Ren'Py launch command used for test runs. Supports executable plus startup args. If empty, 'renpy.renpyExecutableLocation' is used."
},
"renpy.testing.reporters": {
"type": "string",
"default": "console",
"description": "Comma-separated reporter list passed to --reporter for test runs. Use 'console' by default. `jsonl` is always included to allow the Test Explorer integration to function."
},
"renpy.testing.jsonlOutputPath": {
"type": "string",
"default": "",
"description": "Directory for per-run JSONL stream output files. Defaults to the system temp directory."
},
"renpy.testing.jsonlPollInterval": {
"type": "number",
"default": 500,
"minimum": 100,
"maximum": 5000,
"description": "Polling interval in milliseconds for reading JSONL stream events during test runs."
},
"renpy.testing.jsonlBatchSize": {
"type": "number",
"default": 50,
"minimum": 1,
"maximum": 500,
"description": "Number of test result events per batch emitted by the JSONL stream reporter."
},
"renpy.testing.jsonlFlushInterval": {
"type": "number",
"default": 1000,
"minimum": 100,
"maximum": 10000,
"description": "Reporter flush interval in milliseconds for JSONL stream output."
},
"renpy.testing.discoveryRefreshMode": {
"type": "string",
"default": "cache-first",
"enum": ["always-refresh", "cache-first"],
"enumDescriptions": [
"Always run Ren'Py compile/dump before discovery to get fresh metadata.",
"Use existing navigation.json if present and fresh; refresh only when stale."
],
"description": "Controls when navigation.json is refreshed during test discovery."
},
"renpy.testing.jsonlStalledTimeout": {
"type": "number",
"default": 5000,
"minimum": 1000,
"maximum": 60000,
"description": "Stalled-run timeout in milliseconds. If no event is received within this period while the process is running, the run is marked as an infrastructure error."
},
"renpy.testing.showWindow": {
"type": "boolean",
"default": false,
"description": "Shows the game window during test runs. If false, the window will be hidden to allow tests to run without user interaction."
}
}
}
Expand Down
42 changes: 20 additions & 22 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

import { cleanUpPath, getAudioFolder, getImagesFolder, getNavigationJsonFilepath, getWorkspaceFolder, stripWorkspaceFromFile } from "src/utilities";

import { registerTestingController } from "./testing/controller";
import { registerDebugDecorator, unregisterDebugDecorator } from "./tokenizer/debug-decorator";
import { Tokenizer } from "./tokenizer/tokenizer";
import { registerColorProvider } from "./color";
Expand All @@ -38,6 +39,7 @@
import { getStatusBarText, NavigationData } from "./navigation-data";
import { registerSymbolProvider } from "./outline";
import { registerReferencesProvider } from "./references";
import { buildRenpySpawnConfiguration, isValidExecutable } from "./renpy-command";
import { registerSemanticTokensProvider } from "./semantics";
import { registerSignatureProvider } from "./signature";
import { RenpyTaskProvider } from "./task-provider";
Expand Down Expand Up @@ -298,6 +300,9 @@
const taskProvider = new RenpyTaskProvider();
context.subscriptions.push(tasks.registerTaskProvider("renpy", taskProvider));

// Register Ren'Py Test Explorer controller.
registerTestingController(context);

logMessage(LogLevel.Info, "Ren'Py extension activated!");
}

Expand Down Expand Up @@ -332,12 +337,6 @@
return;
}

export function isValidExecutable(renpyExecutableLocation: string): boolean {
if (!renpyExecutableLocation || renpyExecutableLocation === "") {
return false;
}
return fs.existsSync(renpyExecutableLocation);
}
// Attempts to run renpy executable through console commands.
export function RunWorkspaceFolder(): boolean {
const childProcess = ExecuteRunpyRun();
Expand Down Expand Up @@ -368,38 +367,37 @@
export function ExecuteRunpyRun(): cp.ChildProcessWithoutNullStreams | null {
const rpyPath = Configuration.getRenpyExecutablePath();

if (!isValidExecutable(rpyPath)) {
const workFolder = getWorkspaceFolder();
const spawnConfig = buildRenpySpawnConfiguration(rpyPath, [`${workFolder}`, "run"]);
if (spawnConfig == null || !isValidExecutable(rpyPath)) {
return null;
}

const renpyPath = cleanUpPath(Uri.file(rpyPath).path);
const cwd = renpyPath.substring(0, renpyPath.lastIndexOf("/"));
const workFolder = getWorkspaceFolder();
const args: string[] = [`${workFolder}`, "run"];
return cp.spawn(rpyPath, args, {
cwd: `${cwd}`,
return cp.spawn(spawnConfig.command, spawnConfig.args, {
cwd: spawnConfig.cwd,
env: { PATH: process.env.PATH },
});
}

function ExecuteRenpyCompile(): boolean {
const rpyPath = Configuration.getRenpyExecutablePath();
export function ExecuteRenpyCompile(rpyPath: string = ""): boolean {
if (!rpyPath) {
rpyPath = Configuration.getRenpyExecutablePath();

Check warning on line 384 in src/extension.ts

View workflow job for this annotation

GitHub Actions / Lint

Assignment to function parameter 'rpyPath'
}
if (isValidExecutable(rpyPath)) {
const renpyPath = cleanUpPath(Uri.file(rpyPath).path);
const cwd = renpyPath.substring(0, renpyPath.lastIndexOf("/"));

let wf = getWorkspaceFolder();
if (wf.endsWith("/game")) {
wf = wf.substring(0, wf.length - 5);
}
const navData = getNavigationJsonFilepath();
//const args = `${wf} compile --json-dump ${navData}`;
const args: string[] = [`${wf}`, "compile", "--json-dump", `${navData}`];
const spawnConfig = buildRenpySpawnConfiguration(rpyPath, [`${wf}`, "compile", "--json-dump", `${navData}`]);
if (spawnConfig == null) {
return false;
}
try {
NavigationData.isCompiling = true;
updateStatusBar("$(sync~spin) Compiling Ren'Py navigation data...");
const result = cp.spawnSync(rpyPath, args, {
cwd: `${cwd}`,
const result = cp.spawnSync(spawnConfig.command, spawnConfig.args, {
cwd: spawnConfig.cwd,
env: { PATH: process.env.PATH },
encoding: "utf-8",
windowsHide: true,
Expand Down
113 changes: 113 additions & 0 deletions src/renpy-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import * as fs from "fs";
import * as path from "path";

const MAX_PARENT_LOOKUP = 6;

export interface RenpySpawnConfiguration {
command: string;
args: string[];
cwd?: string;
}

function splitCommandLine(commandLine: string): string[] {
const tokens: string[] = [];
let current = "";
let quoteChar: string | null = null;

for (let i = 0; i < commandLine.length; i++) {
const char = commandLine[i];

if ((char === '"' || char === "'") && quoteChar == null) {
quoteChar = char;
continue;
}

if (char === quoteChar) {
quoteChar = null;
continue;
}

if (/\s/.test(char) && quoteChar == null) {
if (current.length > 0) {
tokens.push(current);
current = "";
}
continue;
}

current += char;
}

if (current.length > 0) {
tokens.push(current);
}

return tokens;
}

function isPathLike(command: string): boolean {
return path.isAbsolute(command) || command.includes("/") || command.includes("\\");
}

function resolveWorkingDirectory(command: string, preArgs: string[]): string | undefined {
if (!path.isAbsolute(command)) {
return undefined;
}

const commandDir = path.dirname(command);
const firstArg = preArgs[0];
if (!firstArg || path.isAbsolute(firstArg) || !firstArg.toLowerCase().endsWith(".py")) {
return commandDir;
}

let current = commandDir;
for (let i = 0; i < MAX_PARENT_LOOKUP; ++i) {
if (fs.existsSync(path.join(current, firstArg))) {
return current;
}

const parent = path.dirname(current);
if (parent === current) {
break;
}
current = parent;
}

return commandDir;
}

export function buildRenpySpawnConfiguration(executableOverride: string, args: string[]): RenpySpawnConfiguration | null {
const tokens = splitCommandLine(executableOverride.trim());
if (tokens.length === 0) {
return null;
}

const command = tokens[0];
const preArgs = tokens.slice(1);
const cwd = resolveWorkingDirectory(command, preArgs);

const config: RenpySpawnConfiguration = {
command,
args: [...preArgs, ...args],
};

if (cwd !== undefined) {
config.cwd = cwd;
}

return config;
}

export function isValidExecutable(executableOverride: string): boolean {
const spawnConfig = buildRenpySpawnConfiguration(executableOverride, []);
if (spawnConfig == null || spawnConfig.command.length === 0) {
return false;
}

if (!isPathLike(spawnConfig.command)) {
// Allow commands resolved by PATH, like "python".
return true;
}

return fs.existsSync(spawnConfig.command);
}
8 changes: 7 additions & 1 deletion src/task-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as vscode from "vscode";
import { getWorkspaceFolder } from "src/utilities";

import { Configuration } from "./configuration";
import { buildRenpySpawnConfiguration } from "./renpy-command";

interface RenpyTaskDefinition extends vscode.TaskDefinition {
command: string;
Expand Down Expand Up @@ -44,12 +45,17 @@ export class RenpyTaskProvider implements vscode.TaskProvider {
args.push(...definition.args);
}

const spawnConfig = buildRenpySpawnConfiguration(Configuration.getRenpyExecutablePath(), args);
const command = spawnConfig?.command ?? Configuration.getRenpyExecutablePath();
const finalArgs = spawnConfig?.args ?? args;
const options = spawnConfig?.cwd != null ? { cwd: spawnConfig.cwd } : undefined;

return new vscode.Task(
definition,
vscode.TaskScope.Workspace,
definition.command,
"renpy",
new vscode.ShellExecution(Configuration.getRenpyExecutablePath(), args)
new vscode.ShellExecution(command, finalArgs, options)
);
}
}
Loading
Loading