diff --git a/crates/pixi_manifest/src/script/block.rs b/crates/pixi_manifest/src/script/block.rs new file mode 100644 index 0000000000..62b1eb21fc --- /dev/null +++ b/crates/pixi_manifest/src/script/block.rs @@ -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, + pub(crate) metadata_lines: Vec, +} + +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) +} diff --git a/crates/pixi_manifest/src/script/conda/entrypoint.rs b/crates/pixi_manifest/src/script/conda/entrypoint.rs new file mode 100644 index 0000000000..3b6537e60b --- /dev/null +++ b/crates/pixi_manifest/src/script/conda/entrypoint.rs @@ -0,0 +1,174 @@ +use std::str::FromStr; + +use itertools::Itertools; +use pixi_toml::{custom_error, custom_error_message_with_help}; +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 platform, like `linux-64`. + 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 { + 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( + custom_error_message_with_help( + &format!("`{}` is not a platform", name.escape_debug()), + "the entrypoint table takes platforms like `linux-64` and `win-64`, or the families `unix`, `linux`, `osx` and `win`", + ), + 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").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")); + } +} diff --git a/crates/pixi_manifest/src/script/conda/envelope.rs b/crates/pixi_manifest/src/script/conda/envelope.rs new file mode 100644 index 0000000000..d8d3c509ab --- /dev/null +++ b/crates/pixi_manifest/src/script/conda/envelope.rs @@ -0,0 +1,145 @@ +use super::error::EnvelopeErrorKind; +use crate::script::block::{BlockSourceMap, MetadataLine, without_line_ending}; + +pub(crate) const OPENING_MARKER: &str = "/// conda-script"; +pub(crate) const CLOSING_MARKER: &str = "/// end-conda-script"; +const PEP723_OPENING: &str = "# /// script"; + +/// The extracted content of a `conda-script` block. +pub(crate) struct CondaScriptBlock { + /// The block content with the comment prefix stripped from every line. + pub(crate) metadata: String, + pub(crate) source_map: BlockSourceMap, + /// The comment prefix of the block, taken from its opening line. + pub(crate) prefix: String, + /// Everything before the opening marker line. + pub(crate) prelude: String, + /// Everything after the closing marker line. + pub(crate) postlude: String, +} + +/// Extracts the single `conda-script` block from a source file. +/// +/// Returns `Ok(None)` when the file contains no opening marker with a valid +/// comment prefix. +pub(crate) fn parse_block(source: &str) -> Result, EnvelopeErrorKind> { + // A BOM must not become part of the comment prefix of a block on the + // first line. + let base = if source.starts_with('\u{feff}') { + '\u{feff}'.len_utf8() + } else { + 0 + }; + + let mut lines: Vec<(usize, &str)> = Vec::new(); + let mut offset = base; + for raw_line in source[base..].split_inclusive('\n') { + lines.push((offset, without_line_ending(raw_line))); + offset += raw_line.len(); + } + + let Some((opening_index, prefix)) = lines + .iter() + .enumerate() + .find_map(|(index, (_, line))| opening_prefix(line).map(|prefix| (index, prefix))) + else { + return Ok(None); + }; + let opening = line_span(lines[opening_index]); + + let mut toml_lines: Vec<&str> = Vec::new(); + let mut metadata_lines = Vec::new(); + let mut metadata_len = 0; + let mut closing_index = None; + let mut broken_line = None; + for (index, &(line_start, line)) in lines.iter().enumerate().skip(opening_index + 1) { + if let Some(rest) = line.strip_prefix(prefix) { + if rest.trim_end() == CLOSING_MARKER { + closing_index = Some(index); + break; + } + if rest.trim_end() == OPENING_MARKER { + return Err(EnvelopeErrorKind::MultipleBlocks { + first: opening, + second: line_span((line_start, line)), + }); + } + toml_lines.push(rest); + metadata_lines.push(MetadataLine { + metadata_start: metadata_len, + source_start: line_start + prefix.len(), + len: rest.len(), + }); + metadata_len += rest.len() + 1; + } else if line.trim_end() == prefix.trim_end() { + toml_lines.push(""); + metadata_lines.push(MetadataLine { + metadata_start: metadata_len, + source_start: line_start + line.trim_end().len(), + len: 0, + }); + metadata_len += 1; + } else { + broken_line = Some(line_span((line_start, line))); + break; + } + } + + let Some(closing_index) = closing_index else { + return Err(EnvelopeErrorKind::Unterminated { + opening, + broken_line, + prefix: prefix.to_owned(), + }); + }; + + if let Some(second) = lines[closing_index + 1..] + .iter() + .find(|(_, line)| opening_prefix(line).is_some()) + { + return Err(EnvelopeErrorKind::MultipleBlocks { + first: opening, + second: line_span(*second), + }); + } + + if let Some(pep723) = lines[..opening_index] + .iter() + .chain(&lines[closing_index + 1..]) + .find(|(_, line)| line.trim_end() == PEP723_OPENING) + { + return Err(EnvelopeErrorKind::BothBlockKinds { + conda_script: opening, + pep723: line_span(*pep723), + }); + } + + let after_closing = lines + .get(closing_index + 1) + .map_or(source.len(), |&(start, _)| start); + + Ok(Some(CondaScriptBlock { + metadata: toml_lines.join("\n") + "\n", + source_map: BlockSourceMap { + opening: opening.clone(), + metadata_lines, + }, + prefix: prefix.to_owned(), + prelude: source[..opening.start].to_owned(), + postlude: source[after_closing..].to_owned(), + })) +} + +fn line_span((start, line): (usize, &str)) -> std::ops::Range { + start..start + line.trim_end().len() +} + +/// The comment prefix when `line` opens a `conda-script` block. +/// +/// A prefix must be non-empty and free of alphanumeric characters, so a +/// mention of the marker inside code (`x = "// /// conda-script"`) does not +/// open a block. +fn opening_prefix(line: &str) -> Option<&str> { + let prefix = line.trim_end().strip_suffix(OPENING_MARKER)?; + (!prefix.is_empty() && !prefix.contains(char::is_alphanumeric)).then_some(prefix) +} diff --git a/crates/pixi_manifest/src/script/conda/error.rs b/crates/pixi_manifest/src/script/conda/error.rs new file mode 100644 index 0000000000..48d6d3eabc --- /dev/null +++ b/crates/pixi_manifest/src/script/conda/error.rs @@ -0,0 +1,184 @@ +use std::{fmt, ops::Range, sync::Arc}; + +use miette::{Diagnostic, LabeledSpan, NamedSource, SourceCode}; +use pixi_toml::TomlDiagnostic; +use thiserror::Error; + +use crate::script::block::BlockSourceMap; + +/// Errors produced while reading a `conda-script` file. +#[derive(Debug, Error, Diagnostic)] +pub enum CondaScriptError { + #[error(transparent)] + #[diagnostic(transparent)] + Envelope(#[from] Box), + + #[error(transparent)] + #[diagnostic(transparent)] + Metadata(#[from] Box), + + #[error(transparent)] + Io(#[from] std::io::Error), + + #[error("a file containing a conda-script block must be valid UTF-8")] + #[diagnostic(help("the block holds TOML, which is defined to be UTF-8"))] + Utf8(#[from] std::str::Utf8Error), +} + +#[derive(Debug, Clone)] +pub(crate) enum EnvelopeErrorKind { + Unterminated { + opening: Range, + broken_line: Option>, + prefix: String, + }, + MultipleBlocks { + first: Range, + second: Range, + }, + BothBlockKinds { + conda_script: Range, + pep723: Range, + }, +} + +/// A malformed `conda-script` comment envelope. +#[derive(Debug)] +pub struct EnvelopeError { + pub(crate) kind: EnvelopeErrorKind, + pub(crate) source: NamedSource>, +} + +impl fmt::Display for EnvelopeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.kind { + EnvelopeErrorKind::Unterminated { .. } => f.write_str( + "the `/// conda-script` block has no closing `/// end-conda-script` marker", + ), + EnvelopeErrorKind::MultipleBlocks { .. } => { + f.write_str("the file contains more than one conda-script block") + } + EnvelopeErrorKind::BothBlockKinds { .. } => f.write_str( + "the file contains both a PEP 723 `script` block and a conda-script block", + ), + } + } +} + +impl std::error::Error for EnvelopeError {} + +impl Diagnostic for EnvelopeError { + fn source_code(&self) -> Option<&dyn SourceCode> { + Some(&self.source) + } + + fn help<'a>(&'a self) -> Option> { + match &self.kind { + EnvelopeErrorKind::Unterminated { + broken_line: Some(_), + prefix, + .. + } => Some(Box::new(format!( + "every line of the block must start with its comment prefix {prefix:?}" + ))), + EnvelopeErrorKind::Unterminated { prefix, .. } => Some(Box::new(format!( + "close the block with `{prefix}/// end-conda-script`" + ))), + EnvelopeErrorKind::MultipleBlocks { .. } => Some(Box::new( + "a file may contain at most one conda-script block", + )), + EnvelopeErrorKind::BothBlockKinds { .. } => Some(Box::new( + "keep either the PEP 723 block or the conda-script block, not both", + )), + } + } + + fn labels(&self) -> Option + '_>> { + let labels = match &self.kind { + EnvelopeErrorKind::Unterminated { + opening, + broken_line, + .. + } => { + let mut labels = vec![LabeledSpan::new_primary_with_span( + Some("the block opens here".to_owned()), + opening.clone(), + )]; + if let Some(broken_line) = broken_line { + labels.push(LabeledSpan::new_with_span( + Some("this line does not start with the block's prefix".to_owned()), + broken_line.clone(), + )); + } + labels + } + EnvelopeErrorKind::MultipleBlocks { first, second } => vec![ + LabeledSpan::new_with_span( + Some("the first block opens here".to_owned()), + first.clone(), + ), + LabeledSpan::new_primary_with_span( + Some("a second block opens here".to_owned()), + second.clone(), + ), + ], + EnvelopeErrorKind::BothBlockKinds { + conda_script, + pep723, + } => vec![ + LabeledSpan::new_primary_with_span( + Some("the conda-script block opens here".to_owned()), + conda_script.clone(), + ), + LabeledSpan::new_with_span( + Some("the PEP 723 block opens here".to_owned()), + pep723.clone(), + ), + ], + }; + Some(Box::new(labels.into_iter())) + } +} + +/// Invalid TOML inside a `conda-script` block, with spans mapped back into +/// the original file. +#[derive(Debug)] +pub struct MetadataError { + pub(crate) error: TomlDiagnostic, + pub(crate) source: NamedSource>, + pub(crate) source_map: BlockSourceMap, +} + +impl fmt::Display for MetadataError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.error.fmt(f) + } +} + +impl std::error::Error for MetadataError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.error) + } +} + +impl Diagnostic for MetadataError { + fn help<'a>(&'a self) -> Option> { + self.error.help() + } + + fn source_code(&self) -> Option<&dyn SourceCode> { + Some(&self.source) + } + + fn labels(&self) -> Option + '_>> { + Some(Box::new(self.error.labels()?.map(|label| { + let span = self.source_map.span(label.offset(), label.len(), 0); + let text = label.label().map(str::to_owned); + if label.primary() { + LabeledSpan::new_primary_with_span(text, span) + } else { + LabeledSpan::new_with_span(text, span) + } + }))) + } +} diff --git a/crates/pixi_manifest/src/script/conda/manifest.rs b/crates/pixi_manifest/src/script/conda/manifest.rs new file mode 100644 index 0000000000..5f97ff23d8 --- /dev/null +++ b/crates/pixi_manifest/src/script/conda/manifest.rs @@ -0,0 +1,822 @@ +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; + +use miette::NamedSource; +use toml_edit::DocumentMut; + +use super::{ + CondaScriptError, CondaScriptMetadata, + envelope::{self, CLOSING_MARKER, OPENING_MARKER}, + error::{EnvelopeError, MetadataError}, +}; +use crate::script::block::{LineEnding, serialize_block}; + +/// A code file containing a `conda-script` metadata block. +#[derive(Debug, Clone)] +pub struct CondaScriptManifest { + path: PathBuf, + metadata: CondaScriptMetadata, + toml: String, + prefix: String, + prelude: String, + postlude: String, + line_ending: LineEnding, +} + +impl CondaScriptManifest { + /// Read the `conda-script` block from a file. + /// + /// Returns `Ok(None)` when the file contains no block. + pub fn from_path(path: impl AsRef) -> Result, CondaScriptError> { + let contents = fs_err::read(&path)?; + Self::from_source(path, &contents) + } + + /// Parse a `conda-script` block from source at a given path. + /// + /// The path is only used for diagnostics and to locate the script later; + /// this function never reads it. + pub fn from_source( + path: impl AsRef, + contents: &[u8], + ) -> Result, CondaScriptError> { + // A quick byte scan keeps files without a block out of the UTF-8 + // requirement: only a file mentioning the marker must decode. + if !contents + .windows(OPENING_MARKER.len()) + .any(|window| window == OPENING_MARKER.as_bytes()) + { + return Ok(None); + } + + let path = std::path::absolute(path)?; + let source: Arc = Arc::from(std::str::from_utf8(contents)?); + let source_name = path.to_string_lossy().into_owned(); + + let block = match envelope::parse_block(&source) { + Ok(Some(block)) => block, + Ok(None) => return Ok(None), + Err(kind) => { + return Err(Box::new(EnvelopeError { + kind, + source: NamedSource::new(source_name, source), + }) + .into()); + } + }; + + let metadata = match CondaScriptMetadata::from_toml_str(&block.metadata) { + Ok(metadata) => metadata, + Err(mut errors) => { + return Err(Box::new(MetadataError { + error: errors.errors.remove(0).into(), + source: NamedSource::new(source_name, source), + source_map: block.source_map, + }) + .into()); + } + }; + + Ok(Some(Self { + path, + metadata, + toml: block.metadata, + prefix: block.prefix, + prelude: block.prelude, + postlude: block.postlude, + line_ending: LineEnding::detect(contents), + })) + } + + /// The absolute path of the script file. + pub fn path(&self) -> &Path { + &self.path + } + + /// The parsed block content. + pub fn metadata(&self) -> &CondaScriptMetadata { + &self.metadata + } + + /// The raw TOML text of the block, without comment prefixes. + pub fn toml(&self) -> &str { + &self.toml + } + + /// The block content as an editable TOML document. + pub fn metadata_document(&self) -> Result { + self.toml.parse() + } + + /// The full file contents with the block replaced by `metadata`. + /// + /// The code around the block and the comment prefix stay untouched. + pub fn render_metadata(&self, metadata: &DocumentMut) -> String { + let block = serialize_block( + &metadata.to_string(), + &self.prefix, + &format!("{}{OPENING_MARKER}", self.prefix), + &format!("{}{CLOSING_MARKER}", self.prefix), + self.line_ending.as_str(), + ); + format!("{}{}{}", self.prelude, block, self.postlude) + } + + /// Replace the metadata block while preserving the code around it. + pub fn write_metadata(&self, metadata: &DocumentMut) -> Result<(), CondaScriptError> { + fs_err::write(&self.path, self.render_metadata(metadata))?; + Ok(()) + } +} +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use pixi_pypi_spec::PypiPackageName; + use pixi_test_utils::format_diagnostic; + use rattler_conda_types::PackageName; + + use super::super::Entrypoint; + use super::*; + + /// A name for the source in diagnostics; `from_source` is given the + /// contents directly and never reads this path. + /// + /// It points inside the crate because `from_source` absolutizes the path: + /// `format_diagnostic` rewrites the crate root to `` before it + /// normalizes separators, so the snapshots hold on Windows too. + fn example_path() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("example.c") + } + + fn parse(contents: &str) -> Result, CondaScriptError> { + CondaScriptManifest::from_source(example_path(), contents.as_bytes()) + } + + fn parse_error(contents: &str) -> String { + format_diagnostic(&parse(contents).unwrap_err()) + } + + #[test] + fn parses_a_c_style_block() { + let manifest = parse( + r#"// /// conda-script +// channels = ["conda-forge"] +// entrypoint = "gcc -o ${CACHE}/main ${SCRIPT} -lz && ${CACHE}/main" +// +// [dependencies] +// gcc = "*" +// zlib = { version = "1.3.*", when = "__unix" } +// /// end-conda-script +#include +"#, + ) + .unwrap() + .unwrap(); + + let metadata = manifest.metadata(); + assert_eq!( + metadata + .channels + .iter() + .map(ToString::to_string) + .collect::>(), + ["conda-forge"] + ); + assert!(matches!( + &metadata.entrypoint, + Entrypoint::Uniform(command) + if command == "gcc -o ${CACHE}/main ${SCRIPT} -lz && ${CACHE}/main" + )); + insta::assert_snapshot!( + metadata + .dependencies + .iter() + .map(|(name, spec)| format!("{} = {spec:?}", name.as_normalized())) + .collect::>() + .join("\n"), + @r#" + gcc = Version(Any) + zlib = DetailedVersion(DetailedSpec { version: Some(StrictRange(StartsWith, StrictVersion(Version { version: [[0], [1], [3]], local: [] }))), build: None, build_number: None, file_name: None, extras: None, flags: None, channel: None, subdir: None, license: None, license_family: None, condition: Some(MatchSpec(MatchSpec { name: Exact(PackageName { normalized: None, source: "__unix" }), version: None, build: None, build_number: None, file_name: None, extras: None, flags: None, channel: None, subdir: None, namespace: None, md5: None, sha256: None, url: None, license: None, license_family: None, condition: None, track_features: None })), track_features: None, md5: None, sha256: None }) + "# + ); + assert!( + manifest + .toml() + .starts_with("channels = [\"conda-forge\"]\n") + ); + } + + #[test] + fn recognizes_odd_comment_prefixes() { + for (prefix, name) in [ + ("-- ", "Lua"), + ("; ", "Lisp"), + ("%% ", "MATLAB"), + ("#\t", "tabbed"), + ] { + let contents = format!( + "{prefix}/// conda-script\n{prefix}channels = [\"conda-forge\"]\n{prefix}entrypoint = \"run ${{SCRIPT}}\"\n{prefix}/// end-conda-script\n" + ); + let manifest = parse(&contents) + .unwrap_or_else(|error| panic!("{name} block failed: {error}")) + .unwrap_or_else(|| panic!("{name} block was not recognized")); + assert_eq!(manifest.metadata().channels.len(), 1); + } + } + + #[test] + fn a_prefix_with_alphanumerics_or_nothing_is_not_an_opening() { + // A mention of the marker inside code must not open a block. + let embedded = parse("const char *marker = \"// /// conda-script\";\n").unwrap(); + assert!(embedded.is_none()); + + // The prefix must be non-empty: a bare marker line opens nothing. + let bare = parse("/// conda-script\nchannels = []\n/// end-conda-script\n").unwrap(); + assert!(bare.is_none()); + } + + #[test] + fn a_bare_prefix_line_is_an_empty_metadata_line() { + let manifest = parse( + "# /// conda-script\n# channels = [\"conda-forge\"]\n#\n# entrypoint = \"python ${SCRIPT}\"\n# /// end-conda-script\n", + ) + .unwrap() + .unwrap(); + assert_eq!( + manifest.toml(), + "channels = [\"conda-forge\"]\n\nentrypoint = \"python ${SCRIPT}\"\n" + ); + } + + #[test] + fn parses_crlf_line_endings() { + let contents = "# /// conda-script\r\n# channels = [\"conda-forge\"]\r\n# entrypoint = \"python ${SCRIPT}\"\r\n# /// end-conda-script\r\nprint()\r\n"; + let manifest = parse(contents).unwrap().unwrap(); + assert_eq!(manifest.metadata().channels.len(), 1); + } + + #[test] + fn parses_a_block_behind_a_utf8_bom() { + let contents = "\u{feff}# /// conda-script\n# channels = [\"conda-forge\"]\n# entrypoint = \"python ${SCRIPT}\"\n# /// end-conda-script\n"; + let manifest = parse(contents).unwrap().unwrap(); + assert_eq!(manifest.metadata().channels.len(), 1); + } + + #[test] + fn parses_toml_1_1_multiline_inline_tables() { + let manifest = parse( + r#"// /// conda-script +// channels = ["conda-forge"] +// entrypoint = "python ${SCRIPT}" +// +// [dependencies] +// pytorch = { +// version = ">=2.4", +// build = "*cuda*", +// } +// /// end-conda-script +"#, + ) + .unwrap() + .unwrap(); + assert!( + manifest + .metadata() + .dependencies + .contains_key(&PackageName::new_unchecked("pytorch")) + ); + } + + #[test] + fn parses_the_tool_pixi_table_and_ignores_foreign_tools() { + let manifest = parse( + r#"# /// conda-script +# channels = ["conda-forge"] +# entrypoint = "python ${SCRIPT}" +# +# [dependencies] +# python = "3.13.*" +# +# [tool.pixi.dependencies] +# simple-app = { git = "https://github.com/prefix-dev/pixi-build-testsuite.git" } +# +# [tool.pixi.pypi-dependencies] +# requests = ">=2" +# +# [tool.some-future-runner] +# option = { anything = "goes" } +# /// end-conda-script +"#, + ) + .unwrap() + .unwrap(); + + let pixi = &manifest.metadata().pixi; + let simple_app = pixi + .dependencies + .get(&PackageName::new_unchecked("simple-app")) + .unwrap(); + assert!(simple_app.is_source()); + assert!( + pixi.pypi_dependencies + .contains_key(&PypiPackageName::from_str("requests").unwrap()) + ); + } + + #[test] + fn an_empty_dependency_table_means_any_version() { + let manifest = parse( + "# /// conda-script\n# channels = [\"conda-forge\"]\n# entrypoint = \"python ${SCRIPT}\"\n# [dependencies]\n# python = {}\n# /// end-conda-script\n", + ) + .unwrap() + .unwrap(); + insta::assert_snapshot!( + format!("{:?}", manifest.metadata().dependencies[&PackageName::new_unchecked("python")]), + @"Version(Any)" + ); + } + + #[test] + fn a_file_without_a_block_is_not_a_conda_script() { + assert!(parse("print('hello')\n").unwrap().is_none()); + // A file without the marker never has to be valid UTF-8. + assert!( + CondaScriptManifest::from_source(example_path(), &[0xff, 0xfe, 0x00]) + .unwrap() + .is_none() + ); + } + + #[test] + fn reads_a_block_from_disk() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("main.c"); + fs_err::write( + &path, + "// /// conda-script\n// channels = [\"conda-forge\"]\n// entrypoint = \"run ${SCRIPT}\"\n// /// end-conda-script\n", + ) + .unwrap(); + + let manifest = CondaScriptManifest::from_path(&path).unwrap().unwrap(); + assert_eq!(manifest.path(), path); + + let empty = directory.path().join("empty.c"); + fs_err::write(&empty, "int main(void) { return 0; }\n").unwrap(); + assert!(CondaScriptManifest::from_path(&empty).unwrap().is_none()); + } + + #[test] + fn errors_on_an_unterminated_block() { + insta::assert_snapshot!(parse_error( + "// /// conda-script\n// channels = [\"conda-forge\"]\n" + ), @r#" + × the `/// conda-script` block has no closing `/// end-conda-script` marker + ╭─[/crates/pixi_manifest/example.c:1:1] + 1 │ // /// conda-script + · ─────────┬───────── + · ╰── the block opens here + 2 │ // channels = ["conda-forge"] + ╰──── + help: close the block with `// /// end-conda-script` + "#); + } + + #[test] + fn errors_on_a_line_without_the_prefix() { + insta::assert_snapshot!(parse_error( + "// /// conda-script\n// channels = [\"conda-forge\"]\nint main(void) {}\n// /// end-conda-script\n" + ), @r#" + × the `/// conda-script` block has no closing `/// end-conda-script` marker + ╭─[/crates/pixi_manifest/example.c:1:1] + 1 │ // /// conda-script + · ─────────┬───────── + · ╰── the block opens here + 2 │ // channels = ["conda-forge"] + 3 │ int main(void) {} + · ────────┬──────── + · ╰── this line does not start with the block's prefix + 4 │ // /// end-conda-script + ╰──── + help: every line of the block must start with its comment prefix "// " + "#); + } + + #[test] + fn errors_on_multiple_blocks() { + insta::assert_snapshot!(parse_error( + "# /// conda-script\n# channels = [\"conda-forge\"]\n# entrypoint = \"python ${SCRIPT}\"\n# /// end-conda-script\nprint()\n# /// conda-script\n# channels = [\"bioconda\"]\n# /// end-conda-script\n" + ), @r#" + × the file contains more than one conda-script block + ╭─[/crates/pixi_manifest/example.c:1:1] + 1 │ # /// conda-script + · ─────────┬──────── + · ╰── the first block opens here + 2 │ # channels = ["conda-forge"] + ╰──── + ╭─[/crates/pixi_manifest/example.c:6:1] + 5 │ print() + 6 │ # /// conda-script + · ─────────┬──────── + · ╰── a second block opens here + 7 │ # channels = ["bioconda"] + ╰──── + help: a file may contain at most one conda-script block + "#); + } + + #[test] + fn errors_on_a_second_opening_marker_inside_the_block() { + insta::assert_snapshot!(parse_error( + "# /// conda-script\n# channels = [\"conda-forge\"]\n# /// conda-script\n# entrypoint = \"python ${SCRIPT}\"\n# /// end-conda-script\n" + ), @r#" + × the file contains more than one conda-script block + ╭─[/crates/pixi_manifest/example.c:3:1] + 1 │ # /// conda-script + · ─────────┬──────── + · ╰── the first block opens here + 2 │ # channels = ["conda-forge"] + 3 │ # /// conda-script + · ─────────┬──────── + · ╰── a second block opens here + 4 │ # entrypoint = "python ${SCRIPT}" + ╰──── + help: a file may contain at most one conda-script block + "#); + } + + #[test] + fn errors_on_a_second_block_with_another_prefix() { + insta::assert_snapshot!(parse_error( + "# /// conda-script\n# channels = [\"conda-forge\"]\n# entrypoint = \"python ${SCRIPT}\"\n# /// end-conda-script\nprint()\n// /// conda-script\n// channels = [\"bioconda\"]\n// /// end-conda-script\n" + ), @r#" + × the file contains more than one conda-script block + ╭─[/crates/pixi_manifest/example.c:1:1] + 1 │ # /// conda-script + · ─────────┬──────── + · ╰── the first block opens here + 2 │ # channels = ["conda-forge"] + ╰──── + ╭─[/crates/pixi_manifest/example.c:6:1] + 5 │ print() + 6 │ // /// conda-script + · ─────────┬───────── + · ╰── a second block opens here + 7 │ // channels = ["bioconda"] + ╰──── + help: a file may contain at most one conda-script block + "#); + } + + #[test] + fn errors_on_a_pep_723_block_next_to_a_conda_script_block() { + insta::assert_snapshot!(parse_error( + "# /// script\n# dependencies = []\n# ///\n\n# /// conda-script\n# channels = [\"conda-forge\"]\n# entrypoint = \"python ${SCRIPT}\"\n# /// end-conda-script\n" + ), @r#" + × the file contains both a PEP 723 `script` block and a conda-script block + ╭─[/crates/pixi_manifest/example.c:1:1] + 1 │ # /// script + · ──────┬───── + · ╰── the PEP 723 block opens here + 2 │ # dependencies = [] + ╰──── + ╭─[/crates/pixi_manifest/example.c:5:1] + 4 │ + 5 │ # /// conda-script + · ─────────┬──────── + · ╰── the conda-script block opens here + 6 │ # channels = ["conda-forge"] + ╰──── + help: keep either the PEP 723 block or the conda-script block, not both + "#); + } + + #[test] + fn toml_syntax_errors_point_into_the_original_file() { + insta::assert_snapshot!(parse_error( + "// /// conda-script\n// channels = [\"conda-forge\"\n// entrypoint = \"python ${SCRIPT}\"\n// /// end-conda-script\n" + ), @r#" + × expected a right bracket, found an identifier + ╰─▶ expected a right bracket, found an identifier + ╭─[/crates/pixi_manifest/example.c:3:4] + 2 │ // channels = ["conda-forge" + 3 │ // entrypoint = "python ${SCRIPT}" + · ───────────── + 4 │ // /// end-conda-script + ╰──── + "#); + } + + #[test] + fn errors_on_an_unknown_top_level_key() { + insta::assert_snapshot!(parse_error( + "# /// conda-script\n# channels = [\"conda-forge\"]\n# entrypoint = \"python ${SCRIPT}\"\n# platforms = [\"linux-64\"]\n# /// end-conda-script\n" + ), @r#" + × Unexpected keys, expected only 'channels', 'entrypoint', 'dependencies', 'tool' + ╰─▶ Unexpected keys, expected only 'channels', 'entrypoint', 'dependencies', 'tool' + ╭─[/crates/pixi_manifest/example.c:4:3] + 3 │ # entrypoint = "python ${SCRIPT}" + 4 │ # platforms = ["linux-64"] + · ────┬──── + · ╰── 'platforms' was not expected here + 5 │ # /// end-conda-script + ╰──── + "#); + } + + #[test] + fn errors_on_missing_required_keys() { + insta::assert_snapshot!(parse_error( + "# /// conda-script\n# [dependencies]\n# python = \"*\"\n# /// end-conda-script\n" + ), @r#" + × missing field 'channels' in table + ╰─▶ missing field 'channels' in table + ╭─[/crates/pixi_manifest/example.c:2:3] + 1 │ # /// conda-script + 2 │ ╭─▶ # [dependencies] + 3 │ ╰─▶ # python = "*" + 4 │ # /// end-conda-script + ╰──── + "#); + } + + #[test] + fn errors_on_empty_channels() { + insta::assert_snapshot!(parse_error( + "# /// conda-script\n# channels = []\n# entrypoint = \"python ${SCRIPT}\"\n# /// end-conda-script\n" + ), @r#" + × `channels` must list at least one channel + ╰─▶ `channels` must list at least one channel + ╭─[/crates/pixi_manifest/example.c:2:14] + 1 │ # /// conda-script + 2 │ # channels = [] + · ── + 3 │ # entrypoint = "python ${SCRIPT}" + ╰──── + "#); + } + + #[test] + fn errors_on_a_channel_that_is_empty() { + insta::assert_snapshot!(parse_error( + "# /// conda-script\n# channels = [\"conda-forge\", \"\"]\n# entrypoint = \"python ${SCRIPT}\"\n# /// end-conda-script\n" + ), @r#" + × a channel must not be empty + ╰─▶ a channel must not be empty + ╭─[/crates/pixi_manifest/example.c:2:30] + 1 │ # /// conda-script + 2 │ # channels = ["conda-forge", ""] + · ─ + 3 │ # entrypoint = "python ${SCRIPT}" + ╰──── + "#); + } + + #[test] + fn errors_on_a_dependency_name_that_is_empty() { + insta::assert_snapshot!(parse_error( + "# /// conda-script\n# channels = [\"conda-forge\"]\n# entrypoint = \"python ${SCRIPT}\"\n# [dependencies]\n# \"\" = \"*\"\n# /// end-conda-script\n" + ), @r#" + × a dependency name must not be empty + ╰─▶ a dependency name must not be empty + ╭─[/crates/pixi_manifest/example.c:5:3] + 4 │ # [dependencies] + 5 │ # "" = "*" + · ─ + 6 │ # /// end-conda-script + ╰──── + "#); + } + + #[test] + fn errors_on_a_dependency_name_with_invalid_characters() { + insta::assert_snapshot!(parse_error( + "# /// conda-script\n# channels = [\"conda-forge\"]\n# entrypoint = \"python ${SCRIPT}\"\n# [dependencies]\n# \"py thon\" = \"*\"\n# /// end-conda-script\n" + ), @r#" + × `py thon` is not a package name + ╰─▶ `py thon` is not a package name + ╭─[/crates/pixi_manifest/example.c:5:4] + 4 │ # [dependencies] + 5 │ # "py thon" = "*" + · ─────── + 6 │ # /// end-conda-script + ╰──── + help: package names consist of letters, digits, `-`, `_` and `.` + "#); + } + + #[test] + fn errors_on_an_unknown_dependency_key() { + insta::assert_snapshot!(parse_error( + "# /// conda-script\n# channels = [\"conda-forge\"]\n# entrypoint = \"python ${SCRIPT}\"\n# [dependencies]\n# python = { version = \"*\", build-string = \"*cuda*\" }\n# /// end-conda-script\n" + ), @r#" + × Unexpected keys, expected only 'version', 'build', 'build-number', 'channel', 'subdir', 'extras', 'flags', 'md5', 'sha256', 'url', 'when' + ╰─▶ Unexpected keys, expected only 'version', 'build', 'build-number', 'channel', 'subdir', 'extras', 'flags', 'md5', 'sha256', 'url', 'when' + ╭─[/crates/pixi_manifest/example.c:5:29] + 4 │ # [dependencies] + 5 │ # python = { version = "*", build-string = "*cuda*" } + · ──────┬───── + · ╰── 'build-string' was not expected here + 6 │ # /// end-conda-script + ╰──── + help: Did you mean 'build'? + "#); + } + + #[test] + fn errors_on_a_git_dependency_outside_tool_pixi() { + insta::assert_snapshot!(parse_error( + "# /// conda-script\n# channels = [\"conda-forge\"]\n# entrypoint = \"python ${SCRIPT}\"\n# [dependencies]\n# demo = { git = \"https://github.com/org/repo.git\" }\n# /// end-conda-script\n" + ), @r#" + × Unexpected keys, expected only 'version', 'build', 'build-number', 'channel', 'subdir', 'extras', 'flags', 'md5', 'sha256', 'url', 'when' + ╰─▶ Unexpected keys, expected only 'version', 'build', 'build-number', 'channel', 'subdir', 'extras', 'flags', 'md5', 'sha256', 'url', 'when' + ╭─[/crates/pixi_manifest/example.c:5:12] + 4 │ # [dependencies] + 5 │ # demo = { git = "https://github.com/org/repo.git" } + · ─┬─ + · ╰── 'git' was not expected here + 6 │ # /// end-conda-script + ╰──── + "#); + } + + #[test] + fn errors_on_url_combined_with_version() { + insta::assert_snapshot!(parse_error( + "# /// conda-script\n# channels = [\"conda-forge\"]\n# entrypoint = \"python ${SCRIPT}\"\n# [dependencies]\n# zlib = { url = \"https://example.com/zlib-1.3-h123_0.conda\", version = \"1.3.*\" }\n# /// end-conda-script\n" + ), @r#" + × `url` cannot be combined with `version` + ╰─▶ `url` cannot be combined with `version` + ╭─[/crates/pixi_manifest/example.c:5:63] + 4 │ # [dependencies] + 5 │ # zlib = { url = "https://example.com/zlib-1.3-h123_0.conda", version = "1.3.*" } + · ─────── + 6 │ # /// end-conda-script + ╰──── + help: the URL already determines the artifact; only `md5` and `sha256` may accompany it + "#); + } + + #[test] + fn errors_on_url_combined_with_when() { + insta::assert_snapshot!(parse_error( + "# /// conda-script\n# channels = [\"conda-forge\"]\n# entrypoint = \"python ${SCRIPT}\"\n# [dependencies]\n# zlib = { url = \"https://example.com/zlib-1.3-h123_0.conda\", when = \"__unix\" }\n# /// end-conda-script\n" + ), @r#" + × pixi cannot combine `url` with `when` yet + ╰─▶ pixi cannot combine `url` with `when` yet + ╭─[/crates/pixi_manifest/example.c:5:63] + 4 │ # [dependencies] + 5 │ # zlib = { url = "https://example.com/zlib-1.3-h123_0.conda", when = "__unix" } + · ──── + 6 │ # /// end-conda-script + ╰──── + help: the specification allows it, but pixi does not support conditions on URL specs so far + "#); + } + + #[test] + fn errors_on_a_url_that_is_not_a_conda_archive() { + insta::assert_snapshot!(parse_error( + "# /// conda-script\n# channels = [\"conda-forge\"]\n# entrypoint = \"python ${SCRIPT}\"\n# [dependencies]\n# demo = { url = \"https://example.com/demo.tar.gz\" }\n# /// end-conda-script\n" + ), @r#" + × `url` must point at a conda package archive + ╰─▶ `url` must point at a conda package archive + ╭─[/crates/pixi_manifest/example.c:5:10] + 4 │ # [dependencies] + 5 │ # demo = { url = "https://example.com/demo.tar.gz" } + · ─────────────────────────────────────────── + 6 │ # /// end-conda-script + ╰──── + help: source dependencies are not part of the conda-script specification; declare them under `[tool.pixi.dependencies]` + "#); + } + + #[test] + fn errors_on_an_unknown_entrypoint_platform() { + insta::assert_snapshot!(parse_error( + "# /// conda-script\n# channels = [\"conda-forge\"]\n# entrypoint = { linux = \"run ${SCRIPT}\", commodore = \"load ${SCRIPT}\" }\n# /// end-conda-script\n" + ), @r#" + × `commodore` is not a platform + ╰─▶ `commodore` is not a platform + ╭─[/crates/pixi_manifest/example.c:3:43] + 2 │ # channels = ["conda-forge"] + 3 │ # entrypoint = { linux = "run ${SCRIPT}", commodore = "load ${SCRIPT}" } + · ───────── + 4 │ # /// end-conda-script + ╰──── + help: the entrypoint table takes platforms like `linux-64` and `win-64`, or the families `unix`, `linux`, `osx` and `win` + "#); + } + + /// `format_diagnostic` turns every backslash into a forward slash, so the + /// escaped `\u{1b}` of the message reaches the snapshot as `/u{1b}`. + #[test] + fn an_entrypoint_key_with_control_characters_is_escaped_in_the_error() { + insta::assert_snapshot!(parse_error( + "# /// conda-script\n# channels = [\"conda-forge\"]\n# entrypoint = { \"\\u001B[31m\" = \"run ${SCRIPT}\" }\n# /// end-conda-script\n" + ), @r#" + × `/u{1b}[31m` is not a platform + ╰─▶ `/u{1b}[31m` is not a platform + ╭─[/crates/pixi_manifest/example.c:3:19] + 2 │ # channels = ["conda-forge"] + 3 │ # entrypoint = { "/u001B[31m" = "run ${SCRIPT}" } + · ────────── + 4 │ # /// end-conda-script + ╰──── + help: the entrypoint table takes platforms like `linux-64` and `win-64`, or the families `unix`, `linux`, `osx` and `win` + "#); + } + + #[test] + fn errors_on_an_empty_entrypoint_table() { + insta::assert_snapshot!(parse_error( + "# /// conda-script\n# channels = [\"conda-forge\"]\n# entrypoint = {}\n# /// end-conda-script\n" + ), @r#" + × the entrypoint table must contain at least one platform key + ╰─▶ the entrypoint table must contain at least one platform key + ╭─[/crates/pixi_manifest/example.c:3:16] + 2 │ # channels = ["conda-forge"] + 3 │ # entrypoint = {} + · ── + 4 │ # /// end-conda-script + ╰──── + "#); + } + + #[test] + fn errors_on_an_unsupported_tool_pixi_key() { + insta::assert_snapshot!(parse_error( + "# /// conda-script\n# channels = [\"conda-forge\"]\n# entrypoint = \"python ${SCRIPT}\"\n# [tool.pixi.tasks]\n# test = \"pytest\"\n# /// end-conda-script\n" + ), @r#" + × conda-script blocks do not support `tool.pixi.tasks` + ╰─▶ conda-script blocks do not support `tool.pixi.tasks` + ╭─[/crates/pixi_manifest/example.c:4:14] + 3 │ # entrypoint = "python ${SCRIPT}" + 4 │ # [tool.pixi.tasks] + · ───── + 5 │ # test = "pytest" + ╰──── + help: a script represents one implicit default environment + "#); + } + + #[test] + fn errors_on_package_names_that_collide_after_normalization() { + insta::assert_snapshot!(parse_error( + "# /// conda-script\n# channels = [\"conda-forge\"]\n# entrypoint = \"python ${SCRIPT}\"\n# [dependencies]\n# Python = \"*\"\n# python = \"3.13.*\"\n# /// end-conda-script\n" + ), @r#" + × duplicate key: `python` + ╰─▶ duplicate key: `python` + ╭─[/crates/pixi_manifest/example.c:6:3] + 4 │ # [dependencies] + 5 │ # Python = "*" + · ───┬── + · ╰── first defined here + 6 │ # python = "3.13.*" + · ───┬── + · ╰── duplicate defined here + 7 │ # /// end-conda-script + ╰──── + "#); + } + + #[test] + fn edits_write_back_through_the_comment_prefix() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("main.c"); + fs_err::write( + &path, + "#include \n// /// conda-script\n// channels = [\"conda-forge\"]\n// entrypoint = \"run ${SCRIPT}\"\n//\n// [dependencies]\n// gcc = \"*\"\n// /// end-conda-script\nint main(void) { return 0; }\n", + ) + .unwrap(); + + let manifest = CondaScriptManifest::from_path(&path).unwrap().unwrap(); + let mut metadata = manifest.metadata_document().unwrap(); + metadata["dependencies"]["zlib"] = toml_edit::value("1.3.*"); + manifest.write_metadata(&metadata).unwrap(); + + insta::assert_snapshot!(fs_err::read_to_string(&path).unwrap(), @r#" + #include + // /// conda-script + // channels = ["conda-forge"] + // entrypoint = "run ${SCRIPT}" + // + // [dependencies] + // gcc = "*" + // zlib = "1.3.*" + // /// end-conda-script + int main(void) { return 0; } + "#); + } + + #[test] + fn editing_preserves_crlf_line_endings_and_the_code_around_the_block() { + let contents = "print()\r\n# /// conda-script\r\n# channels = [\"conda-forge\"]\r\n# entrypoint = \"python ${SCRIPT}\"\r\n# /// end-conda-script\r\nprint('after')\r\n"; + let manifest = parse(contents).unwrap().unwrap(); + let metadata = manifest.metadata_document().unwrap(); + let rendered = manifest.render_metadata(&metadata); + assert_eq!(rendered, contents); + } +} diff --git a/crates/pixi_manifest/src/script/conda/metadata.rs b/crates/pixi_manifest/src/script/conda/metadata.rs new file mode 100644 index 0000000000..118c662cae --- /dev/null +++ b/crates/pixi_manifest/src/script/conda/metadata.rs @@ -0,0 +1,360 @@ +use std::{fmt::Display, hash::Hash, str::FromStr}; + +use indexmap::IndexMap; +use itertools::Itertools; +use pixi_pypi_spec::{PixiPypiSpec, PypiPackageName}; +use pixi_spec::PixiSpec; +use pixi_toml::{TomlFromStr, TomlWith, custom_error, custom_error_message_with_help}; +use rattler_conda_types::{NamedChannelOrUrl, PackageName, VersionSpec}; +use toml_span::{ + DeserError, ErrorKind, Spanned, Value, + de_helpers::{TableHelper, expected}, + value::ValueInner, +}; + +use super::entrypoint::Entrypoint; + +/// The dependency keys the `conda-script` specification allows. +const ALLOWED_DEPENDENCY_KEYS: &[&str] = &[ + "version", + "build", + "build-number", + "channel", + "subdir", + "extras", + "flags", + "md5", + "sha256", + "url", + "when", +]; + +/// The keys that may accompany `url`; the URL already determines the +/// artifact, so matchspec fields make no sense next to it. The specification +/// also allows `when`, which pixi cannot attach to a URL spec yet. +const URL_COMPATIBLE_KEYS: &[&str] = &["url", "md5", "sha256"]; + +/// The parsed content of a `conda-script` block. +#[derive(Debug, Clone)] +pub struct CondaScriptMetadata { + /// The conda channels the dependencies are solved from. + pub channels: Vec, + /// The command that runs the script. + pub entrypoint: Entrypoint, + /// The conda dependencies declared in `[dependencies]`. + pub dependencies: IndexMap, + /// The pixi-specific configuration under `[tool.pixi]`. + pub pixi: PixiTool, +} + +/// The `[tool.pixi]` table of a `conda-script` block. +#[derive(Debug, Clone, Default)] +pub struct PixiTool { + /// Conda dependencies in pixi's native spec syntax. They merge with the + /// `[dependencies]` table the way pixi merges features: both specs reach + /// the solver. + pub dependencies: IndexMap, + /// PyPI packages added to the environment. + pub pypi_dependencies: IndexMap, +} + +impl CondaScriptMetadata { + pub(crate) fn from_toml_str(text: &str) -> Result { + let mut value = toml_span::parse(text)?; + ::deserialize(&mut value) + } +} + +impl<'de> toml_span::Deserialize<'de> for CondaScriptMetadata { + fn deserialize(value: &mut Value<'de>) -> Result { + let mut th = TableHelper::new(value)?; + let mut errors = DeserError { errors: Vec::new() }; + + let channels = th + .required_s::>, Vec>>>>( + "channels", + ) + .ok() + .map(|channels| Spanned { + span: channels.span, + value: TomlWith::into_inner(channels.value), + }); + let entrypoint = th.required::("entrypoint").ok(); + + let dependencies = match th.take("dependencies") { + Some((_, mut value)) => match dependency_table(&mut value, conda_script_spec) { + Ok(dependencies) => dependencies, + Err(table_errors) => { + errors.merge(table_errors); + IndexMap::new() + } + }, + None => IndexMap::new(), + }; + + let pixi = match th.take("tool") { + Some((_, mut value)) => match tool_table(&mut value) { + Ok(pixi) => pixi, + Err(tool_errors) => { + errors.merge(tool_errors); + PixiTool::default() + } + }, + None => PixiTool::default(), + }; + + if let Err(finalize_errors) = th.finalize(None) { + errors.merge(finalize_errors); + } + + if let Some(channels) = &channels { + if channels.value.is_empty() { + errors.errors.push(custom_error( + "`channels` must list at least one channel", + channels.span, + )); + } + for channel in &channels.value { + if channel.value.as_str().trim().is_empty() { + errors + .errors + .push(custom_error("a channel must not be empty", channel.span)); + } + } + } + + if !errors.errors.is_empty() { + return Err(errors); + } + + Ok(Self { + channels: channels + .expect("missing channels were reported above") + .value + .into_iter() + .map(|channel| channel.value) + .collect(), + entrypoint: entrypoint.expect("a missing entrypoint was reported above"), + dependencies, + pixi, + }) + } +} + +/// Parses a `name = ` table, rejecting names that collide after +/// normalization. +fn dependency_table<'de, Name, Spec>( + value: &mut Value<'de>, + parse_spec: impl Fn(&mut Value<'de>) -> Result, +) -> Result, DeserError> +where + Name: FromStr + Hash + Eq + Clone, + Name::Err: Display, +{ + let table = match value.take() { + ValueInner::Table(table) => table, + inner => return Err(expected("a table", inner, value.span).into()), + }; + + let mut errors = DeserError { errors: Vec::new() }; + let mut result: IndexMap = IndexMap::new(); + let mut name_spans: IndexMap = IndexMap::new(); + for (key, mut value) in table.into_iter().sorted_by_key(|(key, _)| key.span.start) { + if key.name.is_empty() { + errors.errors.push(custom_error( + "a dependency name must not be empty", + key.span, + )); + continue; + } + let name = match Name::from_str(&key.name) { + Ok(name) => name, + Err(_) => { + errors.errors.push(custom_error( + custom_error_message_with_help( + &format!("`{}` is not a package name", key.name.escape_debug()), + "package names consist of letters, digits, `-`, `_` and `.`", + ), + key.span, + )); + continue; + } + }; + if let Some(first) = name_spans.get(&name) { + errors.errors.push(toml_span::Error { + kind: ErrorKind::DuplicateKey { + key: key.name.into_owned(), + first: *first, + }, + span: key.span, + line_info: None, + }); + continue; + } + name_spans.insert(name.clone(), key.span); + match parse_spec(&mut value) { + Ok(spec) => { + result.insert(name, spec); + } + Err(spec_errors) => errors.merge(spec_errors), + } + } + + if errors.errors.is_empty() { + Ok(result) + } else { + Err(errors) + } +} + +/// Parses one `[dependencies]` value, restricted to the keys the +/// `conda-script` specification defines. +fn conda_script_spec(value: &mut Value<'_>) -> Result { + let span = value.span; + if let Some(table) = value.as_table() { + let unknown: Vec<_> = table + .keys() + .filter(|key| !ALLOWED_DEPENDENCY_KEYS.contains(&key.name.as_ref())) + .map(|key| (key.name.to_string(), key.span)) + .collect(); + if !unknown.is_empty() { + return Err(toml_span::Error { + kind: ErrorKind::UnexpectedKeys { + keys: unknown, + expected: ALLOWED_DEPENDENCY_KEYS + .iter() + .map(ToString::to_string) + .collect(), + }, + span, + line_info: None, + } + .into()); + } + + if table.keys().any(|key| key.name == "url") { + let mut errors = DeserError { errors: Vec::new() }; + for key in table + .keys() + .filter(|key| !URL_COMPATIBLE_KEYS.contains(&key.name.as_ref())) + { + errors.errors.push(custom_error( + if key.name == "when" { + custom_error_message_with_help( + "pixi cannot combine `url` with `when` yet", + "the specification allows it, but pixi does not support conditions on URL specs so far", + ) + } else { + custom_error_message_with_help( + &format!("`url` cannot be combined with `{}`", key.name), + "the URL already determines the artifact; only `md5` and `sha256` may accompany it", + ) + }, + key.span, + )); + } + if !errors.errors.is_empty() { + return Err(errors); + } + } + + // Every key is optional and `version` defaults to `*`. + if table.is_empty() { + return Ok(PixiSpec::from(VersionSpec::Any)); + } + } + + let spec = ::deserialize(value)?; + if spec.is_source() { + return Err(custom_error( + custom_error_message_with_help( + "`url` must point at a conda package archive", + "source dependencies are not part of the conda-script specification; declare them under `[tool.pixi.dependencies]`", + ), + span, + ) + .into()); + } + Ok(spec) +} + +/// Parses the `[tool]` table: `pixi` is interpreted, every other tool's +/// table is ignored without looking inside it. +fn tool_table(value: &mut Value<'_>) -> Result { + let table = match value.take() { + ValueInner::Table(table) => table, + inner => return Err(expected("a table", inner, value.span).into()), + }; + + for (key, mut value) in table { + if key.name == "pixi" { + return pixi_tool_table(&mut value); + } + } + Ok(PixiTool::default()) +} + +fn pixi_tool_table(value: &mut Value<'_>) -> Result { + let mut th = TableHelper::new(value)?; + let mut errors = DeserError { errors: Vec::new() }; + + let dependencies = match th.take("dependencies") { + Some((_, mut value)) => { + match dependency_table( + &mut value, + ::deserialize, + ) { + Ok(dependencies) => dependencies, + Err(table_errors) => { + errors.merge(table_errors); + IndexMap::new() + } + } + } + None => IndexMap::new(), + }; + + let pypi_dependencies = match th.take("pypi-dependencies") { + Some((_, mut value)) => { + match dependency_table( + &mut value, + ::deserialize, + ) { + Ok(dependencies) => dependencies, + Err(table_errors) => { + errors.merge(table_errors); + IndexMap::new() + } + } + } + None => IndexMap::new(), + }; + + // Put the unclaimed keys back so each can be reported as unsupported. + if let Err(finalize_errors) = th.finalize(Some(value)) { + errors.merge(finalize_errors); + } + if let Some(table) = value.as_table() { + for key in table.keys().sorted_by_key(|key| key.span.start) { + errors.errors.push(custom_error( + custom_error_message_with_help( + &format!( + "conda-script blocks do not support `tool.pixi.{}`", + key.name + ), + "a script represents one implicit default environment", + ), + key.span, + )); + } + } + + if errors.errors.is_empty() { + Ok(PixiTool { + dependencies, + pypi_dependencies, + }) + } else { + Err(errors) + } +} diff --git a/crates/pixi_manifest/src/script/conda/mod.rs b/crates/pixi_manifest/src/script/conda/mod.rs new file mode 100644 index 0000000000..6e8eb99a59 --- /dev/null +++ b/crates/pixi_manifest/src/script/conda/mod.rs @@ -0,0 +1,18 @@ +//! The `conda-script` metadata block: conda channels, dependencies and an +//! entrypoint embedded in a comment block of any code file. +//! +//! A block opens with a line ending in `/// conda-script`, whose leading +//! comment characters become the prefix every following line must carry, and +//! closes with the prefix followed by `/// end-conda-script`. The content is +//! TOML 1.1, which allows multiline inline tables. + +mod entrypoint; +mod envelope; +mod error; +mod manifest; +mod metadata; + +pub use entrypoint::{Entrypoint, EntrypointSelector}; +pub use error::{CondaScriptError, EnvelopeError, MetadataError}; +pub use manifest::CondaScriptManifest; +pub use metadata::{CondaScriptMetadata, PixiTool}; diff --git a/crates/pixi_manifest/src/script/mod.rs b/crates/pixi_manifest/src/script/mod.rs new file mode 100644 index 0000000000..8f4b224594 --- /dev/null +++ b/crates/pixi_manifest/src/script/mod.rs @@ -0,0 +1,16 @@ +//! Manifests embedded in script files. +//! +//! A single code file can carry its manifest in a comment block: Python +//! files use the standardized PEP 723 `script` block, while files of any +//! language can use the `conda-script` block. The `block` module +//! holds the machinery both kinds share: comment-prefix stripping, source +//! maps for diagnostics, and serializing edits back into the block. + +mod block; +pub mod conda; +mod pep723; + +pub use pep723::{ + ScriptManifest, ScriptManifestDocument, ScriptManifestError, ScriptMetadataError, + ScriptWorkspaceConfig, +}; diff --git a/crates/pixi_manifest/src/script.rs b/crates/pixi_manifest/src/script/pep723.rs similarity index 94% rename from crates/pixi_manifest/src/script.rs rename to crates/pixi_manifest/src/script/pep723.rs index 9ea0c07282..92b8227e6c 100644 --- a/crates/pixi_manifest/src/script.rs +++ b/crates/pixi_manifest/src/script/pep723.rs @@ -1,15 +1,17 @@ use std::{ error::Error, fmt, - ops::Range, path::{Path, PathBuf}, sync::Arc, }; -use miette::{Diagnostic, LabeledSpan, NamedSource, SourceCode, SourceSpan}; +use miette::{Diagnostic, LabeledSpan, NamedSource, SourceCode}; use thiserror::Error; use toml_edit::{Array, DocumentMut, Item, Table, Value}; +use super::block::{ + BlockSourceMap, LineEnding, MetadataLine, serialize_block, without_line_ending, +}; use crate::{ TomlError, Warning, WorkspaceManifest, pyproject::PyProjectManifest, @@ -25,7 +27,8 @@ pub struct ScriptManifest { prelude: String, postlude: String, line_ending: LineEnding, - source_map: ScriptSourceMap, + source: Arc, + source_map: BlockSourceMap, } #[derive(Debug, Clone)] @@ -35,49 +38,11 @@ struct ScriptManifestContext { project_name: String, } -#[derive(Debug, Clone)] -struct MetadataLine { - metadata_start: usize, - source_start: usize, - len: usize, -} - -#[derive(Debug, Clone)] -struct ScriptSourceMap { - source: Arc, - opening: Range, - metadata_lines: Vec, -} - -impl ScriptSourceMap { - 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) - } - - 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) - } -} - #[derive(Debug)] pub struct ScriptMetadataError { error: TomlError, source: NamedSource>, - source_map: ScriptSourceMap, + source_map: BlockSourceMap, synthetic_prefix: usize, } @@ -160,29 +125,6 @@ pub struct ScriptWorkspaceConfig { pub platforms_explicit: bool, } -#[derive(Debug, Clone, Copy)] -enum LineEnding { - Lf, - CrLf, -} - -impl LineEnding { - fn detect(contents: &[u8]) -> Self { - if contents.windows(2).any(|window| window == b"\r\n") { - Self::CrLf - } else { - Self::Lf - } - } - - fn as_str(self) -> &'static str { - match self { - Self::Lf => "\n", - Self::CrLf => "\r\n", - } - } -} - impl ScriptManifest { /// Add a PEP 723 metadata block to a new or existing Python script. pub fn initialize( @@ -288,7 +230,7 @@ impl ScriptManifest { block.metadata.parse::().map_err(|error| { ScriptManifestError::Metadata(Box::new(ScriptMetadataError { error: error.into(), - source: NamedSource::new(source_name.clone(), Arc::clone(&block.source_map.source)), + source: NamedSource::new(source_name.clone(), Arc::clone(&block.source)), source_map: block.source_map.clone(), synthetic_prefix: 0, })) @@ -305,6 +247,7 @@ impl ScriptManifest { prelude: block.prelude, postlude: block.postlude, line_ending: block.line_ending, + source: block.source, source_map: block.source_map, })) } @@ -360,10 +303,7 @@ impl ScriptManifest { fn metadata_error(&self, error: TomlError, synthetic_prefix: usize) -> ScriptManifestError { ScriptManifestError::Metadata(Box::new(ScriptMetadataError { error, - source: NamedSource::new( - &self.context.source_name, - Arc::clone(&self.source_map.source), - ), + source: NamedSource::new(&self.context.source_name, Arc::clone(&self.source)), source_map: self.source_map.clone(), synthetic_prefix, })) @@ -813,7 +753,8 @@ struct ScriptBlock { metadata: String, postlude: String, line_ending: LineEnding, - source_map: ScriptSourceMap, + source: Arc, + source_map: BlockSourceMap, } impl ScriptBlock { @@ -887,8 +828,8 @@ impl ScriptBlock { metadata: toml.join("\n") + "\n", postlude: postlude.to_owned(), line_ending, - source_map: ScriptSourceMap { - source, + source, + source_map: BlockSourceMap { opening, metadata_lines, }, @@ -896,11 +837,6 @@ impl ScriptBlock { } } -fn without_line_ending(line: &str) -> &str { - let line = line.strip_suffix('\n').unwrap_or(line); - line.strip_suffix('\r').unwrap_or(line) -} - fn reject_duplicate_block(lines: &[&str]) -> Result<(), ScriptManifestError> { for (index, line) in lines.iter().enumerate() { if *line != "# /// script" { @@ -921,20 +857,7 @@ fn reject_duplicate_block(lines: &[&str]) -> Result<(), ScriptManifestError> { } fn serialize_metadata(metadata: &str, line_ending: &str) -> String { - let mut output = String::with_capacity(metadata.len() + 32); - output.push_str("# /// script"); - output.push_str(line_ending); - for line in metadata.lines() { - output.push('#'); - if !line.is_empty() { - output.push(' '); - output.push_str(line); - } - output.push_str(line_ending); - } - output.push_str("# ///"); - output.push_str(line_ending); - output + serialize_block(metadata, "# ", "# /// script", "# ///", line_ending) } #[cfg(test)]