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
108 changes: 108 additions & 0 deletions crates/pixi_manifest/src/script/block.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
//! Machinery shared by the metadata block kinds a script file can carry:
//! the PEP 723 `script` block and the `conda-script` block. Both embed TOML
//! in a comment block, strip the comment prefix on read, map offsets in the
//! extracted TOML back to the original file for diagnostics, and serialize
//! edited TOML back between the block markers.

use std::ops::Range;

use miette::SourceSpan;

#[derive(Debug, Clone, Copy)]
pub(crate) enum LineEnding {
Lf,
CrLf,
}

impl LineEnding {
pub(crate) fn detect(contents: &[u8]) -> Self {
if contents.windows(2).any(|window| window == b"\r\n") {
Self::CrLf
} else {
Self::Lf
}
}

pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Lf => "\n",
Self::CrLf => "\r\n",
}
}
}

/// One line of extracted block content: where it starts in the extracted
/// metadata and where its content starts in the original file.
#[derive(Debug, Clone)]
pub(crate) struct MetadataLine {
pub(crate) metadata_start: usize,
pub(crate) source_start: usize,
pub(crate) len: usize,
}

/// Maps offsets in the extracted metadata TOML back to the original file.
#[derive(Debug, Clone)]
pub(crate) struct BlockSourceMap {
pub(crate) opening: Range<usize>,
pub(crate) metadata_lines: Vec<MetadataLine>,
}

impl BlockSourceMap {
fn metadata_offset(&self, offset: usize) -> usize {
let Some(line) = self
.metadata_lines
.iter()
.rev()
.find(|line| line.metadata_start <= offset)
else {
return self.opening.start;
};
line.source_start + offset.saturating_sub(line.metadata_start).min(line.len)
}

/// A span in the original file for a span in the extracted metadata.
///
/// `synthetic_prefix` is the length of text prepended to the metadata
/// before parsing; offsets inside that prefix map to the opening marker.
pub(crate) fn span(&self, offset: usize, len: usize, synthetic_prefix: usize) -> SourceSpan {
let Some(metadata_start) = offset.checked_sub(synthetic_prefix) else {
return SourceSpan::from(self.opening.clone());
};
let metadata_end = offset.saturating_add(len).saturating_sub(synthetic_prefix);
let start = self.metadata_offset(metadata_start);
let end = self.metadata_offset(metadata_end).max(start);
SourceSpan::new(start.into(), end - start)
}
}

/// Serializes metadata TOML back into a comment block: every line carries
/// `prefix` (trimmed on empty lines), framed by the opening and closing
/// marker lines.
pub(crate) fn serialize_block(
metadata: &str,
prefix: &str,
opening: &str,
closing: &str,
line_ending: &str,
) -> String {
let mut output = String::with_capacity(metadata.len() + 64);
output.push_str(opening);
output.push_str(line_ending);
for line in metadata.lines() {
if line.is_empty() {
output.push_str(prefix.trim_end());
} else {
output.push_str(prefix);
output.push_str(line);
}
output.push_str(line_ending);
}
output.push_str(closing);
output.push_str(line_ending);
output
}

pub(crate) fn without_line_ending(line: &str) -> &str {
let line = line.strip_suffix('\n').unwrap_or(line);
line.strip_suffix('\r').unwrap_or(line)
}
173 changes: 173 additions & 0 deletions crates/pixi_manifest/src/script/conda/entrypoint.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
use std::str::FromStr;

use itertools::Itertools;
use pixi_toml::custom_error;
use rattler_conda_types::Platform;
use toml_span::{DeserError, Value, de_helpers::expected, value::ValueInner};

/// The command that runs a `conda-script` file.
#[derive(Debug, Clone)]
pub enum Entrypoint {
/// One command for every platform.
Uniform(String),
/// A command per platform selector.
PerPlatform(Vec<(EntrypointSelector, String)>),
}

/// A platform key of an entrypoint table.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntrypointSelector {
/// Any Unix platform.
Unix,
/// Any Linux platform.
Linux,
/// Any macOS platform.
Osx,
/// Any Windows platform.
Win,
/// One specific conda platform.
Platform(Platform),
}

