Skip to content

Commit 9d4a9b3

Browse files
committed
feat: add the pixi_conda_script crate with the block parser and TOML model
The first piece of the `conda-script` proposal (#3751): a parser for the `/// conda-script` comment block. The envelope parser detects the comment prefix, extracts the TOML and maps diagnostics back into the original file; the model covers `channels`, `entrypoint` (string or platform table), `[dependencies]` restricted to the keys the spec defines, and `[tool.pixi]` with `dependencies` and `pypi-dependencies`.
1 parent 1f2ecf1 commit 9d4a9b3

8 files changed

Lines changed: 1563 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
[package]
2+
name = "pixi_conda_script"
3+
version = "0.1.0"
4+
authors.workspace = true
5+
edition.workspace = true
6+
description = "Parser for the `conda-script` metadata block embedded in code files"
7+
readme.workspace = true
8+
homepage.workspace = true
9+
repository.workspace = true
10+
license.workspace = true
11+
12+
[dependencies]
13+
fs-err = { workspace = true }
14+
indexmap = { workspace = true }
15+
itertools = { workspace = true }
16+
miette = { workspace = true }
17+
pixi_pypi_spec = { workspace = true }
18+
pixi_spec = { workspace = true }
19+
pixi_toml = { workspace = true }
20+
rattler_conda_types = { workspace = true }
21+
thiserror = { workspace = true }
22+
toml-span = { workspace = true }
23+
24+
[dev-dependencies]
25+
insta = { workspace = true }
26+
pixi_test_utils = { workspace = true }
27+
tempfile = { workspace = true }
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
use std::str::FromStr;
2+
3+
use itertools::Itertools;
4+
use pixi_toml::custom_error;
5+
use rattler_conda_types::Platform;
6+
use toml_span::{DeserError, Value, de_helpers::expected, value::ValueInner};
7+
8+
/// The command that runs a `conda-script` file.
9+
#[derive(Debug, Clone)]
10+
pub enum Entrypoint {
11+
/// One command for every platform.
12+
Uniform(String),
13+
/// A command per platform selector.
14+
PerPlatform(Vec<(EntrypointSelector, String)>),
15+
}
16+
17+
/// A platform key of an entrypoint table.
18+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19+
pub enum EntrypointSelector {
20+
/// Any Unix platform.
21+
Unix,
22+
/// Any Linux platform.
23+
Linux,
24+
/// Any macOS platform.
25+
Osx,
26+
/// Any Windows platform.
27+
Win,
28+
/// One specific conda platform.
29+
Platform(Platform),
30+
}
31+
32+
impl Entrypoint {
33+
/// The command for `platform`, taking the most specific matching key:
34+
/// the exact platform wins over its family (`linux`, `osx`, `win`),
35+
/// which wins over `unix`. Returns `None` when no key matches.
36+
pub fn select(&self, platform: Platform) -> Option<&str> {
37+
match self {
38+
Entrypoint::Uniform(command) => Some(command),
39+
Entrypoint::PerPlatform(commands) => {
40+
let lookup = |selector: EntrypointSelector| {
41+
commands.iter().find_map(|(candidate, command)| {
42+
(*candidate == selector).then_some(command.as_str())
43+
})
44+
};
45+
lookup(EntrypointSelector::Platform(platform))
46+
.or_else(|| {
47+
let family = if platform.is_linux() {
48+
EntrypointSelector::Linux
49+
} else if platform.is_osx() {
50+
EntrypointSelector::Osx
51+
} else if platform.is_windows() {
52+
EntrypointSelector::Win
53+
} else {
54+
return None;
55+
};
56+
lookup(family)
57+
})
58+
.or_else(|| {
59+
platform
60+
.is_unix()
61+
.then(|| lookup(EntrypointSelector::Unix))
62+
.flatten()
63+
})
64+
}
65+
}
66+
}
67+
}
68+
69+
impl<'de> toml_span::Deserialize<'de> for Entrypoint {
70+
fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
71+
let span = value.span;
72+
match value.take() {
73+
ValueInner::String(command) => Ok(Entrypoint::Uniform(command.into_owned())),
74+
ValueInner::Table(table) => {
75+
if table.is_empty() {
76+
return Err(custom_error(
77+
"the entrypoint table must contain at least one platform key",
78+
span,
79+
)
80+
.into());
81+
}
82+
let mut errors = DeserError { errors: Vec::new() };
83+
let mut commands = Vec::new();
84+
for (key, mut command) in table.into_iter().sorted_by_key(|(key, _)| key.span.start)
85+
{
86+
let selector = match key.name.as_ref() {
87+
"unix" => Some(EntrypointSelector::Unix),
88+
"linux" => Some(EntrypointSelector::Linux),
89+
"osx" => Some(EntrypointSelector::Osx),
90+
"win" => Some(EntrypointSelector::Win),
91+
name => match Platform::from_str(name) {
92+
Ok(platform) => Some(EntrypointSelector::Platform(platform)),
93+
Err(_) => {
94+
errors.errors.push(custom_error(
95+
format!(
96+
"'{name}' is neither a platform family (`unix`, `linux`, `osx`, `win`) nor a conda platform"
97+
),
98+
key.span,
99+
));
100+
None
101+
}
102+
},
103+
};
104+
let command = match command.take() {
105+
ValueInner::String(command) => Some(command.into_owned()),
106+
inner => {
107+
errors
108+
.errors
109+
.push(expected("a string", inner, command.span));
110+
None
111+
}
112+
};
113+
if let (Some(selector), Some(command)) = (selector, command) {
114+
commands.push((selector, command));
115+
}
116+
}
117+
if errors.errors.is_empty() {
118+
Ok(Entrypoint::PerPlatform(commands))
119+
} else {
120+
Err(errors)
121+
}
122+
}
123+
inner => Err(expected("a string or a table of platforms", inner, span).into()),
124+
}
125+
}
126+
}
127+
128+
#[cfg(test)]
129+
mod tests {
130+
use toml_span::de_helpers::TableHelper;
131+
132+
use super::*;
133+
134+
fn parse_entrypoint(toml: &str) -> Entrypoint {
135+
let mut value = toml_span::parse(toml).unwrap();
136+
let mut th = TableHelper::new(&mut value).unwrap();
137+
let entrypoint = th.required::<Entrypoint>("entrypoint").unwrap();
138+
th.finalize(None).unwrap();
139+
entrypoint
140+
}
141+
142+
#[test]
143+
fn a_uniform_entrypoint_matches_every_platform() {
144+
let entrypoint = parse_entrypoint(r#"entrypoint = "python ${SCRIPT}""#);
145+
assert_eq!(
146+
entrypoint.select(Platform::Linux64),
147+
Some("python ${SCRIPT}")
148+
);
149+
assert_eq!(entrypoint.select(Platform::Win64), Some("python ${SCRIPT}"));
150+
}
151+
152+
#[test]
153+
fn the_most_specific_platform_key_wins() {
154+
let entrypoint = parse_entrypoint(
155+
r#"entrypoint = { unix = "unix", linux = "linux", linux-64 = "linux-64", win = "win" }"#,
156+
);
157+
assert_eq!(entrypoint.select(Platform::Linux64), Some("linux-64"));
158+
assert_eq!(entrypoint.select(Platform::LinuxAarch64), Some("linux"));
159+
assert_eq!(entrypoint.select(Platform::Osx64), Some("unix"));
160+
assert_eq!(entrypoint.select(Platform::Win64), Some("win"));
161+
assert_eq!(entrypoint.select(Platform::WinArm64), Some("win"));
162+
}
163+
164+
#[test]
165+
fn a_platform_without_a_matching_key_selects_nothing() {
166+
let windows_only = parse_entrypoint(r#"entrypoint = { win = "win" }"#);
167+
assert_eq!(windows_only.select(Platform::Linux64), None);
168+
169+
let unix_only = parse_entrypoint(r#"entrypoint = { unix = "unix" }"#);
170+
assert_eq!(unix_only.select(Platform::Win64), None);
171+
assert_eq!(unix_only.select(Platform::LinuxRiscv64), Some("unix"));
172+
}
173+
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
use std::ops::Range;
2+
3+
use miette::SourceSpan;
4+
5+
use crate::error::EnvelopeErrorKind;
6+
7+
pub(crate) const OPENING_MARKER: &str = "/// conda-script";
8+
pub(crate) const CLOSING_MARKER: &str = "/// end-conda-script";
9+
const PEP723_OPENING: &str = "# /// script";
10+
11+
/// The extracted content of a `conda-script` block.
12+
pub(crate) struct CondaScriptBlock {
13+
/// The block content with the comment prefix stripped from every line.
14+
pub(crate) metadata: String,
15+
pub(crate) source_map: SourceMap,
16+
}
17+
18+
/// Maps offsets in the extracted metadata TOML back to the original file.
19+
#[derive(Debug, Clone)]
20+
pub(crate) struct SourceMap {
21+
opening: Range<usize>,
22+
metadata_lines: Vec<MetadataLine>,
23+
}
24+
25+
#[derive(Debug, Clone)]
26+
struct MetadataLine {
27+
metadata_start: usize,
28+
source_start: usize,
29+
len: usize,
30+
}
31+
32+
impl SourceMap {
33+
fn metadata_offset(&self, offset: usize) -> usize {
34+
let Some(line) = self
35+
.metadata_lines
36+
.iter()
37+
.rev()
38+
.find(|line| line.metadata_start <= offset)
39+
else {
40+
return self.opening.start;
41+
};
42+
line.source_start + offset.saturating_sub(line.metadata_start).min(line.len)
43+
}
44+
45+
pub(crate) fn span(&self, offset: usize, len: usize) -> SourceSpan {
46+
let start = self.metadata_offset(offset);
47+
let end = self.metadata_offset(offset.saturating_add(len)).max(start);
48+
SourceSpan::new(start.into(), end - start)
49+
}
50+
}
51+
52+
/// Extracts the single `conda-script` block from a source file.
53+
///
54+
/// Returns `Ok(None)` when the file contains no opening marker with a valid
55+
/// comment prefix.
56+
pub(crate) fn parse_block(source: &str) -> Result<Option<CondaScriptBlock>, EnvelopeErrorKind> {
57+
// A BOM must not become part of the comment prefix of a block on the
58+
// first line.
59+
let base = if source.starts_with('\u{feff}') {
60+
'\u{feff}'.len_utf8()
61+
} else {
62+
0
63+
};
64+
65+
let mut lines: Vec<(usize, &str)> = Vec::new();
66+
let mut offset = base;
67+
for raw_line in source[base..].split_inclusive('\n') {
68+
lines.push((offset, without_line_ending(raw_line)));
69+
offset += raw_line.len();
70+
}
71+
72+
let Some((opening_index, prefix)) = lines
73+
.iter()
74+
.enumerate()
75+
.find_map(|(index, (_, line))| opening_prefix(line).map(|prefix| (index, prefix)))
76+
else {
77+
return Ok(None);
78+
};
79+
let opening = line_span(lines[opening_index]);
80+
81+
let mut toml_lines: Vec<&str> = Vec::new();
82+
let mut metadata_lines = Vec::new();
83+
let mut metadata_len = 0;
84+
let mut closing_index = None;
85+
let mut broken_line = None;
86+
for (index, &(line_start, line)) in lines.iter().enumerate().skip(opening_index + 1) {
87+
if let Some(rest) = line.strip_prefix(prefix) {
88+
if rest.trim_end() == CLOSING_MARKER {
89+
closing_index = Some(index);
90+
break;
91+
}
92+
toml_lines.push(rest);
93+
metadata_lines.push(MetadataLine {
94+
metadata_start: metadata_len,
95+
source_start: line_start + prefix.len(),
96+
len: rest.len(),
97+
});
98+
metadata_len += rest.len() + 1;
99+
} else if line.trim_end() == prefix.trim_end() {
100+
toml_lines.push("");
101+
metadata_lines.push(MetadataLine {
102+
metadata_start: metadata_len,
103+
source_start: line_start + line.trim_end().len(),
104+
len: 0,
105+
});
106+
metadata_len += 1;
107+
} else {
108+
broken_line = Some(line_span((line_start, line)));
109+
break;
110+
}
111+
}
112+
113+
let Some(closing_index) = closing_index else {
114+
return Err(EnvelopeErrorKind::Unterminated {
115+
opening,
116+
broken_line,
117+
prefix: prefix.to_owned(),
118+
});
119+
};
120+
121+
if let Some(second) = lines[closing_index + 1..]
122+
.iter()
123+
.find(|(_, line)| opening_prefix(line).is_some())
124+
{
125+
return Err(EnvelopeErrorKind::MultipleBlocks {
126+
first: opening,
127+
second: line_span(*second),
128+
});
129+
}
130+
131+
if let Some(pep723) = lines[..opening_index]
132+
.iter()
133+
.chain(&lines[closing_index + 1..])
134+
.find(|(_, line)| line.trim_end() == PEP723_OPENING)
135+
{
136+
return Err(EnvelopeErrorKind::BothBlockKinds {
137+
conda_script: opening,
138+
pep723: line_span(*pep723),
139+
});
140+
}
141+
142+
Ok(Some(CondaScriptBlock {
143+
metadata: toml_lines.join("\n") + "\n",
144+
source_map: SourceMap {
145+
opening,
146+
metadata_lines,
147+
},
148+
}))
149+
}
150+
151+
fn line_span((start, line): (usize, &str)) -> Range<usize> {
152+
start..start + line.trim_end().len()
153+
}
154+
155+
/// The comment prefix when `line` opens a `conda-script` block.
156+
///
157+
/// A prefix must be non-empty and free of alphanumeric characters, so a
158+
/// mention of the marker inside code (`x = "// /// conda-script"`) does not
159+
/// open a block.
160+
fn opening_prefix(line: &str) -> Option<&str> {
161+
let prefix = line.trim_end().strip_suffix(OPENING_MARKER)?;
162+
(!prefix.is_empty() && !prefix.contains(char::is_alphanumeric)).then_some(prefix)
163+
}
164+
165+
fn without_line_ending(line: &str) -> &str {
166+
let line = line.strip_suffix('\n').unwrap_or(line);
167+
line.strip_suffix('\r').unwrap_or(line)
168+
}

0 commit comments

Comments
 (0)