Sprinter: добавить отладку C-исходников и интеграцию VS Code

This commit is contained in:
2026-09-15 17:58:41 +03:00
parent 50c6e56b7b
commit e4695b8281
62 changed files with 7147 additions and 27 deletions
+81
View File
@@ -0,0 +1,81 @@
# Sprinter MAME Debug
VS Code-клиент сборки и отладки для `toolchain/sdbg_dap.py`. Режим `launch` сам
поднимает изолированные MAME и session server; перед `attach` их запускают
вручную.
> [!WARNING]
> Native Windows пока не поддерживает полный launch/attach: `windows`
> выбирает только debugger provider MAME, а host-часть всё ещё зависит от
> `fcntl`, Unix domain sockets и Unix launcher.
Для разработки расширение можно открыть отдельным окном VS Code и запустить
Extension Development Host. Конфигурация проекта:
```sh
code --extensionDevelopmentPath="$PWD/toolchain/vscode-sprinter-debug" "$PWD"
```
Python выбирается без зависимости от `PATH` GUI: при наличии local
`.python-version` используется `~/.pyenv/shims/python`; для внешнего workspace
без неё расширение ищет установленный `~/.pyenv/versions/3.12*/bin/python`.
Версия local передаётся задаче через `PYENV_VERSION`, поэтому сборка работает
и если `project` лежит вне workspace. При необходимости задайте абсолютный путь через
`sprinterDebugger.pythonCommand`. Выбранные пути печатаются в канале Output
`Sprinter MAME Debug`.
```json
{
"type": "sprinter-mame",
"request": "launch",
"name": "Sprinter MAME: Launch",
"build": "${workspaceFolder}/tests/hello/.sprinter-cc-hello"
}
```
Перед F5 расширение по умолчанию находит Makefile проекта по пути `build` и
выполняет задачу `make SRC_DEBUG=1` в каталоге проекта через local Python 3.12.
Ошибки SDCC с файлом и строкой попадают в Problems. При ненулевом коде
сборки debug launch отменяется до старта MAME. Для нестандартной раскладки
укажите `"project": "${workspaceFolder}/path/to/app"`; для уже собранного
пакета можно задать `"autoBuild": false`. Явный `preLaunchTask` остаётся под
контролем стандартного механизма VS Code и отключает автоматическую задачу
расширения.
Команда палитры `Sprinter: Build Active Project` строит проект открытого
C-файла. В `Tasks: Run Task` доступны задачи `Sprinter: Build ...` для
Makefile, включающих `app.mk`. Сборка не заменяет исходный `make` и не пишет
в общий образ дискеты MAME.
По умолчанию patched backend `sdbg` не открывает отдельное окно debugger MAME.
Чтобы пользоваться им одновременно с VS Code, добавьте в launch:
```json
"debugger": "osx"
```
Без project patch можно задать `auto`; явные native provider: `osx` на macOS,
`windows` в Windows, `qt` или `imgui` в Linux. Host-инструменты sdbg сейчас
рассчитаны на macOS/Linux (`fcntl`, Unix sockets и Unix launcher); native
Windows transport и end-to-end тесты ещё требуются.
Готовность DSS определяется по стабильному prompt в VRAM. `dssTimeout`
задаёт таймаут (по умолчанию 30 секунд), а `launchAt` — необязательное самое
раннее время ввода команды.
Ручной attach:
```json
{
"type": "sprinter-mame",
"request": "attach",
"name": "Sprinter MAME: Attach",
"socket": "/tmp/sprinter-sdbg.sock"
}
```
Сейчас доступны точки по исходнику/функции, `logMessage` с безопасными
подстановками `{variable}`, один проверенный frame, регистры, поддержанные
global/static, continue/pause и instruction step. Source step поддержан для
F10/F11/Shift+F11; банковский step-over и step-out проходят
служебные trampoline до следующей C-позиции.
+53
View File
@@ -0,0 +1,53 @@
const fs = require('fs');
const path = require('path');
function absolutePath(value, workspace) {
if (!value || !workspace) return null;
const expanded = value.replace(/\$\{workspaceFolder\}/g, workspace);
return path.resolve(workspace, expanded);
}
function projectForLaunch(configuration, workspace) {
const explicit = absolutePath(configuration.project, workspace);
if (explicit) {
if (!fs.existsSync(path.join(explicit, 'Makefile'))) {
throw new Error(`Нет Makefile в каталоге проекта: ${explicit}`);
}
return explicit;
}
const build = absolutePath(configuration.build, workspace);
if (!build) return null;
let directory = path.dirname(build);
while (directory !== path.dirname(directory)) {
if (fs.existsSync(path.join(directory, 'Makefile'))) {
return directory;
}
if (directory === workspace) break;
directory = path.dirname(directory);
}
return null;
}
function isSprinterMakefile(filename) {
try {
return /include\s+\$\(PROJ_ROOT\)\/app\.mk/.test(
fs.readFileSync(filename, 'utf8'));
} catch (_) {
return false;
}
}
function taskLabel(project, workspace) {
const relative = path.relative(workspace, project);
return `Sprinter: Build ${relative || '.'}`;
}
function makeCommand() {
if (process.platform === 'win32') {
throw new Error('Native Windows build/debug пока не поддерживается');
}
return fs.existsSync('/usr/bin/make') ? '/usr/bin/make' : 'make';
}
module.exports = {absolutePath, projectForLaunch, isSprinterMakefile,
taskLabel, makeCommand};
@@ -0,0 +1,40 @@
const assert = require('node:assert/strict');
const path = require('node:path');
const test = require('node:test');
const {projectForLaunch, isSprinterMakefile, taskLabel} =
require('./build');
const manifest = require('./package.json');
const workspace = path.resolve(__dirname, '..', '..');
const hello = path.join(workspace, 'tests', 'hello');
test('launch находит Makefile рядом с debug-пакетом', () => {
assert.equal(projectForLaunch({
build: '${workspaceFolder}/tests/hello/.sprinter-cc-hello',
}, workspace), hello);
assert.equal(taskLabel(hello, workspace), 'Sprinter: Build tests/hello');
assert.equal(isSprinterMakefile(path.join(hello, 'Makefile')), true);
});
test('пакет в build/ находит Makefile приложения уровнем выше', () => {
const project = path.join(workspace, 'applications', 'SprPoP');
assert.equal(projectForLaunch({
build: '${workspaceFolder}/applications/SprPoP/build/.sprinter-cc-sprpop',
}, workspace), project);
});
test('явный project без Makefile даёт ошибку вместо stale запуска', () => {
assert.throws(() => projectForLaunch({
project: 'tests/sdbg/fixtures', build: 'tests/hello/.sprinter-cc-hello',
}, workspace), /Нет Makefile/);
});
test('matcher разрешает относительную ошибку SDCC внутри проекта', () => {
const matcher = manifest.contributes.problemMatchers[0];
const line = 'hello.c:62: error 20: Undefined identifier \'missing_name\'';
const match = new RegExp(matcher.pattern.regexp).exec(line);
assert.deepEqual(match?.slice(1),
['hello.c', '62', 'error', '20',
"Undefined identifier 'missing_name'"]);
assert.equal(matcher.fileLocation, 'relative');
});
@@ -0,0 +1,195 @@
const path = require('path');
const vscode = require('vscode');
const {resolvePython, pyenvEnvironment} = require('./runtime');
const {projectForLaunch, isSprinterMakefile, taskLabel, makeCommand} =
require('./build');
class SprinterAdapterFactory {
constructor(output) {
this.output = output;
}
createDebugAdapterDescriptor(session) {
const settings = vscode.workspace.getConfiguration('sprinterDebugger');
const folder = session.workspaceFolder || vscode.workspace.workspaceFolders?.[0];
const configured = session.configuration.adapterPath;
if (!configured && !folder) {
throw new Error('Откройте workspace C-Compiler или задайте adapterPath');
}
const adapter = configured || path.join(folder.uri.fsPath, 'toolchain', 'sdbg_dap.py');
const runtime = resolvePython({
command: settings.get('pythonCommand', 'auto'),
args: settings.get('pythonArguments', []),
workspace: folder?.uri.fsPath,
});
const cwd = folder?.uri.fsPath || path.dirname(adapter);
this.output.appendLine(`Python: ${runtime.command}`);
this.output.appendLine(`DAP: ${adapter}`);
const options = {cwd};
const env = pyenvEnvironment(runtime.command, cwd);
if (env) options.env = env;
return new vscode.DebugAdapterExecutable(
runtime.command, [...runtime.args, adapter], options);
}
}
class SprinterTaskProvider {
createTask(folder, project, definitionOverride) {
const settings = vscode.workspace.getConfiguration('sprinterDebugger');
const runtime = resolvePython({
command: settings.get('pythonCommand', 'auto'),
args: settings.get('pythonArguments', []),
workspace: folder.uri.fsPath,
});
const relative = path.relative(folder.uri.fsPath, project);
const definition = definitionOverride ||
{type: 'sprinter', project: relative || '.'};
const options = {cwd: project};
const env = pyenvEnvironment(runtime.command, project) ||
pyenvEnvironment(runtime.command, folder.uri.fsPath);
if (env) options.env = env;
const task = new vscode.Task(
definition, folder, taskLabel(project, folder.uri.fsPath), 'sprinter',
new vscode.ProcessExecution(makeCommand(), [
'SRC_DEBUG=1', `PYTHON=${runtime.command}`,
], options), ['$sprinter-sdcc']);
task.group = vscode.TaskGroup.Build;
return task;
}
async provideTasks() {
const folders = vscode.workspace.workspaceFolders || [];
const results = [];
for (const folder of folders) {
const files = await vscode.workspace.findFiles(
new vscode.RelativePattern(folder, '**/Makefile'),
'**/{third_party,mame,libc,libbgi,toolchain}/**');
for (const file of files) {
if (isSprinterMakefile(file.fsPath)) {
results.push(this.createTask(folder, path.dirname(file.fsPath)));
}
}
}
return results;
}
resolveTask(task) {
const folder = task.scope?.uri ? task.scope :
vscode.workspace.workspaceFolders?.[0];
const project = task.definition.project;
if (!folder || typeof project !== 'string') return undefined;
const directory = path.resolve(folder.uri.fsPath, project);
if (!isSprinterMakefile(path.join(directory, 'Makefile'))) return undefined;
return this.createTask(folder, directory, task.definition);
}
}
async function runBuildTask(task) {
const early = [], ended = [];
let execution, resolveResult, completed = false;
const result = new Promise(resolve => {resolveResult = resolve;});
function finish(code) {
if (!completed) {
completed = true;
resolveResult(code);
}
}
const processListener = vscode.tasks.onDidEndTaskProcess(event => {
early.push(event);
if (execution && event.execution === execution) finish(event.exitCode);
});
const endListener = vscode.tasks.onDidEndTask(event => {
ended.push(event);
if (execution && event.execution === execution) {
// При отмене задачи process exit event может не появиться.
setTimeout(() => finish(undefined), 100);
}
});
try {
execution = await vscode.tasks.executeTask(task);
const finished = early.find(event => event.execution === execution);
if (finished) finish(finished.exitCode);
if (ended.some(event => event.execution === execution)) {
setTimeout(() => finish(undefined), 100);
}
return await result;
} finally {
processListener.dispose();
endListener.dispose();
}
}
class SprinterConfigurationProvider {
constructor(tasks, output) {
this.tasks = tasks;
this.output = output;
}
async resolveDebugConfiguration(folder, configuration) {
if (configuration.type !== 'sprinter-mame' ||
configuration.request !== 'launch' ||
configuration.autoBuild === false || configuration.preLaunchTask) {
return configuration;
}
const workspace = folder?.uri.fsPath ||
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspace) return configuration;
try {
const project = projectForLaunch(configuration, workspace);
if (project && isSprinterMakefile(path.join(project, 'Makefile'))) {
const scope = folder || vscode.workspace.workspaceFolders[0];
const task = this.tasks.createTask(scope, project);
this.output.appendLine(`Build: ${project}`);
const code = await runBuildTask(task);
if (code !== 0) {
vscode.window.showErrorMessage(
`Сборка Sprinter не прошла (код ${code ?? 'отмена'}); MAME не запущен`);
return undefined;
}
}
} catch (error) {
vscode.window.showErrorMessage(`Sprinter Build: ${error.message}`);
return undefined;
}
return configuration;
}
}
function activate(context) {
const output = vscode.window.createOutputChannel('Sprinter MAME Debug');
context.subscriptions.push(output);
context.subscriptions.push(vscode.debug.registerDebugAdapterDescriptorFactory(
'sprinter-mame', new SprinterAdapterFactory(output)));
const tasks = new SprinterTaskProvider();
context.subscriptions.push(vscode.tasks.registerTaskProvider('sprinter', tasks));
context.subscriptions.push(vscode.debug.registerDebugConfigurationProvider(
'sprinter-mame', new SprinterConfigurationProvider(tasks, output)));
context.subscriptions.push(vscode.commands.registerCommand('sprinter.buildActive', async () => {
const editor = vscode.window.activeTextEditor;
const folder = editor && vscode.workspace.getWorkspaceFolder(editor.document.uri);
if (!editor || !folder) {
vscode.window.showErrorMessage('Откройте C-файл проекта Sprinter');
return;
}
let directory = path.dirname(editor.document.uri.fsPath);
const root = folder.uri.fsPath;
while (directory !== path.dirname(directory)) {
if (isSprinterMakefile(path.join(directory, 'Makefile'))) {
try {
await vscode.tasks.executeTask(tasks.createTask(folder, directory));
} catch (error) {
vscode.window.showErrorMessage(`Sprinter Build: ${error.message}`);
}
return;
}
if (directory === root) break;
directory = path.dirname(directory);
}
vscode.window.showErrorMessage('Не найден проект Sprinter с app.mk');
}));
}
function deactivate() {}
module.exports = {activate, deactivate, SprinterTaskProvider,
SprinterConfigurationProvider, runBuildTask};
@@ -0,0 +1,134 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
test('F5 выполняет TaskProvider build и не запускает MAME после ошибки', async () => {
let factory, configurationProvider, taskProvider;
let onProcess, onEnd, exitCode = 0;
const errors = [];
const output = {appendLine() {}, dispose() {}};
class DebugAdapterExecutable {
constructor(command, args, options) {
Object.assign(this, {command, args, options});
}
}
class ProcessExecution {
constructor(command, args, options) {
Object.assign(this, {command, args, options});
}
}
class Task {
constructor(definition, scope, name, source, execution, problemMatchers) {
Object.assign(this, {definition, scope, name, source,
execution, problemMatchers});
}
}
class RelativePattern {
constructor(folder, pattern) {Object.assign(this, {folder, pattern});}
}
const vscode = {
workspace: {
workspaceFolders: [],
getConfiguration: () => ({get: (key, fallback) => fallback}),
findFiles: async () => [],
},
window: {createOutputChannel: () => output,
showErrorMessage: message => errors.push(message)},
debug: {
registerDebugAdapterDescriptorFactory: (type, value) => {
assert.equal(type, 'sprinter-mame');
factory = value;
return {dispose() {}};
},
registerDebugConfigurationProvider: (type, value) => {
assert.equal(type, 'sprinter-mame');
configurationProvider = value;
return {dispose() {}};
},
},
tasks: {
registerTaskProvider: (type, value) => {
assert.equal(type, 'sprinter');
taskProvider = value;
return {dispose() {}};
},
onDidEndTaskProcess: listener => {
onProcess = listener;
return {dispose() {onProcess = null;}};
},
onDidEndTask: listener => {
onEnd = listener;
return {dispose() {onEnd = null;}};
},
executeTask: async task => {
const execution = {task};
onProcess({execution, exitCode});
onEnd({execution});
return execution;
},
},
commands: {registerCommand: (name) => {
assert.equal(name, 'sprinter.buildActive');
return {dispose() {}};
}},
DebugAdapterExecutable,
ProcessExecution, Task, RelativePattern,
TaskGroup: {Build: 'build'},
};
const source = fs.readFileSync(path.join(__dirname, 'extension.js'), 'utf8');
const module = {exports: {}};
vm.runInNewContext(source, {
require: name => name === 'vscode' ? vscode : require(name),
module, setTimeout,
}, {filename: 'extension.js'});
const context = {subscriptions: []};
module.exports.activate(context);
assert.equal(context.subscriptions.length, 5);
const workspace = path.resolve(__dirname, '..', '..');
const session = {
workspaceFolder: {uri: {fsPath: workspace}},
configuration: {},
};
const descriptor = factory.createDebugAdapterDescriptor(session);
assert.equal(descriptor.command,
path.join(require('node:os').homedir(), '.pyenv', 'shims', 'python'));
assert.equal(descriptor.args.at(-1),
path.join(workspace, 'toolchain', 'sdbg_dap.py'));
assert.equal(descriptor.options.cwd, workspace);
assert.equal(descriptor.options.env.PYENV_VERSION, '3.12');
const configuration = {
type: 'sprinter-mame', request: 'launch',
build: '${workspaceFolder}/tests/hello/.sprinter-cc-hello',
};
assert.equal(await configurationProvider.resolveDebugConfiguration(
session.workspaceFolder, configuration), configuration, errors.join('\n'));
const task = taskProvider.createTask(session.workspaceFolder,
path.join(workspace, 'tests', 'hello'));
assert.equal(task.name, 'Sprinter: Build tests/hello');
assert.equal(task.execution.command, '/usr/bin/make');
assert.equal(task.execution.options.cwd,
path.join(workspace, 'tests', 'hello'));
assert.equal(task.execution.options.env.PYENV_VERSION, '3.12');
assert.deepEqual(Array.from(task.execution.args),
['SRC_DEBUG=1', `PYTHON=${descriptor.command}`]);
assert.equal(task.problemMatchers[0], '$sprinter-sdcc');
vscode.workspace.workspaceFolders = [session.workspaceFolder];
vscode.workspace.findFiles = async () => [
{fsPath: path.join(workspace, 'tests', 'hello', 'Makefile')},
{fsPath: path.join(workspace, 'Makefile')},
];
const discovered = await taskProvider.provideTasks();
assert.equal(discovered.length, 1);
assert.equal(discovered[0].name, task.name);
const unresolved = {scope: session.workspaceFolder,
definition: {type: 'sprinter', project: 'tests/hello'}};
assert.equal(taskProvider.resolveTask(unresolved).definition,
unresolved.definition);
exitCode = 1;
assert.equal(await configurationProvider.resolveDebugConfiguration(
session.workspaceFolder, {...configuration}), undefined);
assert.match(errors.at(-1), /MAME не запущен/);
});
@@ -0,0 +1,170 @@
{
"name": "sprinter-mame-debug",
"displayName": "Sprinter MAME Debug",
"description": "Сборка и DAP-отладка C-приложений Sprinter в MAME",
"version": "0.2.0",
"publisher": "sprinter-c-compiler",
"engines": {"vscode": "^1.85.0"},
"categories": ["Debuggers"],
"main": "./extension.js",
"activationEvents": ["onDebug", "onTaskType:sprinter", "onCommand:sprinter.buildActive"],
"contributes": {
"commands": [
{"command": "sprinter.buildActive", "title": "Sprinter: Build Active Project"}
],
"taskDefinitions": [
{
"type": "sprinter",
"required": ["project"],
"properties": {
"project": {
"type": "string",
"description": "Путь к каталогу с Makefile приложения относительно workspace"
}
}
}
],
"problemMatchers": [
{
"name": "sprinter-sdcc",
"owner": "sprinter-sdcc",
"fileLocation": "relative",
"pattern": {
"regexp": "^(.+?):(\\d+):\\s+(error|warning)\\s+(\\d+):\\s+(.+)$",
"file": 1,
"line": 2,
"severity": 3,
"code": 4,
"message": 5
}
}
],
"configuration": {
"title": "Sprinter MAME Debug",
"properties": {
"sprinterDebugger.pythonCommand": {
"type": "string",
"default": "auto",
"description": "Python 3.12 для DAP; auto выбирает local pyenv shim без зависимости от PATH GUI"
},
"sprinterDebugger.pythonArguments": {
"type": "array",
"items": {"type": "string"},
"default": [],
"description": "Аргументы пользовательской Python-команды перед путём DAP-адаптера"
}
}
},
"debuggers": [
{
"type": "sprinter-mame",
"label": "Sprinter MAME",
"languages": ["c"],
"configurationAttributes": {
"launch": {
"required": ["build"],
"properties": {
"build": {
"type": "string",
"description": "Путь к каталогу .sprinter-cc-NAME"
},
"project": {
"type": "string",
"description": "Каталог с Makefile для сборки перед запуском; определяется из build, если не задан"
},
"autoBuild": {
"type": "boolean",
"default": true,
"description": "Добавить Sprinter build task перед F5, когда найден app.mk"
},
"data": {
"type": "array",
"items": {"type": "string"},
"description": "Дополнительные файлы на debug-дискету"
},
"mame": {
"type": "string",
"description": "Путь к mame.arm"
},
"debugger": {
"type": "string",
"default": "sdbg",
"enum": ["sdbg", "auto", "osx", "windows", "qt", "imgui"],
"enumDescriptions": [
"Только окно Sprinter и управление из VS Code",
"Выбрать доступный штатный backend MAME",
"VS Code вместе с Cocoa debugger MAME на macOS",
"VS Code вместе с native debugger MAME на Windows",
"Qt debugger MAME, если сборка включает USE_QTDEBUG",
"Debugger MAME внутри основного графического окна"
],
"description": "OSD debugger provider; sdbg не открывает Cocoa debugger"
},
"launchAt": {
"type": "number",
"default": 0,
"description": "Не начинать ввод раньше этой секунды эмуляции"
},
"dssTimeout": {
"type": "number",
"default": 30,
"description": "Таймаут появления стабильного prompt DSS"
}
}
},
"attach": {
"required": ["socket"],
"properties": {
"socket": {
"type": "string",
"description": "Unix socket запущенного sdbg_server.py"
},
"adapterPath": {
"type": "string",
"description": "Путь к toolchain/sdbg_dap.py; по умолчанию из workspace"
}
}
}
},
"configurationSnippets": [
{
"label": "Sprinter MAME: Launch",
"description": "Запустить изолированный MAME и остановиться на main",
"body": {
"type": "sprinter-mame",
"request": "launch",
"name": "Sprinter MAME: Launch",
"build": "^\"${workspaceFolder}/tests/hello/.sprinter-cc-hello\""
}
},
{
"label": "Sprinter MAME: Launch + native debugger",
"description": "Запустить VS Code debugger вместе с Cocoa debugger MAME",
"body": {
"type": "sprinter-mame",
"request": "launch",
"name": "Sprinter MAME: Launch + native debugger",
"build": "^\"${workspaceFolder}/tests/hello/.sprinter-cc-hello\"",
"debugger": "osx"
}
},
{
"label": "Sprinter MAME: Attach",
"description": "Подключиться к проверенной sdbg-сессии",
"body": {
"type": "sprinter-mame",
"request": "attach",
"name": "Sprinter MAME: Attach",
"socket": "^\"/tmp/sprinter-sdbg.sock\""
}
}
]
}
]
},
"scripts": {
"check": "node --check extension.js && node --check build.js && node --test runtime.test.js build.test.js extension.test.js"
},
"files": ["extension.js", "runtime.js", "build.js", "README.md"],
"license": "BSD-3-Clause"
}
@@ -0,0 +1,84 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
function executable(filename) {
try {
fs.accessSync(filename, fs.constants.X_OK);
return true;
} catch (_) {
return false;
}
}
function localPythonVersion(workspace) {
if (!workspace) return null;
let directory = path.resolve(workspace);
while (directory !== path.dirname(directory)) {
try {
const version = fs.readFileSync(path.join(directory, '.python-version'),
'utf8').trim().split(/\s+/)[0];
return /^[A-Za-z0-9._-]+$/.test(version) ? version : null;
} catch (_) {}
directory = path.dirname(directory);
}
return null;
}
function installedPyenvPythons(home) {
try {
const versions = fs.readdirSync(path.join(home, '.pyenv', 'versions'));
return versions.filter(version => /^3\.12(?:\.\d+)?$/.test(version))
.sort((a, b) => Number(b.split('.')[2] || 0) -
Number(a.split('.')[2] || 0))
.map(version => path.join(home, '.pyenv', 'versions', version,
'bin', 'python'));
} catch (_) {
return [];
}
}
function resolvePython(options = {}) {
const command = String(options.command || 'auto').trim();
const args = Array.isArray(options.args) ? options.args : [];
if (command !== 'auto') {
return {command, args};
}
const home = options.home || os.homedir();
const workspace = options.workspace;
const isExecutable = options.isExecutable || executable;
const candidates = [];
const shim = path.join(home, '.pyenv', 'shims', 'python');
const version = options.localVersion === undefined ?
localPythonVersion(workspace) : options.localVersion;
const installed = options.installedPyenv === undefined ?
installedPyenvPythons(home) : options.installedPyenv;
if (version) candidates.push(shim);
candidates.push(...installed);
if (!version) candidates.push(shim);
if (workspace) {
candidates.push(path.join(workspace, '.venv', 'bin', 'python'));
}
candidates.push(
'/opt/homebrew/bin/python3.12',
'/usr/local/bin/python3.12',
'/usr/bin/python3.12',
);
const found = candidates.find(isExecutable);
if (!found) {
throw new Error(
'Не найден Python 3.12. Задайте абсолютный путь в ' +
'sprinterDebugger.pythonCommand.');
}
return {command: found, args: []};
}
function pyenvEnvironment(command, workspace, home = os.homedir()) {
const shim = path.join(home, '.pyenv', 'shims', 'python');
const version = command === shim ? localPythonVersion(workspace) : null;
return version ? {PYENV_VERSION: version} : undefined;
}
module.exports = {resolvePython, localPythonVersion, installedPyenvPythons,
pyenvEnvironment};
@@ -0,0 +1,67 @@
const assert = require('node:assert/strict');
const path = require('node:path');
const test = require('node:test');
const {resolvePython, localPythonVersion, pyenvEnvironment} = require('./runtime');
test('auto использует pyenv shim без PATH GUI', () => {
const home = path.join(path.sep, 'Users', 'tester');
const shim = path.join(home, '.pyenv', 'shims', 'python');
const result = resolvePython({
command: 'auto',
workspace: path.join(home, 'project'),
home,
isExecutable: filename => filename === shim,
});
assert.deepEqual(result, {command: shim, args: []});
});
test('local pyenv имеет приоритет над случайным .venv', () => {
const home = path.join(path.sep, 'Users', 'tester');
const workspace = path.join(home, 'project');
const shim = path.join(home, '.pyenv', 'shims', 'python');
const venv = path.join(workspace, '.venv', 'bin', 'python');
const result = resolvePython({
command: 'auto', workspace, home,
isExecutable: filename => filename === shim || filename === venv,
});
assert.equal(result.command, shim);
});
test('явная команда и аргументы сохраняются', () => {
const result = resolvePython({
command: '/python/custom',
args: ['-I'],
isExecutable: () => false,
});
assert.deepEqual(result, {command: '/python/custom', args: ['-I']});
});
test('auto сообщает понятную ошибку без Python 3.12', () => {
assert.throws(
() => resolvePython({command: 'auto', isExecutable: () => false}),
/Задайте абсолютный путь/,
);
});
test('внешний workspace без .python-version выбирает установленный pyenv 3.12', () => {
const home = path.join(path.sep, 'Users', 'tester');
const installed = path.join(home, '.pyenv', 'versions', '3.12.14',
'bin', 'python');
const result = resolvePython({
command: 'auto', workspace: '/tmp/external-project', home,
localVersion: null, installedPyenv: [installed],
isExecutable: filename => filename === installed ||
filename === path.join(home, '.pyenv', 'shims', 'python'),
});
assert.equal(result.command, installed);
});
test('shim получает версию workspace при сборке за пределами его дерева', () => {
const workspace = path.resolve(__dirname, '..', '..');
const version = localPythonVersion(workspace);
assert.match(version, /^3\.12/);
const shim = path.join(require('node:os').homedir(), '.pyenv',
'shims', 'python');
assert.deepEqual(pyenvEnvironment(shim, workspace),
{PYENV_VERSION: version});
});