1use only_diagnostic::{Diagnostic, DiagnosticCode, DiagnosticPhase, DiagnosticSeverity};
2use text_size::{TextRange, TextSize};
3
4const UTF8_BOM: &str = "\u{feff}";
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub struct VersionRequirement {
8 pub major: u64,
9 pub minor: u64,
10 pub span: TextRange,
11}
12
13impl VersionRequirement {
14 pub fn required_range(&self) -> String {
15 let upper_major = self
16 .major
17 .checked_add(1)
18 .expect("validated version requirement must have an upper bound");
19 format!(">={}.{}.0, <{upper_major}.0.0", self.major, self.minor)
20 }
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct BootstrapHeader {
25 pub required_version: Option<VersionRequirement>,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29struct RunnerVersion {
30 major: u64,
31 minor: u64,
32 patch: u64,
33 prerelease: bool,
34}
35
36pub fn scan_bootstrap_header(source: &str) -> Result<BootstrapHeader, Diagnostic> {
38 let mut offset = source.strip_prefix(UTF8_BOM).map_or(0, |_| UTF8_BOM.len());
39
40 while offset < source.len() {
41 let line_end = source[offset..]
42 .find(['\r', '\n'])
43 .map_or(source.len(), |end| offset + end);
44 let line = &source[offset..line_end];
45 let leading = line.len() - line.trim_start_matches([' ', '\t']).len();
46 let declaration = &line[leading..];
47
48 if declaration.is_empty() || declaration.starts_with("//") || declaration.starts_with('#') {
49 offset = next_line_offset(source, line_end);
50 continue;
51 }
52
53 if !is_version_directive(declaration) {
54 return Ok(BootstrapHeader {
55 required_version: None,
56 });
57 }
58
59 let span = text_range(offset + leading, line_end);
60 let value = declaration
61 .strip_prefix("!version")
62 .expect("version directive prefix was checked")
63 .strip_prefix([' ', '\t'])
64 .map(str::trim)
65 .unwrap_or_default();
66 let required_version = parse_version_requirement(value, span)?;
67 return Ok(BootstrapHeader {
68 required_version: Some(required_version),
69 });
70 }
71
72 Ok(BootstrapHeader {
73 required_version: None,
74 })
75}
76
77pub fn check_version_compatibility(
79 header: &BootstrapHeader,
80 runner_version: &str,
81) -> Result<(), Diagnostic> {
82 let Some(requirement) = header.required_version else {
83 return Ok(());
84 };
85 let runner = parse_runner_version(runner_version).ok_or_else(|| {
86 version_error(
87 "version.invalid-runner-version",
88 format!("only has an invalid version: '{runner_version}'"),
89 DiagnosticPhase::Host,
90 requirement.span,
91 )
92 })?;
93
94 let compatible = !runner.prerelease
95 && runner.major == requirement.major
96 && (runner.minor, runner.patch) >= (requirement.minor, 0);
97 if compatible {
98 return Ok(());
99 }
100
101 let help = if runner.prerelease
102 || runner.major < requirement.major
103 || (runner.major == requirement.major && runner.minor < requirement.minor)
104 {
105 "run `only --upgrade`".to_string()
106 } else {
107 format!(
108 "install `only` {}.x or change `!version`",
109 requirement.major
110 )
111 };
112 Err(version_error(
113 "version.incompatible",
114 format!(
115 "this Onlyfile needs `only` {}.{} or newer (not {}.x)\ninstalled: {runner_version}\nneeded: {}\nhelp: {help}",
116 requirement.major,
117 requirement.minor,
118 requirement.major + 1,
119 requirement.required_range(),
120 ),
121 DiagnosticPhase::Host,
122 requirement.span,
123 ))
124}
125
126pub fn bootstrap(source: &str, runner_version: &str) -> Result<BootstrapHeader, Diagnostic> {
128 let header = scan_bootstrap_header(source)?;
129 check_version_compatibility(&header, runner_version)?;
130 Ok(header)
131}
132
133pub fn parse_version_requirement(
135 value: &str,
136 span: TextRange,
137) -> Result<VersionRequirement, Diagnostic> {
138 let Some((major, minor)) = value.split_once('.') else {
139 return Err(invalid_format(span));
140 };
141 if major.is_empty() || minor.is_empty() || minor.contains('.') {
142 return Err(invalid_format(span));
143 }
144 if !valid_component(major) || !valid_component(minor) {
145 return Err(invalid_format(span));
146 }
147
148 let major = major.parse::<u64>().map_err(|_| range_overflow(span))?;
149 let minor = minor.parse::<u64>().map_err(|_| range_overflow(span))?;
150 if major == 0 && minor == 0 {
151 return Err(version_error(
152 "version.pre-0.1-unsupported",
153 "`!version 0.0` is not allowed\nhelp: use `!version 0.1` or remove this line",
154 DiagnosticPhase::Parse,
155 span,
156 ));
157 }
158 major.checked_add(1).ok_or_else(|| range_overflow(span))?;
159
160 Ok(VersionRequirement { major, minor, span })
161}
162
163fn is_version_directive(declaration: &str) -> bool {
164 declaration == "!version"
165 || declaration
166 .strip_prefix("!version")
167 .is_some_and(|rest| rest.starts_with([' ', '\t']))
168}
169
170fn valid_component(component: &str) -> bool {
171 component.bytes().all(|byte| byte.is_ascii_digit())
172 && (component == "0" || !component.starts_with('0'))
173}
174
175fn parse_runner_version(version: &str) -> Option<RunnerVersion> {
176 let (without_build, build) = split_suffix(version, '+', false)?;
177 if let Some(build) = build
178 && !valid_identifiers(build, false)
179 {
180 return None;
181 }
182 let (core, prerelease) = split_suffix(without_build, '-', true)?;
183 if let Some(prerelease) = prerelease
184 && !valid_identifiers(prerelease, true)
185 {
186 return None;
187 }
188
189 let mut components = core.split('.');
190 let major = parse_runner_component(components.next()?)?;
191 let minor = parse_runner_component(components.next()?)?;
192 let patch = parse_runner_component(components.next()?)?;
193 if components.next().is_some() {
194 return None;
195 }
196
197 Some(RunnerVersion {
198 major,
199 minor,
200 patch,
201 prerelease: prerelease.is_some(),
202 })
203}
204
205fn split_suffix(
206 input: &str,
207 separator: char,
208 allow_separator_in_suffix: bool,
209) -> Option<(&str, Option<&str>)> {
210 match input.split_once(separator) {
211 Some((left, right)) if !left.is_empty() && !right.is_empty() => {
212 if !allow_separator_in_suffix && right.contains(separator) {
213 None
214 } else {
215 Some((left, Some(right)))
216 }
217 }
218 Some(_) => None,
219 None => Some((input, None)),
220 }
221}
222
223fn parse_runner_component(component: &str) -> Option<u64> {
224 valid_component(component)
225 .then(|| component.parse::<u64>().ok())
226 .flatten()
227}
228
229fn valid_identifiers(input: &str, reject_numeric_leading_zero: bool) -> bool {
230 input.split('.').all(|identifier| {
231 !identifier.is_empty()
232 && identifier
233 .bytes()
234 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
235 && (!reject_numeric_leading_zero
236 || !identifier.bytes().all(|byte| byte.is_ascii_digit())
237 || identifier == "0"
238 || !identifier.starts_with('0'))
239 })
240}
241
242fn next_line_offset(source: &str, line_end: usize) -> usize {
243 match source.as_bytes().get(line_end..) {
244 Some([b'\r', b'\n', ..]) => line_end + 2,
245 Some([b'\r' | b'\n', ..]) => line_end + 1,
246 _ => line_end,
247 }
248}
249
250fn text_range(start: usize, end: usize) -> TextRange {
251 TextRange::new(TextSize::from(start as u32), TextSize::from(end as u32))
252}
253
254fn invalid_format(span: TextRange) -> Diagnostic {
255 version_error(
256 "version.invalid-format",
257 "use `!version A.B`, for example `!version 0.1`",
258 DiagnosticPhase::Parse,
259 span,
260 )
261}
262
263fn range_overflow(span: TextRange) -> Diagnostic {
264 version_error(
265 "version.range-overflow",
266 "the version number is too large",
267 DiagnosticPhase::Parse,
268 span,
269 )
270}
271
272fn version_error(
273 code: &str,
274 message: impl Into<String>,
275 phase: DiagnosticPhase,
276 span: TextRange,
277) -> Diagnostic {
278 Diagnostic::new(
279 DiagnosticSeverity::Error,
280 DiagnosticCode::new(code),
281 message,
282 phase,
283 span,
284 )
285}