Skip to content

Commit 53a72ac

Browse files
committed
Improve
1 parent 48e6900 commit 53a72ac

File tree

3 files changed

+195
-57
lines changed

3 files changed

+195
-57
lines changed

crates/prek/src/languages/script.rs

Lines changed: 123 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use std::sync::Arc;
66
use anyhow::{Context, Result};
77
use fs_err::tokio as fs;
88
use tempfile::TempDir;
9+
use tracing::trace;
910

1011
use crate::cli::reporter::{HookInstallReporter, HookRunReporter};
1112
use crate::hook::InstalledHook;
@@ -19,24 +20,30 @@ use crate::store::Store;
1920
#[derive(Debug, Copy, Clone)]
2021
pub(crate) struct Script;
2122

22-
fn is_script_path_entry(entry: &str, repo_path: &Path) -> bool {
23-
// Ignore trailing newlines so a path with a trailing line break isn't treated as inline.
24-
let trimmed = entry.trim_end_matches(['\n', '\r']);
25-
if !(trimmed.contains('\n') || trimmed.contains('\r')) {
26-
return true;
23+
/// Determine if the entry is an inline script or a script path.
24+
/// An entry is considered an inline script if:
25+
/// - It contains newlines (YAML block scalar).
26+
/// - The first token does not resolve to a real file in the repo.
27+
fn is_inline_script(entry: &str, repo_path: &Path) -> bool {
28+
// YAML block scalar chompping style:
29+
// | => keep single trailing newline
30+
// |- => remove single trailing newline
31+
// |+ => keep all trailing newlines
32+
let entry = entry.trim_end_matches(['\n', '\r']);
33+
if !(entry.contains('\n') || entry.contains('\r')) {
34+
return false;
2735
}
2836

2937
// If we can parse the first token and it resolves to a real file, treat it as a script path.
3038
// Otherwise, assume the entry is inline script content.
31-
let Some(tokens) = shlex::split(trimmed) else {
32-
return false;
33-
};
34-
let Some(first) = tokens.first() else {
39+
if let Some(tokens) = shlex::split(entry)
40+
&& let Some(first) = tokens.first()
41+
&& repo_path.join(first).is_file()
42+
{
3543
return false;
36-
};
44+
}
3745

38-
let candidate = repo_path.join(first);
39-
candidate.is_file()
46+
true
4047
}
4148

4249
fn parse_inline_shebang(entry: &str) -> Option<Vec<String>> {
@@ -48,53 +55,121 @@ fn parse_inline_shebang(entry: &str) -> Option<Vec<String>> {
4855
}
4956
}
5057

51-
fn inline_extension_from_interpreter(interpreter: Option<&str>) -> &'static str {
52-
match interpreter.map(str::to_ascii_lowercase) {
53-
Some(value) if value.contains("pwsh") || value.contains("powershell") => "ps1",
54-
_ => "sh",
58+
#[derive(Debug, Clone)]
59+
struct ShellSpec {
60+
program: String,
61+
prefix_args: Vec<String>,
62+
extension: &'static str,
63+
}
64+
65+
impl ShellSpec {
66+
fn build_for_script(&self, script_path: &Path) -> Vec<String> {
67+
let mut cmd = Vec::with_capacity(1 + self.prefix_args.len() + 1);
68+
cmd.push(self.program.clone());
69+
cmd.extend(self.prefix_args.iter().cloned());
70+
cmd.push(script_path.to_string_lossy().to_string());
71+
cmd
5572
}
5673
}
5774

58-
fn resolve_default_shell() -> Result<std::path::PathBuf> {
75+
#[cfg(not(windows))]
76+
fn resolve_default_shell_spec() -> Result<ShellSpec> {
77+
let tried = "bash, sh";
5978
if let Ok(path) = which::which("bash") {
60-
return Ok(path);
79+
return Ok(ShellSpec {
80+
program: path.to_string_lossy().to_string(),
81+
prefix_args: vec!["-e".to_string()],
82+
extension: "sh",
83+
});
6184
}
6285
if let Ok(path) = which::which("sh") {
63-
return Ok(path);
86+
return Ok(ShellSpec {
87+
program: path.to_string_lossy().to_string(),
88+
prefix_args: vec!["-e".to_string()],
89+
extension: "sh",
90+
});
6491
}
65-
anyhow::bail!("Inline script requires `bash` or `sh` in PATH")
92+
anyhow::bail!("No suitable default shell found (tried {tried})")
6693
}
6794

68-
fn inline_shebang_command(script_path: &Path, mut cmd: Vec<String>) -> Vec<String> {
69-
let interpreter = cmd
70-
.first()
71-
.map(|value| value.to_ascii_lowercase())
72-
.unwrap_or_default();
73-
if interpreter.contains("pwsh") || interpreter.contains("powershell") {
74-
cmd.push("-NoProfile".to_string());
75-
cmd.push("-NonInteractive".to_string());
76-
cmd.push("-File".to_string());
77-
cmd.push(script_path.to_string_lossy().to_string());
78-
return cmd;
95+
#[cfg(windows)]
96+
fn resolve_default_shell_spec() -> Result<ShellSpec> {
97+
let tried = "pwsh, powershell, cmd";
98+
// Prefer PowerShell 7+ if available.
99+
if let Ok(path) = which::which("pwsh") {
100+
return Ok(ShellSpec {
101+
program: path.to_string_lossy().to_string(),
102+
prefix_args: vec![
103+
"-NoProfile".to_string(),
104+
"-NonInteractive".to_string(),
105+
"-ExecutionPolicy".to_string(),
106+
"Bypass".to_string(),
107+
"-File".to_string(),
108+
],
109+
extension: "ps1",
110+
});
111+
}
112+
if let Ok(path) = which::which("powershell") {
113+
return Ok(ShellSpec {
114+
program: path.to_string_lossy().to_string(),
115+
prefix_args: vec![
116+
"-NoProfile".to_string(),
117+
"-NonInteractive".to_string(),
118+
"-ExecutionPolicy".to_string(),
119+
"Bypass".to_string(),
120+
"-File".to_string(),
121+
],
122+
extension: "ps1",
123+
});
79124
}
125+
// As a last resort, try cmd.exe.
126+
if let Ok(path) = which::which("cmd") {
127+
return Ok(ShellSpec {
128+
program: path.to_string_lossy().to_string(),
129+
prefix_args: vec!["/d".to_string(), "/s".to_string(), "/c".to_string()],
130+
extension: "cmd",
131+
});
132+
}
133+
134+
anyhow::bail!("No suitable default shell found (tried {tried})")
135+
}
80136

81-
cmd.push(script_path.to_string_lossy().to_string());
82-
cmd
137+
fn extension_for_interpreter(interpreter: &str) -> &'static str {
138+
if interpreter.contains("pwsh") || interpreter.contains("powershell") {
139+
"ps1"
140+
} else if interpreter.contains("cmd") {
141+
"cmd"
142+
} else if interpreter.contains("python") {
143+
"py"
144+
} else {
145+
"sh"
146+
}
83147
}
84148

85149
async fn build_inline_entry(
86150
raw_entry: &str,
87151
hook_id: &str,
88152
store: &Store,
89153
) -> Result<(Vec<String>, TempDir)> {
90-
// Parse the shebang from the inline content (if any) to choose interpreter + extension.
91-
let shebang = parse_inline_shebang(raw_entry);
92-
let extension = inline_extension_from_interpreter(
93-
shebang
94-
.as_ref()
95-
.and_then(|cmd| cmd.first())
96-
.map(String::as_str),
97-
);
154+
// If there is a shebang, we can rely on `resolve_command([script_path])` later.
155+
// If there is no shebang, we choose a reasonable platform-specific default shell.
156+
let (shebang, default_shell) = if let Some(cmd) = parse_inline_shebang(raw_entry) {
157+
(Some(cmd), None)
158+
} else {
159+
let spec = resolve_default_shell_spec()?;
160+
trace!(program = %spec.program, "Selected default shell for inline script");
161+
(None, Some(spec))
162+
};
163+
164+
let extension = if let Some(cmd) = &shebang {
165+
cmd.first()
166+
.map(|s| extension_for_interpreter(s))
167+
.unwrap_or("sh")
168+
} else if let Some(spec) = &default_shell {
169+
spec.extension
170+
} else {
171+
"sh"
172+
};
98173

99174
let temp_dir = tempfile::tempdir_in(store.scratch_path())?;
100175
let script_path = temp_dir
@@ -104,16 +179,13 @@ async fn build_inline_entry(
104179
.await
105180
.context("Failed to write inline script")?;
106181

107-
// Build the command line using the shebang if present; otherwise use bash/sh.
108-
let entry = if let Some(cmd) = shebang {
109-
let entry = inline_shebang_command(&script_path, cmd);
110-
resolve_command(entry, None)
182+
let entry = if shebang.is_some() {
183+
// Run the temp script file, honoring its shebang.
184+
resolve_command(vec![script_path.to_string_lossy().to_string()], None)
111185
} else {
112-
let shell = resolve_default_shell()?;
113-
vec![
114-
shell.to_string_lossy().to_string(),
115-
script_path.to_string_lossy().to_string(),
116-
]
186+
// Execute via the chosen default shell by passing the script path.
187+
let spec = default_shell.expect("default_shell must be set if shebang is None");
188+
spec.build_for_script(&script_path)
117189
};
118190

119191
Ok((entry, temp_dir))
@@ -149,7 +221,7 @@ impl LanguageImpl for Script {
149221

150222
let raw_entry = hook.entry.raw();
151223
let repo_path = hook.repo_path().unwrap_or(hook.work_dir());
152-
let is_inline = !is_script_path_entry(raw_entry, repo_path);
224+
let is_inline = is_inline_script(raw_entry, repo_path);
153225
let mut inline_temp: Option<TempDir> = None;
154226

155227
let entry = if is_inline {

crates/prek/tests/languages/script.rs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ mod unix {
173173
}
174174

175175
#[test]
176-
fn inline_script_no_shebang() -> Result<()> {
176+
fn inline_script_no_shebang() {
177177
let context = TestContext::new();
178178
context.init_project();
179179
context.write_pre_commit_config(indoc::indoc! {r#"
@@ -206,8 +206,6 @@ mod unix {
206206
207207
----- stderr -----
208208
");
209-
210-
Ok(())
211209
}
212210

213211
#[test]
@@ -259,7 +257,7 @@ mod unix {
259257

260258
// Entry is written as a YAML block scalar (contains newlines) but the first token is a
261259
// real script path, so it should be treated as a normal `script` hook.
262-
context.write_pre_commit_config(indoc::indoc! {r#"
260+
context.write_pre_commit_config(indoc::indoc! {r"
263261
repos:
264262
- repo: local
265263
hooks:
@@ -270,7 +268,7 @@ mod unix {
270268
./script.sh --from-entry
271269
pass_filenames: false
272270
verbose: true
273-
"#});
271+
"});
274272

275273
let script = context.work_dir().child("script.sh");
276274
script.write_str(indoc::indoc! {r#"

docs/languages.md

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,75 @@ Use `system` for tools with special environment requirements that cannot run in
360360

361361
`script` runs repository-local scripts without a managed environment. For remote hooks, `entry` is resolved relative to the hook repository root; for local hooks, it is resolved relative to the current working directory.
362362

363-
If `entry` contains a newline, it is treated as an inline script. Inline scripts are written to a temporary file and executed based on the script content. If the first line starts with a shebang (`#!`), that interpreter is used (for example, a `pwsh` shebang yields a `.ps1` file). If no shebang is present, `bash` is preferred when available, otherwise `sh` is used.
363+
#### Inline scripts
364+
365+
prek supports **inline scripts** for `language: script`, so you can put a small script directly in `entry`.
366+
367+
**How prek decides “inline script” vs “script path”**
368+
369+
- If `entry` does *not* contain a newline, it is treated as a normal command/script path.
370+
- If `entry` *does* contain a newline, it is treated as an inline script **unless** the *first token* resolves to a real file in the repository (or current working directory for `repo: local`).
371+
372+
This means YAML block scalars are safe to use for readability even when `entry` is a script path.
373+
374+
**How inline scripts are executed**
375+
376+
- The inline content is written to a temporary file.
377+
- If the first line has a shebang (`#!`), prek executes the temp file and lets the OS/shebang decide the interpreter.
378+
- If there is **no shebang**, prek chooses a default shell:
379+
- On Unix: prefers `bash -e`, falls back to `sh -e`.
380+
- On Windows: prefers `pwsh`, then `powershell`, then `cmd.exe`.
381+
382+
Any hook arguments (`args`) and selected filenames are passed as arguments to the interpreter/script. For POSIX shells, use `"$@"` to read the filenames; for PowerShell, use `$args`.
383+
384+
**Examples**
385+
386+
Inline script without a shebang (uses the default shell):
387+
388+
```yaml
389+
repos:
390+
- repo: local
391+
hooks:
392+
- id: inline
393+
name: inline
394+
language: script
395+
entry: |
396+
echo "hello"
397+
printf 'args:'
398+
printf ' %s' "$@"
399+
printf '\n'
400+
verbose: true
401+
```
402+
403+
Inline script with a shebang (interpreter comes from the script content):
404+
405+
```yaml
406+
repos:
407+
- repo: local
408+
hooks:
409+
- id: inline-bash
410+
name: inline-bash
411+
language: script
412+
entry: |
413+
#!/usr/bin/env -S bash -e
414+
echo "running under bash"
415+
echo "args: $*"
416+
verbose: true
417+
```
418+
419+
Multiline `entry`, but still a script path (first token is an existing file):
420+
421+
```yaml
422+
repos:
423+
- repo: local
424+
hooks:
425+
- id: script-path
426+
name: script-path
427+
language: script
428+
entry: |
429+
./script.sh --from-entry
430+
pass_filenames: false
431+
```
364432

365433
Use `script` for simple repository scripts that only need file paths and no managed environment.
366434

0 commit comments

Comments
 (0)