Skip to main content

kcl_lib/execution/
annotations.rs

1//! Data on available annotations.
2
3use std::fmt;
4use std::str::FromStr;
5
6use kittycad_modeling_cmds::coord::KITTYCAD;
7use kittycad_modeling_cmds::coord::OPENGL;
8use kittycad_modeling_cmds::coord::System;
9use kittycad_modeling_cmds::coord::VULKAN;
10use serde::Deserialize;
11use serde::Serialize;
12
13use crate::KclError;
14use crate::KclVersion;
15use crate::SourceRange;
16use crate::errors::KclErrorDetails;
17use crate::errors::Severity;
18use crate::parsing::ast::types::Annotation;
19use crate::parsing::ast::types::Expr;
20use crate::parsing::ast::types::LiteralValue;
21use crate::parsing::ast::types::Node;
22use crate::parsing::ast::types::ObjectProperty;
23
24/// Annotations which should cause re-execution if they change.
25pub(super) const SIGNIFICANT_ATTRS: [&str; 4] = [SETTINGS, NO_PRELUDE, WARNINGS, DIAGNOSTICS];
26
27pub(crate) const SETTINGS: &str = "settings";
28pub(crate) const SETTINGS_UNIT_LENGTH: &str = "defaultLengthUnit";
29pub(crate) const SETTINGS_UNIT_ANGLE: &str = "defaultAngleUnit";
30pub(crate) const SETTINGS_VERSION: &str = "kclVersion";
31pub(crate) const SETTINGS_EXPERIMENTAL_FEATURES: &str = "experimentalFeatures";
32
33pub(super) const NO_PRELUDE: &str = "no_std";
34pub(crate) const ADDED_IN: &str = "added_in";
35pub(crate) const DEPRECATED: &str = "deprecated";
36pub(crate) const DEPRECATED_SINCE: &str = "deprecated_since";
37pub(crate) const REMOVED_IN: &str = "removed_in";
38pub(crate) const DOC_CATEGORY: &str = "doc_category";
39pub(crate) const EXPERIMENTAL: &str = "experimental";
40pub(crate) const INCLUDE_IN_FEATURE_TREE: &str = "feature_tree";
41
42pub(super) const IMPORT_FORMAT: &str = "format";
43pub(super) const IMPORT_COORDS: &str = "coords";
44pub(super) const IMPORT_COORDS_VALUES: [(&str, &System); 3] =
45    [("zoo", KITTYCAD), ("opengl", OPENGL), ("vulkan", VULKAN)];
46pub(super) const IMPORT_LENGTH_UNIT: &str = "lengthUnit";
47pub(crate) const IMPORT_TARGET_REPRESENTATION: &str = "targetRepresentation";
48
49pub(crate) const IMPL: &str = "impl";
50pub(crate) const IMPL_RUST: &str = "std_rust";
51pub(crate) const IMPL_CONSTRAINT: &str = "std_rust_constraint";
52pub(crate) const IMPL_CONSTRAINABLE: &str = "std_constrainable";
53pub(crate) const IMPL_RUST_CONSTRAINABLE: &str = "std_rust_constrainable";
54pub(crate) const IMPL_KCL: &str = "kcl";
55pub(crate) const IMPL_PRIMITIVE: &str = "primitive";
56pub(super) const IMPL_VALUES: [&str; 6] = [
57    IMPL_RUST,
58    IMPL_KCL,
59    IMPL_PRIMITIVE,
60    IMPL_CONSTRAINT,
61    IMPL_CONSTRAINABLE,
62    IMPL_RUST_CONSTRAINABLE,
63];
64
65/// Customizes how diagnostics are reported, in KCL 2 or earlier.
66pub(crate) const WARNINGS: &str = "warnings";
67/// Customizes how diagnostics are reported, in KCL 3.0 and later.
68/// KCL 3.0 renamed `@warnings` to `@diagnostics`.
69pub(crate) const DIAGNOSTICS: &str = "diagnostics";
70pub(crate) const WARN_ALLOW: &str = "allow";
71pub(crate) const WARN_DENY: &str = "deny";
72pub(crate) const WARN_WARN: &str = "warn";
73pub(super) const WARN_LEVELS: [&str; 3] = [WARN_ALLOW, WARN_DENY, WARN_WARN];
74pub(crate) const WARN_UNKNOWN_UNITS: &str = "unknownUnits";
75pub(crate) const WARN_ANGLE_UNITS: &str = "angleUnits";
76pub(crate) const WARN_UNKNOWN_ATTR: &str = "unknownAttribute";
77pub(crate) const WARN_MOD_RETURN_VALUE: &str = "moduleReturnValue";
78pub(crate) const WARN_DEPRECATED: &str = "deprecated";
79pub(crate) const WARN_IGNORED_Z_AXIS: &str = "ignoredZAxis";
80pub(crate) const WARN_SOLVER: &str = "solver";
81pub(crate) const WARN_SHOULD_BE_PERCENTAGE: &str = "shouldBePercentage";
82pub(crate) const WARN_INVALID_MATH: &str = "invalidMath";
83pub(crate) const WARN_CSG_NO_INTERSECTION: &str = "csgNoIntersection";
84pub(crate) const WARN_UNNECESSARY_CLOSE: &str = "unnecessaryClose";
85pub(crate) const WARN_UNUSED_TAGS: &str = "unusedTags";
86pub(crate) const WARN_NOT_YET_SUPPORTED: &str = "notYetSupported";
87pub(crate) const WARN_OVER_CONSTRAINED_SKETCH: &str = "overConstrainedSketch";
88pub(crate) const WARN_REGION_LIVENESS: &str = "regionLiveness";
89pub(crate) const WARN_PARENTLESS_MERGE: &str = "parentlessMerge";
90pub(super) const WARN_VALUES: [&str; 15] = [
91    WARN_UNKNOWN_UNITS,
92    WARN_ANGLE_UNITS,
93    WARN_UNKNOWN_ATTR,
94    WARN_MOD_RETURN_VALUE,
95    WARN_DEPRECATED,
96    WARN_IGNORED_Z_AXIS,
97    WARN_SOLVER,
98    WARN_SHOULD_BE_PERCENTAGE,
99    WARN_INVALID_MATH,
100    WARN_UNNECESSARY_CLOSE,
101    WARN_NOT_YET_SUPPORTED,
102    WARN_CSG_NO_INTERSECTION,
103    WARN_OVER_CONSTRAINED_SKETCH,
104    WARN_REGION_LIVENESS,
105    WARN_PARENTLESS_MERGE,
106];
107
108#[derive(Clone, Copy, Eq, PartialEq, Debug, Deserialize, Serialize, ts_rs::TS)]
109#[ts(export)]
110#[serde(tag = "type")]
111pub enum WarningLevel {
112    Allow,
113    Warn,
114    Deny,
115}
116
117impl WarningLevel {
118    pub(crate) fn severity(self) -> Option<Severity> {
119        match self {
120            WarningLevel::Allow => None,
121            WarningLevel::Warn => Some(Severity::Warning),
122            WarningLevel::Deny => Some(Severity::Error),
123        }
124    }
125
126    pub(crate) fn as_str(self) -> &'static str {
127        match self {
128            WarningLevel::Allow => WARN_ALLOW,
129            WarningLevel::Warn => WARN_WARN,
130            WarningLevel::Deny => WARN_DENY,
131        }
132    }
133}
134
135impl FromStr for WarningLevel {
136    type Err = ();
137
138    fn from_str(s: &str) -> Result<Self, Self::Err> {
139        match s {
140            WARN_ALLOW => Ok(Self::Allow),
141            WARN_WARN => Ok(Self::Warn),
142            WARN_DENY => Ok(Self::Deny),
143            _ => Err(()),
144        }
145    }
146}
147
148#[derive(Clone, Copy, Eq, PartialEq, Debug, Default)]
149pub enum Impl {
150    #[default]
151    Kcl,
152    KclConstrainable,
153    Rust,
154    RustConstrainable,
155    RustConstraint,
156    Primitive,
157}
158
159impl FromStr for Impl {
160    type Err = ();
161
162    fn from_str(s: &str) -> Result<Self, Self::Err> {
163        match s {
164            IMPL_RUST => Ok(Self::Rust),
165            IMPL_CONSTRAINT => Ok(Self::RustConstraint),
166            IMPL_CONSTRAINABLE => Ok(Self::KclConstrainable),
167            IMPL_RUST_CONSTRAINABLE => Ok(Self::RustConstrainable),
168            IMPL_KCL => Ok(Self::Kcl),
169            IMPL_PRIMITIVE => Ok(Self::Primitive),
170            _ => Err(()),
171        }
172    }
173}
174
175pub(crate) fn settings_completion_text() -> String {
176    format!("@{SETTINGS}({SETTINGS_UNIT_LENGTH} = mm, {SETTINGS_VERSION} = 2.0)")
177}
178
179pub(super) fn is_significant(attr: &&Node<Annotation>) -> bool {
180    match attr.name() {
181        Some(name) => SIGNIFICANT_ATTRS.contains(&name),
182        None => true,
183    }
184}
185
186/// The name of the attribute that customizes how diagnostics are reported
187/// under the given KCL version: `warnings` before KCL 3.0 and `diagnostics`
188/// in KCL 3.0 and later.
189pub(super) fn diagnostics_attr_name(version: KclVersion) -> &'static str {
190    if version >= KclVersion::V3Preview {
191        DIAGNOSTICS
192    } else {
193        WARNINGS
194    }
195}
196
197pub(super) fn expect_properties<'a>(
198    for_key: &'static str,
199    annotation: &'a Node<Annotation>,
200) -> Result<&'a [Node<ObjectProperty>], KclError> {
201    assert_eq!(annotation.name().unwrap(), for_key);
202    Ok(&**annotation.properties.as_ref().ok_or_else(|| {
203        KclError::new_semantic(KclErrorDetails::new(
204            format!("Empty `{for_key}` annotation"),
205            vec![annotation.as_source_range()],
206        ))
207    })?)
208}
209
210pub(super) fn expect_ident(expr: &Expr) -> Result<&str, KclError> {
211    if let Expr::Name(name) = expr
212        && let Some(name) = name.local_ident()
213    {
214        return Ok(*name);
215    }
216
217    Err(KclError::new_semantic(KclErrorDetails::new(
218        "Unexpected settings value, expected a simple name, e.g., `mm`".to_owned(),
219        vec![expr.into()],
220    )))
221}
222
223/// Parses the value of an `allow` or `deny` property of the attribute named
224/// `attr_name`, which is used in error messages.
225pub(super) fn many_of(
226    expr: &Expr,
227    of: &[&'static str],
228    attr_name: &str,
229    source_range: SourceRange,
230) -> Result<Vec<&'static str>, KclError> {
231    let unexpected_msg = format!(
232        "Unexpected {attr_name} value, expected a name or array of names, e.g., `unknownUnits` or `[unknownUnits, deprecated]`"
233    );
234
235    let values = match expr {
236        Expr::Name(name) => {
237            if let Some(name) = name.local_ident() {
238                vec![*name]
239            } else {
240                return Err(KclError::new_semantic(KclErrorDetails::new(
241                    unexpected_msg,
242                    vec![expr.into()],
243                )));
244            }
245        }
246        Expr::ArrayExpression(e) => {
247            let mut result = Vec::new();
248            for e in &e.elements {
249                if let Expr::Name(name) = e
250                    && let Some(name) = name.local_ident()
251                {
252                    result.push(*name);
253                    continue;
254                }
255                return Err(KclError::new_semantic(KclErrorDetails::new(
256                    unexpected_msg,
257                    vec![e.into()],
258                )));
259            }
260            result
261        }
262        _ => {
263            return Err(KclError::new_semantic(KclErrorDetails::new(
264                unexpected_msg,
265                vec![expr.into()],
266            )));
267        }
268    };
269
270    // Each value names one diagnostic, so use the singular form of the
271    // attribute name: `warning` or `diagnostic`.
272    let noun = attr_name.strip_suffix('s').unwrap_or(attr_name);
273    values
274        .into_iter()
275        .map(|v| {
276            of.iter()
277                .find(|vv| **vv == v)
278                .ok_or_else(|| {
279                    KclError::new_semantic(KclErrorDetails::new(
280                        format!("Unexpected {noun} value: `{v}`; accepted values: {}", of.join(", "),),
281                        vec![source_range],
282                    ))
283                })
284                .copied()
285        })
286        .collect::<Result<Vec<&str>, KclError>>()
287}
288
289/// Returns a KCL version.
290/// Usually a number, but may have a trailing string suffix like 'preview' with a '-' divider,
291/// e.g. 3.0-preview.
292pub(super) fn expect_kcl_version(expr: &Expr) -> Result<String, KclError> {
293    if let Expr::Literal(lit) = expr {
294        return match &lit.value {
295            LiteralValue::Number { .. } => Ok(lit.raw.clone()),
296            LiteralValue::String(value) => Ok(value.clone()),
297            LiteralValue::Bool(_) => Err(KclError::new_semantic(KclErrorDetails::new(
298                "Unexpected KCL version value, expected a number or string, e.g., `2.0` or `\"3.0-preview\"`"
299                    .to_owned(),
300                vec![expr.into()],
301            ))),
302        };
303    }
304
305    Err(KclError::new_semantic(KclErrorDetails::new(
306        "Unexpected KCL version value, expected a number or string, e.g., `2.0` or `\"3.0-preview\"`".to_owned(),
307        vec![expr.into()],
308    )))
309}
310
311#[derive(Debug, Clone, Eq, PartialEq)]
312pub struct FnAttrs {
313    pub impl_: Impl,
314    /// KCL version in which this item was added, e.g. "3.0". Programs on an
315    /// earlier version skip the declaration entirely.
316    pub added_in: Option<VersionConstraint>,
317    pub deprecated: bool,
318    /// Constraint marking a KCL version at or after which this item is
319    /// deprecated, e.g. "2.0".
320    pub deprecated_since: Option<VersionConstraint>,
321    pub experimental: bool,
322    pub include_in_feature_tree: bool,
323}
324
325impl Default for FnAttrs {
326    fn default() -> Self {
327        Self {
328            impl_: Impl::default(),
329            added_in: None,
330            deprecated: false,
331            deprecated_since: None,
332            experimental: false,
333            include_in_feature_tree: true,
334        }
335    }
336}
337
338/// A constraint on a KCL version, e.g. the threshold that `@(added_in = "3.0")`,
339/// `@(deprecated_since = "2.0")`, or `@(removed_in = "3.0")` describes.
340/// Stored as the parsed component list so comparisons are numeric, not lexical.
341///
342/// Distinct from the concrete `kclVersion` set in `@settings(...)`: this type
343/// represents a version *boundary*, and we expect to grow more constraint kinds
344/// (e.g., "deprecated up until version X") in the future. Comparisons against a
345/// concrete version are expressed via the free `version_*` functions below
346/// rather than `Ord` so the direction of comparison stays explicit at every
347/// call site.
348#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
349pub struct VersionConstraint(Vec<u32>);
350
351impl VersionConstraint {
352    /// Construct a major.minor version boundary from numeric components.
353    pub(crate) fn new(major: u32, minor: u32) -> Self {
354        Self(vec![major, minor])
355    }
356
357    /// Parse a dotted version string like "1.0" or "2.1.3". Returns `None` for empty
358    /// input or any component that doesn't parse as a non-negative integer.
359    pub fn parse(s: &str) -> Option<Self> {
360        let parts: Vec<u32> = s
361            .split('.')
362            .map(|p| p.parse::<u32>().ok())
363            .collect::<Option<Vec<_>>>()?;
364        if parts.is_empty() { None } else { Some(Self(parts)) }
365    }
366
367    /// Whether this version boundary comes strictly before `other`, comparing
368    /// components numerically, like [`version_ge`] does for concrete versions.
369    pub(crate) fn is_before(&self, other: &Self) -> bool {
370        self.0 < other.0
371    }
372}
373
374impl fmt::Display for VersionConstraint {
375    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
376        let mut first = true;
377        for n in &self.0 {
378            if !first {
379                f.write_str(".")?;
380            }
381            write!(f, "{n}")?;
382            first = false;
383        }
384        Ok(())
385    }
386}
387
388/// Returns true when the concrete `version` (e.g., from `@settings(kclVersion = ...)`)
389/// is greater than or equal to the `constraint`. Returns false if `version` cannot be
390/// parsed as a dotted integer version with an optional pre-release suffix.
391pub(crate) fn version_ge(version: &str, constraint: &VersionConstraint) -> bool {
392    let release = version.split_once('-').map_or(version, |(release, _)| release);
393    let Some(parsed) = VersionConstraint::parse(release) else {
394        return false;
395    };
396    parsed.0 >= constraint.0
397}
398
399/// Parse a version attribute value such as `"3.0"`, naming `key` in errors.
400fn version_property(p: &ObjectProperty, key: &str, source_range: SourceRange) -> Result<VersionConstraint, KclError> {
401    let Some(s) = p.value.literal_str() else {
402        return Err(KclError::new_semantic(KclErrorDetails::new(
403            format!("Expected a version string for {key}, e.g., \"2.0\""),
404            vec![source_range],
405        )));
406    };
407    VersionConstraint::parse(s).ok_or_else(|| {
408        KclError::new_semantic(KclErrorDetails::new(
409            format!("Invalid version string for {key}: `{s}`; expected a dotted integer version, e.g., \"2.0\""),
410            vec![source_range],
411        ))
412    })
413}
414
415/// The `added_in` version in `annotations`, if any. Reads only that attribute,
416/// so a declaration can be skipped before `get_fn_attrs` validates the rest.
417pub(super) fn added_in_version(
418    annotations: &[Node<Annotation>],
419    source_range: SourceRange,
420) -> Result<Option<VersionConstraint>, KclError> {
421    for attr in annotations {
422        if attr.name.is_some() {
423            continue;
424        }
425        let Some(properties) = &attr.properties else {
426            continue;
427        };
428        for p in properties {
429            if &*p.key.name == ADDED_IN {
430                return Ok(Some(version_property(p, ADDED_IN, source_range)?));
431            }
432        }
433    }
434    Ok(None)
435}
436
437pub(super) fn get_fn_attrs(
438    annotations: &[Node<Annotation>],
439    source_range: SourceRange,
440) -> Result<Option<FnAttrs>, KclError> {
441    let mut found_attrs = false;
442    let mut fn_attrs = FnAttrs::default();
443    for attr in annotations {
444        if attr.name.is_some() || attr.properties.is_none() {
445            continue;
446        }
447        for p in attr.properties.as_ref().unwrap() {
448            if &*p.key.name == IMPL
449                && let Some(s) = p.value.ident_name()
450            {
451                found_attrs = true;
452                fn_attrs.impl_ = Impl::from_str(s).map_err(|_| {
453                    KclError::new_semantic(KclErrorDetails::new(
454                        format!(
455                            "Invalid value for {} attribute, expected one of: {}",
456                            IMPL,
457                            IMPL_VALUES.join(", ")
458                        ),
459                        vec![source_range],
460                    ))
461                })?;
462                continue;
463            }
464
465            if &*p.key.name == DEPRECATED
466                && let Some(b) = p.value.literal_bool()
467            {
468                found_attrs = true;
469                fn_attrs.deprecated = b;
470                continue;
471            }
472
473            if &*p.key.name == ADDED_IN {
474                found_attrs = true;
475                fn_attrs.added_in = Some(version_property(p, ADDED_IN, source_range)?);
476                continue;
477            }
478
479            if &*p.key.name == DEPRECATED_SINCE {
480                found_attrs = true;
481                fn_attrs.deprecated_since = Some(version_property(p, DEPRECATED_SINCE, source_range)?);
482                continue;
483            }
484
485            // doc_category is handled by the docs generator, not execution.
486            if &*p.key.name == DOC_CATEGORY {
487                continue;
488            }
489
490            if &*p.key.name == EXPERIMENTAL
491                && let Some(b) = p.value.literal_bool()
492            {
493                found_attrs = true;
494                fn_attrs.experimental = b;
495                continue;
496            }
497
498            if &*p.key.name == INCLUDE_IN_FEATURE_TREE
499                && let Some(b) = p.value.literal_bool()
500            {
501                found_attrs = true;
502                fn_attrs.include_in_feature_tree = b;
503                continue;
504            }
505
506            return Err(KclError::new_semantic(KclErrorDetails::new(
507                format!(
508                    "Invalid attribute, expected one of: {IMPL}, {ADDED_IN}, {DEPRECATED}, {DEPRECATED_SINCE}, {DOC_CATEGORY}, {EXPERIMENTAL}, {INCLUDE_IN_FEATURE_TREE}, found `{}`",
509                    &*p.key.name,
510                ),
511                vec![source_range],
512            )));
513        }
514    }
515
516    // May arrive already deprecated, but not deprecated before it existed.
517    if let (Some(added), Some(since)) = (&fn_attrs.added_in, &fn_attrs.deprecated_since)
518        && since.is_before(added)
519    {
520        return Err(KclError::new_semantic(KclErrorDetails::new(
521            format!("`{DEPRECATED_SINCE}` (KCL {since}) must not be earlier than `{ADDED_IN}` (KCL {added})"),
522            vec![source_range],
523        )));
524    }
525
526    Ok(if found_attrs { Some(fn_attrs) } else { None })
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    fn vc(s: &str) -> VersionConstraint {
534        VersionConstraint::parse(s).unwrap()
535    }
536
537    #[test]
538    fn version_constraint_parse_handles_typical_inputs() {
539        assert_eq!(VersionConstraint::parse("1.0"), Some(VersionConstraint(vec![1, 0])));
540        assert_eq!(VersionConstraint::parse("2"), Some(VersionConstraint(vec![2])));
541        assert_eq!(
542            VersionConstraint::parse("2.1.3"),
543            Some(VersionConstraint(vec![2, 1, 3]))
544        );
545        assert_eq!(VersionConstraint::parse(""), None);
546        assert_eq!(VersionConstraint::parse("1.x"), None);
547        assert_eq!(VersionConstraint::parse("1.-1"), None);
548    }
549
550    #[test]
551    fn version_constraint_is_before_compares_numerically() {
552        assert!(vc("2.0").is_before(&vc("3.0")));
553        assert!(vc("2.9").is_before(&vc("2.10")));
554        assert!(vc("9.0").is_before(&vc("10.0")));
555        assert!(vc("3.0").is_before(&vc("3.0.1")));
556        assert!(!vc("3.0").is_before(&vc("3.0")));
557        assert!(!vc("3.0").is_before(&vc("2.0")));
558        assert!(!vc("2.10").is_before(&vc("2.9")));
559    }
560
561    #[test]
562    fn version_constraint_display_round_trips() {
563        assert_eq!(vc("1.0").to_string(), "1.0");
564        assert_eq!(vc("2.1.3").to_string(), "2.1.3");
565        assert_eq!(vc("2").to_string(), "2");
566    }
567
568    #[test]
569    fn version_ge_compares_components_numerically() {
570        assert!(version_ge("1.0", &vc("1.0")));
571        assert!(version_ge("2.0", &vc("1.0")));
572        assert!(version_ge("2.0", &vc("2.0")));
573        assert!(version_ge("10.0", &vc("2.0")));
574        assert!(version_ge("2.1", &vc("2.0")));
575        assert!(!version_ge("1.0", &vc("2.0")));
576        assert!(!version_ge("2.0", &vc("2.1")));
577        assert!(!version_ge("1.99", &vc("2.0")));
578        // An unparsable concrete version never satisfies the constraint.
579        assert!(!version_ge("bogus", &vc("1.0")));
580    }
581
582    #[test]
583    fn version_ge_supports_prerelease_versions() {
584        assert!(version_ge("3.0-preview", &vc("2.0")));
585        assert!(!version_ge("3.0-preview", &vc("4.0")));
586    }
587
588    /// The outer attributes of the first declaration in `source`.
589    fn outer_attrs(source: &str) -> Vec<Node<Annotation>> {
590        let program = crate::parsing::top_level_parse(source).unwrap();
591        match &program.body[0] {
592            crate::parsing::ast::types::BodyItem::VariableDeclaration(decl) => decl.outer_attrs.clone(),
593            crate::parsing::ast::types::BodyItem::TypeDeclaration(decl) => decl.outer_attrs.clone(),
594            other => panic!("expected a declaration, got {other:?}"),
595        }
596    }
597
598    fn fn_attrs_of(source: &str) -> Result<Option<FnAttrs>, KclError> {
599        get_fn_attrs(&outer_attrs(source), SourceRange::default())
600    }
601
602    #[test]
603    fn get_fn_attrs_parses_added_in_on_functions_and_types() {
604        let attrs = fn_attrs_of("@(added_in = \"3.0\")\nfn f() { return 1 }")
605            .unwrap()
606            .unwrap();
607        assert_eq!(attrs.added_in, Some(vc("3.0")));
608
609        let attrs = fn_attrs_of("@(added_in = \"2.1\", experimental = true)\ntype T = [number; 2]")
610            .unwrap()
611            .unwrap();
612        assert_eq!(attrs.added_in, Some(vc("2.1")));
613        assert!(attrs.experimental);
614
615        // Absent by default.
616        assert_eq!(
617            fn_attrs_of("@(experimental = true)\nfn f() { return 1 }")
618                .unwrap()
619                .unwrap()
620                .added_in,
621            None
622        );
623    }
624
625    #[test]
626    fn get_fn_attrs_rejects_malformed_added_in() {
627        assert_eq!(
628            fn_attrs_of("@(added_in = 3)\nfn f() { return 1 }")
629                .unwrap_err()
630                .message(),
631            "Expected a version string for added_in, e.g., \"2.0\""
632        );
633        assert_eq!(
634            fn_attrs_of("@(added_in = \"3.0-preview\")\nfn f() { return 1 }")
635                .unwrap_err()
636                .message(),
637            "Invalid version string for added_in: `3.0-preview`; expected a dotted integer version, e.g., \"2.0\""
638        );
639    }
640
641    #[test]
642    fn deprecated_since_may_equal_but_not_precede_added_in() {
643        let attrs = fn_attrs_of("@(added_in = \"3.0\", deprecated_since = \"3.0\")\nfn f() { return 1 }")
644            .unwrap()
645            .unwrap();
646        assert_eq!(attrs.added_in, Some(vc("3.0")));
647        assert_eq!(attrs.deprecated_since, Some(vc("3.0")));
648
649        assert_eq!(
650            fn_attrs_of("@(deprecated_since = \"2.0\", added_in = \"3.0\")\nfn f() { return 1 }")
651                .unwrap_err()
652                .message(),
653            "`deprecated_since` (KCL 2.0) must not be earlier than `added_in` (KCL 3.0)"
654        );
655    }
656
657    #[test]
658    fn added_in_version_reads_only_that_attribute() {
659        // Other attributes, even invalid ones, are left for get_fn_attrs.
660        let attrs = outer_attrs("@(added_in = \"2.0\", bogus = true)\nx = 1");
661        assert_eq!(
662            added_in_version(&attrs, SourceRange::default()).unwrap(),
663            Some(vc("2.0"))
664        );
665        assert_eq!(
666            get_fn_attrs(&attrs, SourceRange::default()).unwrap_err().message(),
667            "Invalid attribute, expected one of: impl, added_in, deprecated, deprecated_since, doc_category, experimental, feature_tree, found `bogus`"
668        );
669
670        assert_eq!(
671            added_in_version(&outer_attrs("@(experimental = true)\nx = 1"), SourceRange::default()).unwrap(),
672            None
673        );
674        assert_eq!(
675            added_in_version(&outer_attrs("x = 1"), SourceRange::default()).unwrap(),
676            None
677        );
678
679        // Named annotations such as @settings are not item attributes.
680        assert_eq!(
681            added_in_version(
682                &outer_attrs("@settings(kclVersion = 2.0)\nx = 1"),
683                SourceRange::default()
684            )
685            .unwrap(),
686            None
687        );
688
689        assert_eq!(
690            added_in_version(&outer_attrs("@(added_in = \"x\")\nx = 1"), SourceRange::default())
691                .unwrap_err()
692                .message(),
693            "Invalid version string for added_in: `x`; expected a dotted integer version, e.g., \"2.0\""
694        );
695    }
696}