impl Entrypoint {
/// The command for `platform`, taking the most specific matching key:
/// the exact platform wins over its family (`linux`, `osx`, `win`),
/// which wins over `unix`. Returns `None` when no key matches.
pub fn select(&self, platform: Platform) -> Option<&str> {
match self {
Entrypoint::Uniform(command) => Some(command),
Entrypoint::PerPlatform(commands) => {
let lookup = |selector: EntrypointSelector| {
commands.iter().find_map(|(candidate, command)| {
(*candidate == selector).then_some(command.as_str())
})
};
lookup(EntrypointSelector::Platform(platform))
.or_else(|| {
let family = if platform.is_linux() {
EntrypointSelector::Linux
} else if platform.is_osx() {
EntrypointSelector::Osx
} else if platform.is_windows() {
EntrypointSelector::Win
} else {
return None;
};
lookup(family)
})
.or_else(|| {
platform
.is_unix()
.then(|| lookup(EntrypointSelector::Unix))
.flatten()
})
}
}
}
}

impl<'de> toml_span::Deserialize<'de> for Entrypoint {
fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
let span = value.span;
match value.take() {
ValueInner::String(command) => Ok(Entrypoint::Uniform(command.into_owned())),
ValueInner::Table(table) => {
if table.is_empty() {
return Err(custom_error(
"the entrypoint table must contain at least one platform key",
span,
)
.into());
}
let mut errors = DeserError { errors: Vec::new() };
let mut commands = Vec::new();
for (key, mut command) in table.into_iter().sorted_by_key(|(key, _)| key.span.start)
{
let selector = match key.name.as_ref() {
"unix" => Some(EntrypointSelector::Unix),
"linux" => Some(EntrypointSelector::Linux),
"osx" => Some(EntrypointSelector::Osx),
"win" => Some(EntrypointSelector::Win),
name => match Platform::from_str(name) {
Ok(platform) => Some(EntrypointSelector::Platform(platform)),
Err(_) => {
errors.errors.push(custom_error(
format!(
"'{name}' is neither a platform family (`unix`, `linux`, `osx`, `win`) nor a conda platform"
),
key.span,
));
None
}
},
};
let command = match command.take() {
ValueInner::String(command) => Some(command.into_owned()),
inner => {
errors
.errors
.push(expected("a string", inner, command.span));
None
}
};
if let (Some(selector), Some(command)) = (selector, command) {
commands.push((selector, command));
}
}
if errors.errors.is_empty() {
Ok(Entrypoint::PerPlatform(commands))
} else {
Err(errors)
}
}
inner => Err(expected("a string or a table of platforms", inner, span).into()),
}
}
}

#[cfg(test)]
mod tests {
use toml_span::de_helpers::TableHelper;

use super::*;

fn parse_entrypoint(toml: &str) -> Entrypoint {
let mut value = toml_span::parse(toml).unwrap();
let mut th = TableHelper::new(&mut value).unwrap();
let entrypoint = th.required::<Entrypoint>("entrypoint").unwrap();
th.finalize(None).unwrap();
entrypoint
}

#[test]
fn a_uniform_entrypoint_matches_every_platform() {
let entrypoint = parse_entrypoint(r#"entrypoint = "python ${SCRIPT}""#);
assert_eq!(
entrypoint.select(Platform::Linux64),
Some("python ${SCRIPT}")
);
assert_eq!(entrypoint.select(Platform::Win64), Some("python ${SCRIPT}"));
}

#[test]
fn the_most_specific_platform_key_wins() {
let entrypoint = parse_entrypoint(
r#"entrypoint = { unix = "unix", linux = "linux", linux-64 = "linux-64", win = "win" }"#,
);
assert_eq!(entrypoint.select(Platform::Linux64), Some("linux-64"));
assert_eq!(entrypoint.select(Platform::LinuxAarch64), Some("linux"));
assert_eq!(entrypoint.select(Platform::Osx64), Some("unix"));
assert_eq!(entrypoint.select(Platform::Win64), Some("win"));
assert_eq!(entrypoint.select(Platform::WinArm64), Some("win"));
}

#[test]
fn a_platform_without_a_matching_key_selects_nothing() {
let windows_only = parse_entrypoint(r#"entrypoint = { win = "win" }"#);
assert_eq!(windows_only.select(Platform::Linux64), None);

let unix_only = parse_entrypoint(r#"entrypoint = { unix = "unix" }"#);
assert_eq!(unix_only.select(Platform::Win64), None);
assert_eq!(unix_only.select(Platform::LinuxRiscv64), Some("unix"));
}
}
Loading
Loading