From ae5018d3e516f2c9f1d5b166232926d324c68088 Mon Sep 17 00:00:00 2001 From: Matan Shavit <71092861+matanshavit@users.noreply.github.com> Date: Tue, 28 Oct 2025 21:15:51 -0400 Subject: [PATCH] Add Bun language support Implements Bun as a new language option for pre-commit hooks, enabling hooks to run using the Bun JavaScript runtime and package manager. - Add bun.py language implementation with binary download/install - Support system-installed Bun or automatic version download - Add comprehensive tests including version handling and hook execution - Register bun in all_languages.py - Include test repository fixture for integration tests --- pre_commit/all_languages.py | 2 + pre_commit/languages/bun.py | 195 ++++++++++++++++++ .../bun-hook-repo/.pre-commit-hooks.yaml | 5 + .../resources/bun-hook-repo/bin/test-hook.js | 16 ++ testing/resources/bun-hook-repo/package.json | 7 + tests/languages/bun_test.py | 141 +++++++++++++ 6 files changed, 366 insertions(+) create mode 100644 pre_commit/languages/bun.py create mode 100644 testing/resources/bun-hook-repo/.pre-commit-hooks.yaml create mode 100755 testing/resources/bun-hook-repo/bin/test-hook.js create mode 100644 testing/resources/bun-hook-repo/package.json create mode 100644 tests/languages/bun_test.py diff --git a/pre_commit/all_languages.py b/pre_commit/all_languages.py index ba569c37..42176229 100644 --- a/pre_commit/all_languages.py +++ b/pre_commit/all_languages.py @@ -1,6 +1,7 @@ from __future__ import annotations from pre_commit.lang_base import Language +from pre_commit.languages import bun from pre_commit.languages import conda from pre_commit.languages import coursier from pre_commit.languages import dart @@ -25,6 +26,7 @@ from pre_commit.languages import system languages: dict[str, Language] = { + 'bun': bun, 'conda': conda, 'coursier': coursier, 'dart': dart, diff --git a/pre_commit/languages/bun.py b/pre_commit/languages/bun.py new file mode 100644 index 00000000..9f2c098c --- /dev/null +++ b/pre_commit/languages/bun.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import contextlib +import functools +import os.path +import platform +import shutil +import sys +import tempfile +import urllib.error +import urllib.request +import zipfile +from collections.abc import Generator +from collections.abc import Sequence + +import pre_commit.constants as C +from pre_commit import lang_base +from pre_commit.envcontext import envcontext +from pre_commit.envcontext import PatchesT +from pre_commit.envcontext import Var +from pre_commit.languages.python import bin_dir +from pre_commit.prefix import Prefix +from pre_commit.util import cmd_output_b + +ENVIRONMENT_DIR = 'bunenv' +run_hook = lang_base.basic_run_hook + +# Architecture mapping for Bun binary downloads +_ARCH_ALIASES = { + 'x86_64': 'x64', + 'amd64': 'x64', + 'aarch64': 'aarch64', + 'arm64': 'aarch64', +} +_ARCH = platform.machine().lower() +_ARCH = _ARCH_ALIASES.get(_ARCH, _ARCH) + + +@functools.lru_cache(maxsize=1) +def get_default_version() -> str: + """Detect if Bun is installed system-wide.""" + # Check for system-installed bun + if lang_base.exe_exists('bun'): + return 'system' + else: + return C.DEFAULT + + +def _get_platform() -> str: + """Get platform string for Bun binary downloads.""" + if sys.platform == 'darwin': + return 'darwin' + elif sys.platform == 'win32': + return 'windows' + elif sys.platform.startswith('linux'): + return 'linux' + else: + raise AssertionError(f'Unsupported platform: {sys.platform}') + + +def _normalize_version(version: str) -> str: + """Normalize version string for download URL.""" + if version == C.DEFAULT: + return 'latest' + # Ensure version has 'bun-v' prefix for download URL + if not version.startswith('bun-v'): + if version.startswith('v'): + return f'bun-{version}' + else: + return f'bun-v{version}' + return version + + +def _get_download_url(version: str) -> str: + """Construct Bun binary download URL from GitHub releases.""" + platform_name = _get_platform() + normalized_version = _normalize_version(version) + + # Bun release URL format: + # https://github.com/oven-sh/bun/releases/download/bun-v1.1.42/bun-darwin-x64.zip + # https://github.com/oven-sh/bun/releases/download/bun-v1.1.42/bun-linux-x64.zip + # https://github.com/oven-sh/bun/releases/download/bun-v1.1.42/bun-windows-x64.zip + base_url = 'https://github.com/oven-sh/bun/releases' + + if normalized_version == 'latest': + # Use latest release + return f'{base_url}/latest/download/bun-{platform_name}-{_ARCH}.zip' + else: + # Use specific version + return ( + f'{base_url}/download/{normalized_version}/' + f'bun-{platform_name}-{_ARCH}.zip' + ) + + +def _install_bun(version: str, dest: str) -> None: + """Download and extract Bun binary to destination directory.""" + url = _get_download_url(version) + + try: + resp = urllib.request.urlopen(url) + except urllib.error.HTTPError as e: + if e.code == 404: + raise ValueError( + f'Could not find Bun version matching your requirements ' + f'(version={version}; os={_get_platform()}; ' + f'arch={_ARCH}). Check available versions at ' + f'https://github.com/oven-sh/bun/releases', + ) from e + else: + raise + + with tempfile.TemporaryFile() as f: + shutil.copyfileobj(resp, f) + f.seek(0) + + with zipfile.ZipFile(f) as zipf: + zipf.extractall(dest) + + # Bun zipfile contains a directory like 'bun-darwin-x64' or 'bun-linux-x64' + # Move the binary from the extracted directory to dest/bin/ + bin_dir_path = os.path.join(dest, 'bin') + os.makedirs(bin_dir_path, exist_ok=True) + + # Find the extracted directory + for item in os.listdir(dest): + item_path = os.path.join(dest, item) + if os.path.isdir(item_path) and item.startswith('bun-'): + # Move bun executable to bin directory + bun_exe = 'bun.exe' if sys.platform == 'win32' else 'bun' + src_exe = os.path.join(item_path, bun_exe) + if os.path.exists(src_exe): + shutil.move(src_exe, os.path.join(bin_dir_path, bun_exe)) + # Remove the extracted directory + shutil.rmtree(item_path) + break + + +def get_env_patch(venv: str) -> PatchesT: + """Prepare environment variables for Bun execution.""" + # Bun is much simpler than Node - primarily just needs PATH + return ( + ('PATH', (bin_dir(venv), os.pathsep, Var('PATH'))), + # BUN_INSTALL controls where global packages are installed + ('BUN_INSTALL', venv), + ) + + +@contextlib.contextmanager +def in_env(prefix: Prefix, version: str) -> Generator[None]: + """Context manager for Bun environment.""" + envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + with envcontext(get_env_patch(envdir)): + yield + + +def health_check(prefix: Prefix, version: str) -> str | None: + """Check if Bun environment is healthy.""" + with in_env(prefix, version): + retcode, _, _ = cmd_output_b('bun', '--version', check=False) + if retcode != 0: # pragma: no cover + return f'`bun --version` returned {retcode}' + else: + return None + + +def install_environment( + prefix: Prefix, + version: str, + additional_dependencies: Sequence[str], +) -> None: + """Install Bun environment and dependencies.""" + assert prefix.exists('package.json') + envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + + # Install Bun binary (unless using system version) + if version != 'system': + _install_bun(version, envdir) + + with in_env(prefix, version): + # Install local dependencies from package.json + # Use --no-progress to avoid cluttering output + install_cmd = ('bun', 'install', '--no-progress') + lang_base.setup_cmd(prefix, install_cmd) + + # Install the package globally from the current directory + # Bun's global install uses `bun add -g` with file: protocol + # We need to install from an absolute file path, so we use file:. + # Note: Unlike npm, bun creates symlinks to the local package, + # so we must NOT delete node_modules or the bin directory. + abs_prefix = os.path.abspath(prefix.prefix_dir) + install = ['bun', 'add', '-g', f'file:{abs_prefix}'] + if additional_dependencies: + install.extend(additional_dependencies) + lang_base.setup_cmd(prefix, tuple(install)) diff --git a/testing/resources/bun-hook-repo/.pre-commit-hooks.yaml b/testing/resources/bun-hook-repo/.pre-commit-hooks.yaml new file mode 100644 index 00000000..f9827c4f --- /dev/null +++ b/testing/resources/bun-hook-repo/.pre-commit-hooks.yaml @@ -0,0 +1,5 @@ +- id: test-bun-hook + name: Test Bun Hook + entry: test-bun-hook + language: bun + files: \.txt$ diff --git a/testing/resources/bun-hook-repo/bin/test-hook.js b/testing/resources/bun-hook-repo/bin/test-hook.js new file mode 100755 index 00000000..9db7b4a9 --- /dev/null +++ b/testing/resources/bun-hook-repo/bin/test-hook.js @@ -0,0 +1,16 @@ +#!/usr/bin/env node +// Simple test hook that validates file content +const fs = require('fs'); + +const files = process.argv.slice(2); +let failed = false; + +files.forEach(file => { + const content = fs.readFileSync(file, 'utf8'); + if (content.includes('bad')) { + console.error(`Error in ${file}: contains 'bad'`); + failed = true; + } +}); + +process.exit(failed ? 1 : 0); diff --git a/testing/resources/bun-hook-repo/package.json b/testing/resources/bun-hook-repo/package.json new file mode 100644 index 00000000..177461bb --- /dev/null +++ b/testing/resources/bun-hook-repo/package.json @@ -0,0 +1,7 @@ +{ + "name": "test-bun-hook", + "version": "1.0.0", + "bin": { + "test-bun-hook": "./bin/test-hook.js" + } +} diff --git a/tests/languages/bun_test.py b/tests/languages/bun_test.py new file mode 100644 index 00000000..4416ad01 --- /dev/null +++ b/tests/languages/bun_test.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import sys +from unittest import mock + +import pytest + +import pre_commit.constants as C +from pre_commit import lang_base +from pre_commit import parse_shebang +from pre_commit.languages import bun +from pre_commit.prefix import Prefix +from pre_commit.store import _make_local_repo +from testing.language_helpers import run_language + + +ACTUAL_GET_DEFAULT_VERSION = bun.get_default_version.__wrapped__ + + +@pytest.fixture +def find_exe_mck(): + with mock.patch.object(parse_shebang, 'find_executable') as mck: + yield mck + + +def test_sets_system_when_bun_is_available(find_exe_mck): + find_exe_mck.return_value = '/path/to/exe' + assert ACTUAL_GET_DEFAULT_VERSION() == 'system' + + +def test_uses_default_when_bun_is_not_available(find_exe_mck): + find_exe_mck.return_value = None + assert ACTUAL_GET_DEFAULT_VERSION() == C.DEFAULT + + +def _make_hello_world(tmp_path): + """Create a simple Node/Bun package for testing.""" + package_json = '''\ +{ + "name": "test-bun-hook", + "version": "1.0.0", + "bin": {"bun-hello": "./bin/bun-hello.js"} +} +''' + bin_script = '''\ +#!/usr/bin/env node +console.log('Hello World'); +''' + + tmp_path.joinpath('package.json').write_text(package_json) + bin_dir = tmp_path.joinpath('bin') + bin_dir.mkdir() + bin_dir.joinpath('bun-hello.js').write_text(bin_script) + + +def test_bun_default_version(): + """Test default version detection.""" + version = bun.get_default_version() + # Should return either 'system' or 'default' + assert version in {'system', 'default'} + + +@pytest.mark.skipif( + not lang_base.exe_exists('bun'), + reason='bun not installed on system', +) +def test_bun_hook_system(tmp_path): + """Test running a hook with system Bun.""" + _make_hello_world(tmp_path) + ret = run_language(tmp_path, bun, 'bun-hello') + assert ret == (0, b'Hello World\n') + + +@pytest.mark.skipif( + sys.platform == 'win32', + reason='Test may be slow on Windows', +) +def test_bun_hook_default_version(tmp_path): + """Test running a hook with downloaded Bun (default/latest).""" + _make_hello_world(tmp_path) + ret = run_language(tmp_path, bun, 'bun-hello', version=C.DEFAULT) + assert ret == (0, b'Hello World\n') + + +@pytest.mark.skipif( + sys.platform == 'win32', + reason='Test may be slow on Windows', +) +def test_bun_hook_specific_version(tmp_path): + """Test running a hook with specific Bun version.""" + _make_hello_world(tmp_path) + # Use a known stable version + ret = run_language(tmp_path, bun, 'bun-hello', version='1.1.42') + assert ret == (0, b'Hello World\n') + + +def test_bun_additional_dependencies(tmp_path): + """Test installing additional dependencies.""" + _make_local_repo(str(tmp_path)) + ret, out = run_language( + tmp_path, + bun, + 'bun pm ls -g', + deps=('lodash',), + ) + assert b'lodash' in out + + +def test_bun_with_package_json_only(tmp_path): + """Test that package.json is required.""" + # Don't create package.json - just create a Prefix + prefix = Prefix(str(tmp_path)) + + with pytest.raises(AssertionError): + bun.install_environment(prefix, 'system', ()) + + +def test_environment_dir(): + """Test ENVIRONMENT_DIR is set correctly.""" + assert bun.ENVIRONMENT_DIR == 'bunenv' + + +def test_run_hook_uses_basic(): + """Test that run_hook is set to basic implementation.""" + assert bun.run_hook is lang_base.basic_run_hook + + +@pytest.mark.skipif( + not lang_base.exe_exists('bun'), + reason='bun not installed on system', +) +def test_bun_health_check_success(tmp_path): + """Test health check with valid environment.""" + _make_hello_world(tmp_path) + + _make_local_repo(str(tmp_path)) + prefix = Prefix(str(tmp_path)) + bun.install_environment(prefix, 'system', ()) + + health = bun.health_check(prefix, 'system') + assert health is None # None means healthy