use std::path::Path;
use std::str::FromStr;
use std::sync::LazyLock;
use anyhow::Result;
use memchr::memmem::Finder;
use serde::Deserialize;
use tracing::trace;
use crate::hook::Hook;
use crate::languages::version::LanguageRequest;
static FINDER: LazyLock<Finder> = LazyLock::new(|| Finder::new(b"# /// script"));
#[derive(Debug, Clone)]
pub struct Pep723Script {
pub metadata: Pep723Metadata,
pub prelude: String,
pub postlude: String,
}
impl Pep723Script {
pub async fn read(file: impl AsRef<Path>) -> Result<Option<Self>, Pep723Error> {
let contents = fs_err::tokio::read(&file).await?;
let ScriptTag {
prelude,
metadata,
postlude,
} = match ScriptTag::parse(&contents) {
Ok(Some(tag)) => tag,
Ok(None) => return Ok(None),
Err(err) => return Err(err),
};
let metadata = Pep723Metadata::from_str(&metadata)?;
Ok(Some(Self {
metadata,
prelude,
postlude,
}))
}
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct Pep723Metadata {
pub dependencies: Option<Vec<String>>,
pub requires_python: Option<String>,
}
impl FromStr for Pep723Metadata {
type Err = toml::de::Error;
fn from_str(raw: &str) -> Result<Self, Self::Err> {
let metadata = toml::from_str(raw)?;
Ok(metadata)
}
}
#[derive(Debug, thiserror::Error)]
pub enum Pep723Error {
#[error(
"An opening tag (`# /// script`) was found without a closing tag (`# ///`). Ensure that every line between the opening and closing tags (including empty lines) starts with a leading `#`."
)]
UnclosedBlock,
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Utf8(#[from] std::str::Utf8Error),
#[error(transparent)]
Toml(#[from] toml::de::Error),
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct ScriptTag {
prelude: String,
metadata: String,
postlude: String,
}
impl ScriptTag {
pub fn parse(contents: &[u8]) -> Result<Option<Self>, Pep723Error> {
let Some(index) = FINDER.find(contents) else {
return Ok(None);
};
if !(index == 0 || matches!(contents[index - 1], b'\r' | b'\n')) {
return Ok(None);
}
let prelude = std::str::from_utf8(&contents[..index])?;
let contents = &contents[index..];
let contents = std::str::from_utf8(contents)?;
let mut lines = contents.lines();
if lines.next().is_none_or(|line| line != "# /// script") {
return Ok(None);
}
let mut toml = vec![];
for line in lines {
let Some(line) = line.strip_prefix('#') else {
break;
};
if line.is_empty() {
toml.push("");
continue;
}
let Some(line) = line.strip_prefix(' ') else {
break;
};
toml.push(line);
}
let Some(index) = toml.iter().rev().position(|line| *line == "///") else {
return Err(Pep723Error::UnclosedBlock);
};
let index = toml.len() - index;
toml.truncate(index - 1);
let prelude = prelude.to_string();
let metadata = toml.join("\n") + "\n";
let postlude = contents
.lines()
.skip(index + 1)
.collect::<Vec<_>>()
.join("\n")
+ "\n";
Ok(Some(Self {
prelude,
metadata,
postlude,
}))
}
}
pub(crate) async fn extract_pep723_metadata(hook: &mut Hook) -> Result<()> {
if hook.entry.shell().is_some() {
trace!(
"Skipping reading PEP 723 metadata for hook `{hook}` because `shell` treats `entry` as shell source",
);
return Ok(());
}
if !hook.additional_dependencies.is_empty() {
trace!(
"Skipping reading PEP 723 metadata for hook `{hook}` because it already has `additional_dependencies`",
);
return Ok(());
}
let repo_path = hook.repo_path().unwrap_or(hook.work_dir());
let split = hook.entry.expect_direct().split()?;
let file = repo_path.join(&split[0]);
let Some(script) = Pep723Script::read(&file).await? else {
return Ok(());
};
if let Some(dependencies) = script.metadata.dependencies {
hook.additional_dependencies = dependencies.into_iter().collect();
}
if let Some(language_request) = script.metadata.requires_python {
if !hook.language_request.is_any() {
trace!(
"`language_version` is ignored because `requires_python` is specified in the PEP 723 metadata"
);
}
hook.language_request = LanguageRequest::parse(hook.language, &language_request)?;
}
Ok(())
}