Skip to content

Commit eb469c7

Browse files
Holzhausasottile
authored andcommitted
Rust as 1st class language
1 parent 028efcb commit eb469c7

File tree

5 files changed

+174
-24
lines changed

5 files changed

+174
-24
lines changed

CONTRIBUTING.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,9 @@ to implement. The current implemented languages are at varying levels:
6565
- 0th class - pre-commit does not require any dependencies for these languages
6666
as they're not actually languages (current examples: fail, pygrep)
6767
- 1st class - pre-commit will bootstrap a full interpreter requiring nothing to
68-
be installed globally (current examples: node, ruby)
68+
be installed globally (current examples: node, ruby, rust)
6969
- 2nd class - pre-commit requires the user to install the language globally but
70-
will install tools in an isolated fashion (current examples: python, go, rust,
70+
will install tools in an isolated fashion (current examples: python, go,
7171
swift, docker).
7272
- 3rd class - pre-commit requires the user to install both the tool and the
7373
language globally (current examples: script, system)

azure-pipelines.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ jobs:
1717
parameters:
1818
toxenvs: [py37]
1919
os: windows
20+
additional_variables:
21+
TEMP: C:\Temp
2022
pre_test:
2123
- task: UseRubyVersion@0
2224
- powershell: Write-Host "##vso[task.prependpath]$env:CONDA\Scripts"

pre_commit/languages/rust.py

Lines changed: 98 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
from __future__ import annotations
22

33
import contextlib
4+
import functools
45
import os.path
6+
import platform
7+
import shutil
8+
import sys
9+
import tempfile
10+
import urllib.request
511
from typing import Generator
612
from typing import Sequence
713

814
import toml
915

1016
import pre_commit.constants as C
17+
from pre_commit import parse_shebang
1118
from pre_commit.envcontext import envcontext
1219
from pre_commit.envcontext import PatchesT
1320
from pre_commit.envcontext import Var
@@ -16,24 +23,61 @@
1623
from pre_commit.prefix import Prefix
1724
from pre_commit.util import clean_path_on_failure
1825
from pre_commit.util import cmd_output_b
26+
from pre_commit.util import make_executable
27+
from pre_commit.util import win_exe
1928

2029
ENVIRONMENT_DIR = 'rustenv'
21-
get_default_version = helpers.basic_get_default_version
2230
health_check = helpers.basic_health_check
2331

2432

25-
def get_env_patch(target_dir: str) -> PatchesT:
33+
@functools.lru_cache(maxsize=1)
34+
def get_default_version() -> str:
35+
# If rust is already installed, we can save a bunch of setup time by
36+
# using the installed version.
37+
#
38+
# Just detecting the executable does not suffice, because if rustup is
39+
# installed but no toolchain is available, then `cargo` exists but
40+
# cannot be used without installing a toolchain first.
41+
if cmd_output_b('cargo', '--version', retcode=None)[0] == 0:
42+
return 'system'
43+
else:
44+
return C.DEFAULT
45+
46+
47+
def _rust_toolchain(language_version: str) -> str:
48+
"""Transform the language version into a rust toolchain version."""
49+
if language_version == C.DEFAULT:
50+
return 'stable'
51+
else:
52+
return language_version
53+
54+
55+
def _envdir(prefix: Prefix, version: str) -> str:
56+
directory = helpers.environment_dir(ENVIRONMENT_DIR, version)
57+
return prefix.path(directory)
58+
59+
60+
def get_env_patch(target_dir: str, version: str) -> PatchesT:
2661
return (
62+
('CARGO_HOME', target_dir),
2763
('PATH', (os.path.join(target_dir, 'bin'), os.pathsep, Var('PATH'))),
64+
# Only set RUSTUP_TOOLCHAIN if we don't want use the system's default
65+
# toolchain
66+
*(
67+
(('RUSTUP_TOOLCHAIN', _rust_toolchain(version)),)
68+
if version != 'system' else ()
69+
),
2870
)
2971

3072

3173
@contextlib.contextmanager
32-
def in_env(prefix: Prefix) -> Generator[None, None, None]:
33-
target_dir = prefix.path(
34-
helpers.environment_dir(ENVIRONMENT_DIR, C.DEFAULT),
35-
)
36-
with envcontext(get_env_patch(target_dir)):
74+
def in_env(
75+
prefix: Prefix,
76+
language_version: str,
77+
) -> Generator[None, None, None]:
78+
with envcontext(
79+
get_env_patch(_envdir(prefix, language_version), language_version),
80+
):
3781
yield
3882

3983

@@ -52,15 +96,45 @@ def _add_dependencies(
5296
f.truncate()
5397

5498

99+
def install_rust_with_toolchain(toolchain: str) -> None:
100+
with tempfile.TemporaryDirectory() as rustup_dir:
101+
with envcontext((('RUSTUP_HOME', rustup_dir),)):
102+
# acquire `rustup` if not present
103+
if parse_shebang.find_executable('rustup') is None:
104+
# We did not detect rustup and need to download it first.
105+
if sys.platform == 'win32': # pragma: win32 cover
106+
if platform.machine() == 'x86_64':
107+
url = 'https://win.rustup.rs/x86_64'
108+
else:
109+
url = 'https://win.rustup.rs/i686'
110+
else: # pragma: win32 no cover
111+
url = 'https://sh.rustup.rs'
112+
113+
resp = urllib.request.urlopen(url)
114+
115+
rustup_init = os.path.join(rustup_dir, win_exe('rustup-init'))
116+
with open(rustup_init, 'wb') as f:
117+
shutil.copyfileobj(resp, f)
118+
make_executable(rustup_init)
119+
120+
# install rustup into `$CARGO_HOME/bin`
121+
cmd_output_b(
122+
rustup_init, '-y', '--quiet', '--no-modify-path',
123+
'--default-toolchain', 'none',
124+
)
125+
126+
cmd_output_b(
127+
'rustup', 'toolchain', 'install', '--no-self-update',
128+
toolchain,
129+
)
130+
131+
55132
def install_environment(
56133
prefix: Prefix,
57134
version: str,
58135
additional_dependencies: Sequence[str],
59136
) -> None:
60-
helpers.assert_version_default('rust', version)
61-
directory = prefix.path(
62-
helpers.environment_dir(ENVIRONMENT_DIR, C.DEFAULT),
63-
)
137+
directory = _envdir(prefix, version)
64138

65139
# There are two cases where we might want to specify more dependencies:
66140
# as dependencies for the library being built, and as binary packages
@@ -84,23 +158,27 @@ def install_environment(
84158
packages_to_install: set[tuple[str, ...]] = {('--path', '.')}
85159
for cli_dep in cli_deps:
86160
cli_dep = cli_dep[len('cli:'):]
87-
package, _, version = cli_dep.partition(':')
88-
if version != '':
89-
packages_to_install.add((package, '--version', version))
161+
package, _, crate_version = cli_dep.partition(':')
162+
if crate_version != '':
163+
packages_to_install.add((package, '--version', crate_version))
90164
else:
91165
packages_to_install.add((package,))
92166

93-
for args in packages_to_install:
94-
cmd_output_b(
95-
'cargo', 'install', '--bins', '--root', directory, *args,
96-
cwd=prefix.prefix_dir,
97-
)
167+
with in_env(prefix, version):
168+
if version != 'system':
169+
install_rust_with_toolchain(_rust_toolchain(version))
170+
171+
for args in packages_to_install:
172+
cmd_output_b(
173+
'cargo', 'install', '--bins', '--root', directory, *args,
174+
cwd=prefix.prefix_dir,
175+
)
98176

99177

100178
def run_hook(
101179
hook: Hook,
102180
file_args: Sequence[str],
103181
color: bool,
104182
) -> tuple[int, bytes]:
105-
with in_env(hook.prefix):
183+
with in_env(hook.prefix, hook.language_version):
106184
return helpers.run_xargs(hook, hook.cmd, file_args, color=color)

tests/languages/rust_test.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
from __future__ import annotations
2+
3+
from unittest import mock
4+
5+
import pytest
6+
7+
import pre_commit.constants as C
8+
from pre_commit import parse_shebang
9+
from pre_commit.languages import rust
10+
from pre_commit.prefix import Prefix
11+
from pre_commit.util import cmd_output
12+
13+
ACTUAL_GET_DEFAULT_VERSION = rust.get_default_version.__wrapped__
14+
15+
16+
@pytest.fixture
17+
def cmd_output_b_mck():
18+
with mock.patch.object(rust, 'cmd_output_b') as mck:
19+
yield mck
20+
21+
22+
def test_sets_system_when_rust_is_available(cmd_output_b_mck):
23+
cmd_output_b_mck.return_value = (0, b'', b'')
24+
assert ACTUAL_GET_DEFAULT_VERSION() == 'system'
25+
26+
27+
def test_uses_default_when_rust_is_not_available(cmd_output_b_mck):
28+
cmd_output_b_mck.return_value = (127, b'', b'error: not found')
29+
assert ACTUAL_GET_DEFAULT_VERSION() == C.DEFAULT
30+
31+
32+
@pytest.mark.parametrize('language_version', (C.DEFAULT, '1.56.0'))
33+
def test_installs_with_bootstrapped_rustup(tmpdir, language_version):
34+
tmpdir.join('src', 'main.rs').ensure().write(
35+
'fn main() {\n'
36+
' println!("Hello, world!");\n'
37+
'}\n',
38+
)
39+
tmpdir.join('Cargo.toml').ensure().write(
40+
'[package]\n'
41+
'name = "hello_world"\n'
42+
'version = "0.1.0"\n'
43+
'edition = "2021"\n',
44+
)
45+
prefix = Prefix(str(tmpdir))
46+
47+
find_executable_exes = []
48+
49+
original_find_executable = parse_shebang.find_executable
50+
51+
def mocked_find_executable(exe: str) -> str | None:
52+
"""
53+
Return `None` the first time `find_executable` is called to ensure
54+
that the bootstrapping code is executed, then just let the function
55+
work as normal.
56+
57+
Also log the arguments to ensure that everything works as expected.
58+
"""
59+
find_executable_exes.append(exe)
60+
if len(find_executable_exes) == 1:
61+
return None
62+
return original_find_executable(exe)
63+
64+
with mock.patch.object(parse_shebang, 'find_executable') as find_exe_mck:
65+
find_exe_mck.side_effect = mocked_find_executable
66+
rust.install_environment(prefix, language_version, ())
67+
assert find_executable_exes == ['rustup', 'rustup', 'cargo']
68+
69+
with rust.in_env(prefix, language_version):
70+
assert cmd_output('hello_world')[1] == 'Hello, world!\n'

tests/repository_test.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,7 @@ def test_additional_rust_cli_dependencies_installed(
471471
hook = _get_hook(config, store, 'rust-hook')
472472
binaries = os.listdir(
473473
hook.prefix.path(
474-
helpers.environment_dir(rust.ENVIRONMENT_DIR, C.DEFAULT), 'bin',
474+
helpers.environment_dir(rust.ENVIRONMENT_DIR, 'system'), 'bin',
475475
),
476476
)
477477
# normalize for windows
@@ -490,7 +490,7 @@ def test_additional_rust_lib_dependencies_installed(
490490
hook = _get_hook(config, store, 'rust-hook')
491491
binaries = os.listdir(
492492
hook.prefix.path(
493-
helpers.environment_dir(rust.ENVIRONMENT_DIR, C.DEFAULT), 'bin',
493+
helpers.environment_dir(rust.ENVIRONMENT_DIR, 'system'), 'bin',
494494
),
495495
)
496496
# normalize for windows

0 commit comments

Comments
 (0)