Skip to main content

only_syntax/
version.rs

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
36/// Scans the optional version declaration at the start of an Onlyfile.
37pub 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
77/// Checks the optional Onlyfile version declaration against a runner SemVer.
78pub 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
126/// Runs the header scan and compatibility check without parsing the full file.
127pub 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
133/// Parses the two-segment version value used by a `!version` directive.
134pub 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}