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
3 changes: 3 additions & 0 deletions .github/workflows/languages.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ jobs:
with:
python-version: '3.10'

- uses: oven-sh/setup-bun@v2
if: matrix.language == 'bun'

- run: echo "$CONDA\Scripts" >> "$GITHUB_PATH"
shell: bash
if: matrix.os == 'windows-latest' && matrix.language == 'conda'
Expand Down
2 changes: 2 additions & 0 deletions pre_commit/all_languages.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -25,6 +26,7 @@


languages: dict[str, Language] = {
'bun': bun,
'conda': conda,
'coursier': coursier,
'dart': dart,
Expand Down
195 changes: 195 additions & 0 deletions pre_commit/languages/bun.py
Original file line number Diff line number Diff line change
@@ -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))
5 changes: 5 additions & 0 deletions testing/resources/bun-hook-repo/.pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- id: test-bun-hook
name: Test Bun Hook
entry: test-bun-hook
language: bun
files: \.txt$
16 changes: 16 additions & 0 deletions testing/resources/bun-hook-repo/bin/test-hook.js
Original file line number Diff line number Diff line change
@@ -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);
7 changes: 7 additions & 0 deletions testing/resources/bun-hook-repo/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "test-bun-hook",
"version": "1.0.0",
"bin": {
"test-bun-hook": "./bin/test-hook.js"
}
}
Loading
Loading