Skip to main content

openjd_expr/
profile.rs

1// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2// Copyright by contributors to this project.
3// SPDX-License-Identifier: (Apache-2.0 OR MIT)
4
5//! Expression profile: the tuple of (revision, extensions, host context)
6//! that governs which functions, operators, and types are available for a
7//! given evaluation.
8//!
9//! A profile is passed to
10//! [`FunctionLibrary::for_profile`](crate::FunctionLibrary::for_profile) to
11//! obtain a library that matches the requested revision, extensions, and
12//! host context. Libraries are cached per *rules-independent* profile key,
13//! so callers that construct many libraries with the same spec shape and
14//! different path-mapping rules pay only the host-context registration
15//! cost per call.
16//!
17//! The three axes modelled here correspond to the axes identified in the
18//! forward-compatibility evaluation report:
19//!
20//! - **Axis A — revision**: which base functions and operators exist
21//!   (see [`ExprRevision`]).
22//! - **Axis B — extensions**: which add-on functions exist
23//!   (see [`ExprExtension`]).
24//! - **Axis C — host state**: whether host-context implementations are
25//!   real, stubbed, or absent (see [`HostContext`]).
26//!
27//! Axis D (scope-specific symbol availability) is handled by the caller
28//! building an appropriate [`SymbolTable`](crate::SymbolTable) — it is
29//! orthogonal to the profile.
30
31use std::collections::HashSet;
32use std::sync::Arc;
33
34use crate::path_mapping::PathMappingRule;
35
36/// Expression-language specification revision.
37///
38/// Mirrors the `SpecificationRevision` enum in `openjd-model` but lives in
39/// `openjd-expr` so the expression crate can model which revision it is
40/// operating under without depending on the model crate.
41///
42/// Marked `#[non_exhaustive]` so future revisions can be added without a
43/// SemVer break.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
45#[non_exhaustive]
46pub enum ExprRevision {
47    /// The `2026-02` revision — the first revision to define the
48    /// expression language (RFC 0005).
49    V2026_02,
50}
51
52impl ExprRevision {
53    /// The current revision. Equivalent to the most recent variant.
54    pub const CURRENT: ExprRevision = ExprRevision::V2026_02;
55}
56
57impl Default for ExprRevision {
58    fn default() -> Self {
59        ExprRevision::CURRENT
60    }
61}
62
63/// Expression-language extensions.
64///
65/// Expression-level extensions add or modify functions, operators, or
66/// types beyond what the base revision provides. Today no such
67/// extensions exist — the "EXPR" extension in `openjd-model` gates
68/// whether the expression language is *available at all*, not which
69/// functions are registered once it is available. This enum is therefore
70/// defined as empty-but-`#[non_exhaustive]`, reserving the API shape for
71/// the first expr-level extension.
72///
73/// Empty non-exhaustive enums are legal Rust and correctly express
74/// "values may exist in the future, none exist today."
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
76#[non_exhaustive]
77pub enum ExprExtension {}
78
79impl ExprExtension {
80    /// All extension variants, in a stable order. Used by
81    /// [`ExprProfile::latest`] to construct a profile with every
82    /// expression-level extension enabled.
83    ///
84    /// When a new variant is added, include it here. With no variants
85    /// today the slice is empty; the constant still provides the
86    /// contract that downstream code can rely on.
87    pub const ALL: &'static [ExprExtension] = &[];
88}
89
90/// Host-context state available to expression evaluation.
91///
92/// Host-context functions (today: `apply_path_mapping`) need host-supplied
93/// state that the evaluator has no knowledge of. This enum expresses the
94/// three possible states of host availability in a single type, replacing
95/// the previous split between `FunctionLibrary::with_host_context` and
96/// `FunctionLibrary::with_unresolved_host_context`.
97#[derive(Debug, Clone, Default)]
98pub enum HostContext {
99    /// No host-context functions are registered. Default.
100    #[default]
101    None,
102    /// Host-context function *signatures* are registered with stub
103    /// implementations that return `Unresolved(T)`. Use this at
104    /// template-validation time, when real host state is not yet
105    /// available but signatures must be known for type checking.
106    Unresolved,
107    /// Host-context functions are registered with implementations that
108    /// use the supplied path mapping rules. Use this at runtime.
109    ///
110    /// Rules are shared via `Arc` so cloning a library is cheap.
111    WithRules(Arc<Vec<PathMappingRule>>),
112}
113
114impl HostContext {
115    /// Convenience constructor: take ownership of a `Vec<PathMappingRule>`
116    /// and wrap it in an `Arc`.
117    pub fn with_rules(rules: Vec<PathMappingRule>) -> Self {
118        HostContext::WithRules(Arc::new(rules))
119    }
120
121    /// Whether this host context registers any host-context functions.
122    pub fn is_enabled(&self) -> bool {
123        !matches!(self, HostContext::None)
124    }
125
126    /// Whether this host context uses unresolved stub implementations.
127    pub fn is_unresolved(&self) -> bool {
128        matches!(self, HostContext::Unresolved)
129    }
130}
131
132/// Optional language-syntax features that a profile may accept or reject.
133///
134/// **Crate-private**: this enum is consulted only by the parser's
135/// structural validator ([`validate_structure`](crate::eval::parse)) via
136/// [`ExprProfile::allows_syntax`]. External callers describe their
137/// language flavor by constructing an `ExprProfile` with the appropriate
138/// revision and extensions; they never reach for `SyntaxFeature`
139/// directly. Keeping it `pub(crate)` means new variants and new match
140/// arms in `allows_syntax` are not SemVer-visible.
141///
142/// The expression language accepts a Python subset. Which AST shapes
143/// the parser accepts is governed by the profile's revision *and*
144/// extensions: [`ExprProfile::allows_syntax`] resolves the decision in
145/// two stages. The revision supplies a baseline (under 2026-02 every
146/// variant below is rejected, matching the original Python
147/// implementation); enabled extensions may then *additively* allow
148/// features the baseline rejects. Extensions cannot remove features
149/// the baseline allows.
150///
151/// A future revision may move a feature into its baseline (so the
152/// extension is no longer needed under that revision) or define a
153/// different set of extensions that contribute syntax.
154///
155/// Marked `#[non_exhaustive]` inside the crate as well — treated as
156/// "never pattern-match non-exhaustively, because new variants will be
157/// added," which keeps the exhaustive matches inside
158/// `baseline_syntax_v2026_02` honest.
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
160#[non_exhaustive]
161pub(crate) enum SyntaxFeature {
162    // ── Expression-level syntax ──
163    /// Walrus operator `:=`.
164    Walrus,
165    /// Lambda expressions, e.g. `lambda x: x + 1`.
166    Lambda,
167    /// Tuple literals, e.g. `(1, 2, 3)`.
168    TupleLiteral,
169    /// Dict literals, e.g. `{"a": 1}`.
170    DictLiteral,
171    /// Set literals, e.g. `{1, 2, 3}`.
172    SetLiteral,
173    /// Dict comprehensions, e.g. `{k: v for k, v in pairs}`.
174    DictComprehension,
175    /// Set comprehensions, e.g. `{x for x in xs}`.
176    SetComprehension,
177    /// Generator expressions, e.g. `(x for x in xs)`.
178    GeneratorExpression,
179    /// f-strings, e.g. `f"x={x}"`.
180    FString,
181    /// Ellipsis literal `...`.
182    Ellipsis,
183    /// Starred expressions, e.g. `*x`.
184    Starred,
185    /// Await expressions, e.g. `await x`.
186    Await,
187    /// Unicode string prefix, e.g. `u"..."`.
188    UnicodeStringPrefix,
189    /// Bytes literal, e.g. `b"..."`.
190    BytesLiteral,
191
192    // ── Binary / comparison / unary operators ──
193    /// Bitwise AND `&`.
194    BitwiseAnd,
195    /// Bitwise OR `|`.
196    BitwiseOr,
197    /// Bitwise XOR `^`.
198    BitwiseXor,
199    /// Bitwise NOT `~`.
200    BitwiseNot,
201    /// Left shift `<<`.
202    LeftShift,
203    /// Right shift `>>`.
204    RightShift,
205    /// Matrix multiply `@`.
206    MatMult,
207    /// Identity operator `is`.
208    IsOperator,
209    /// Identity operator `is not`.
210    IsNotOperator,
211
212    // ── Call-site features ──
213    /// Keyword arguments in function calls, e.g. `f(name=value)`.
214    KeywordArguments,
215
216    // ── List-comprehension features ──
217    /// Multiple `for` clauses in a list comprehension,
218    /// e.g. `[x for a in A for b in B]`.
219    MultipleForClauses,
220    /// Tuple unpacking as the loop target in a list comprehension,
221    /// e.g. `[x for (a, b) in pairs]`.
222    TupleUnpackingInComprehension,
223    /// Multiple `if` clauses in a list comprehension,
224    /// e.g. `[x for x in xs if a if b]`.
225    MultipleIfClauses,
226}
227
228/// A complete expression profile: revision, enabled extensions, and host
229/// context.
230///
231/// Passed to
232/// [`FunctionLibrary::for_profile`](crate::FunctionLibrary::for_profile)
233/// to obtain a library matching the profile.
234///
235/// # Examples
236///
237/// ```
238/// use openjd_expr::{ExprProfile, ExprRevision, HostContext, FunctionLibrary};
239///
240/// // Default profile: current revision, no extensions, no host context.
241/// let profile = ExprProfile::current();
242/// let lib = FunctionLibrary::for_profile(&profile);
243/// assert!(!lib.host_context_enabled);
244///
245/// // Template-validation profile: same as above but with unresolved host.
246/// let profile = ExprProfile::current().with_host_context(HostContext::Unresolved);
247/// let lib = FunctionLibrary::for_profile(&profile);
248/// assert!(lib.host_context_enabled);
249/// ```
250#[derive(Debug, Clone)]
251pub struct ExprProfile {
252    revision: ExprRevision,
253    extensions: HashSet<ExprExtension>,
254    host_context: HostContext,
255}
256
257impl ExprProfile {
258    /// Build a profile for the given revision with no extensions and no
259    /// host context.
260    pub fn new(revision: ExprRevision) -> Self {
261        Self {
262            revision,
263            extensions: HashSet::new(),
264            host_context: HostContext::None,
265        }
266    }
267
268    /// Shortcut for `ExprProfile::new(ExprRevision::CURRENT)`.
269    ///
270    /// Builds a profile with the current revision, *no* extensions, and
271    /// no host context. Use this when you want a stable baseline: future
272    /// crate versions that ship a new revision will change what
273    /// [`ExprRevision::CURRENT`] points to, but the extensions set will
274    /// remain explicitly empty, and the accepted syntax/functions are
275    /// whatever the current revision defines without opt-in.
276    pub fn current() -> Self {
277        Self::new(ExprRevision::CURRENT)
278    }
279
280    /// Build a profile with the latest revision *and every known
281    /// extension enabled*.
282    ///
283    /// **This profile is intentionally unstable across crate versions.**
284    /// As new extensions are added to [`ExprExtension::ALL`] and new
285    /// revisions land at [`ExprRevision::CURRENT`], the set of accepted
286    /// syntax, functions, and types grows. An expression that parses
287    /// under `latest()` today may fail to parse against a future version
288    /// of this crate if its meaning changes under the new revision.
289    ///
290    /// `ParsedExpression::new` and `FormatString::new` use this profile
291    /// as a quick-start default. For parse behavior that is stable
292    /// across crate versions, construct a profile with an explicit
293    /// revision and extension set via [`ExprProfile::new`] or
294    /// [`ExprProfile::current`] and use
295    /// [`ParsedExpression::with_profile`](crate::ParsedExpression::with_profile)
296    /// / [`FormatString::with_profile`](crate::FormatString::with_profile).
297    pub fn latest() -> Self {
298        Self {
299            revision: ExprRevision::CURRENT,
300            extensions: ExprExtension::ALL.iter().copied().collect(),
301            host_context: HostContext::None,
302        }
303    }
304
305    /// Set the enabled extensions (replaces any existing set).
306    #[must_use]
307    pub fn with_extensions(mut self, extensions: HashSet<ExprExtension>) -> Self {
308        self.extensions = extensions;
309        self
310    }
311
312    /// Set the host context.
313    #[must_use]
314    pub fn with_host_context(mut self, host_context: HostContext) -> Self {
315        self.host_context = host_context;
316        self
317    }
318
319    /// The specification revision this profile targets.
320    pub fn revision(&self) -> ExprRevision {
321        self.revision
322    }
323
324    /// The set of enabled extensions.
325    pub fn extensions(&self) -> &HashSet<ExprExtension> {
326        &self.extensions
327    }
328
329    /// The host context.
330    pub fn host_context(&self) -> &HostContext {
331        &self.host_context
332    }
333
334    /// Whether the given extension is enabled in this profile.
335    pub fn has_extension(&self, ext: ExprExtension) -> bool {
336        self.extensions.contains(&ext)
337    }
338
339    /// Whether this profile accepts the given optional syntax feature.
340    ///
341    /// **Crate-private**: consulted by the parser's structural
342    /// validator; external callers do not construct `SyntaxFeature`
343    /// values. They describe their desired language flavor through the
344    /// profile's revision and extensions; this method is how the
345    /// parser interrogates those choices.
346    ///
347    /// Resolved in two stages:
348    ///
349    /// 1. **Revision baseline.** Each revision defines a baseline set of
350    ///    accepted features. Under 2026-02 every [`SyntaxFeature`]
351    ///    variant is rejected by the baseline — the language accepts the
352    ///    same Python subset as the original Python implementation.
353    ///    A future revision may flip specific features to allowed at
354    ///    baseline (e.g. if dict literals become part of the core
355    ///    language).
356    /// 2. **Extension layer.** Any extension enabled on the profile may
357    ///    *additively* grant features the baseline rejects. Extensions
358    ///    cannot take features away; if the baseline allows a feature,
359    ///    the feature is allowed regardless of extensions. Which
360    ///    extensions contribute which features is itself a
361    ///    per-revision decision (an extension that enables feature X
362    ///    under one revision may not exist, or mean something
363    ///    different, under another), so the extension-layer dispatch
364    ///    also matches on the revision.
365    pub(crate) fn allows_syntax(&self, feature: SyntaxFeature) -> bool {
366        // Stage 1: revision baseline. The match localizes where the
367        // first revision bump needs to plug in its own baseline.
368        let baseline_allows = match self.revision {
369            ExprRevision::V2026_02 => Self::baseline_syntax_v2026_02(feature),
370        };
371        if baseline_allows {
372            return true;
373        }
374        // Stage 2: per-revision extension layer. A given extension's
375        // effect on the accepted syntax is revision-scoped, so this
376        // second match is intentional and parallel to the first. Today
377        // `ExprExtension` has no variants, so this function always
378        // returns `false`; the structure is in place for the first
379        // extension variant to plug in.
380        match self.revision {
381            ExprRevision::V2026_02 => self.extension_syntax_v2026_02(feature),
382        }
383    }
384
385    /// Baseline syntax-feature acceptance for the 2026-02 revision.
386    ///
387    /// Extracted as an associated function (no `self`) to make the
388    /// baseline self-contained and unambiguous — extension logic lives
389    /// in [`Self::extension_syntax_v2026_02`].
390    fn baseline_syntax_v2026_02(feature: SyntaxFeature) -> bool {
391        // 2026-02 baseline: every optional syntax feature is rejected.
392        // Exhaustive match so that adding a new `SyntaxFeature` variant
393        // produces a compile error here rather than silently becoming
394        // allowed.
395        match feature {
396            SyntaxFeature::Walrus
397            | SyntaxFeature::Lambda
398            | SyntaxFeature::TupleLiteral
399            | SyntaxFeature::DictLiteral
400            | SyntaxFeature::SetLiteral
401            | SyntaxFeature::DictComprehension
402            | SyntaxFeature::SetComprehension
403            | SyntaxFeature::GeneratorExpression
404            | SyntaxFeature::FString
405            | SyntaxFeature::Ellipsis
406            | SyntaxFeature::Starred
407            | SyntaxFeature::Await
408            | SyntaxFeature::UnicodeStringPrefix
409            | SyntaxFeature::BytesLiteral
410            | SyntaxFeature::BitwiseAnd
411            | SyntaxFeature::BitwiseOr
412            | SyntaxFeature::BitwiseXor
413            | SyntaxFeature::BitwiseNot
414            | SyntaxFeature::LeftShift
415            | SyntaxFeature::RightShift
416            | SyntaxFeature::MatMult
417            | SyntaxFeature::IsOperator
418            | SyntaxFeature::IsNotOperator
419            | SyntaxFeature::KeywordArguments
420            | SyntaxFeature::MultipleForClauses
421            | SyntaxFeature::TupleUnpackingInComprehension
422            | SyntaxFeature::MultipleIfClauses => false,
423        }
424    }
425
426    /// Extension-layer syntax-feature acceptance for the 2026-02 revision.
427    ///
428    /// Iterates the profile's enabled extensions and asks each one
429    /// whether it grants the feature under this revision. Today
430    /// `ExprExtension` has no variants, so this function is defined as
431    /// an empty iteration over `self.extensions` whose body would
432    /// match on the extension variant. When the first variant is added,
433    /// add a `match` arm here that returns `true` for each feature that
434    /// variant contributes under V2026_02.
435    #[allow(clippy::unused_self)] // placeholder: `self` is needed once extensions exist
436    #[allow(clippy::never_loop)] // shape is preserved for when ExprExtension has variants
437    fn extension_syntax_v2026_02(&self, feature: SyntaxFeature) -> bool {
438        // With no `ExprExtension` variants today, the iteration body is
439        // unreachable. The shape is kept so that adding a variant makes
440        // it obvious where to plug in the grant logic.
441        for ext in &self.extensions {
442            // Exhaustive match: adding a new `ExprExtension` variant
443            // produces a compile error here, forcing the contributor to
444            // state which `SyntaxFeature`s (if any) that variant
445            // enables under V2026_02.
446            match *ext {
447                // No variants today. When an extension is added that
448                // enables a syntax feature, add a match arm like:
449                //
450                //     ExprExtension::DictLiteral => {
451                //         if matches!(feature, SyntaxFeature::DictLiteral) {
452                //             return true;
453                //         }
454                //     }
455            }
456        }
457        let _ = feature; // silence unused warning until extensions exist
458        false
459    }
460
461    /// The cache key for the *rules-independent* portion of this profile.
462    ///
463    /// Libraries are cached on this key — profiles that differ only in
464    /// which `Arc<Vec<PathMappingRule>>` they carry share a single cached
465    /// skeleton, and `with_host_context(rules)` is applied on top when
466    /// needed.
467    pub(crate) fn cache_key(&self) -> ProfileKey {
468        ProfileKey {
469            revision: self.revision,
470            extensions: {
471                let mut v: Vec<ExprExtension> = self.extensions.iter().copied().collect();
472                // ExprExtension is copyable and has no Ord today; compare
473                // by hash-compatible means. With the current empty enum
474                // the vec is always empty, but keep the sort for when
475                // extensions are added.
476                v.sort_by_key(|e| {
477                    // Use Debug-formatted name as a stable order key.
478                    // With an empty enum this branch is unreachable.
479                    format!("{:?}", e)
480                });
481                v
482            },
483            host_kind: HostKind::from(&self.host_context),
484        }
485    }
486}
487
488impl Default for ExprProfile {
489    fn default() -> Self {
490        Self::current()
491    }
492}
493
494/// The rules-independent portion of an [`ExprProfile`] used as a cache key.
495#[derive(Debug, Clone, PartialEq, Eq, Hash)]
496pub(crate) struct ProfileKey {
497    pub(crate) revision: ExprRevision,
498    pub(crate) extensions: Vec<ExprExtension>,
499    pub(crate) host_kind: HostKind,
500}
501
502/// Which variety of [`HostContext`] is in use, ignoring any attached
503/// rules. Used as part of the cache key.
504#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
505pub(crate) enum HostKind {
506    None,
507    Unresolved,
508    WithRules,
509}
510
511impl From<&HostContext> for HostKind {
512    fn from(h: &HostContext) -> Self {
513        match h {
514            HostContext::None => HostKind::None,
515            HostContext::Unresolved => HostKind::Unresolved,
516            HostContext::WithRules(_) => HostKind::WithRules,
517        }
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn default_profile_is_current() {
527        let p = ExprProfile::default();
528        assert_eq!(p.revision(), ExprRevision::CURRENT);
529        assert!(p.extensions().is_empty());
530        assert!(matches!(p.host_context(), HostContext::None));
531    }
532
533    #[test]
534    fn current_matches_v2026_02() {
535        // Until a second revision exists, CURRENT must be V2026_02.
536        assert_eq!(ExprRevision::CURRENT, ExprRevision::V2026_02);
537    }
538
539    #[test]
540    fn with_host_context_unresolved() {
541        let p = ExprProfile::current().with_host_context(HostContext::Unresolved);
542        assert!(p.host_context().is_enabled());
543        assert!(p.host_context().is_unresolved());
544    }
545
546    #[test]
547    fn with_host_context_rules() {
548        let rules = vec![];
549        let p = ExprProfile::current().with_host_context(HostContext::with_rules(rules));
550        assert!(p.host_context().is_enabled());
551        assert!(!p.host_context().is_unresolved());
552    }
553
554    #[test]
555    fn cache_key_ignores_rules_content() {
556        // Two profiles with different rules must produce the same cache key,
557        // because `HostKind::WithRules` is the cache bucket, not the rules.
558        use crate::path_mapping::{PathFormat, PathMappingRule};
559        let r1 = PathMappingRule {
560            source_path_format: PathFormat::Posix,
561            source_path: "/a".into(),
562            destination_path: "/b".into(),
563        };
564        let r2 = PathMappingRule {
565            source_path_format: PathFormat::Posix,
566            source_path: "/c".into(),
567            destination_path: "/d".into(),
568        };
569        let p1 = ExprProfile::current().with_host_context(HostContext::with_rules(vec![r1]));
570        let p2 = ExprProfile::current().with_host_context(HostContext::with_rules(vec![r2]));
571        assert_eq!(p1.cache_key(), p2.cache_key());
572    }
573
574    #[test]
575    fn cache_key_distinguishes_host_kinds() {
576        let a = ExprProfile::current().cache_key(); // None
577        let b = ExprProfile::current()
578            .with_host_context(HostContext::Unresolved)
579            .cache_key();
580        let c = ExprProfile::current()
581            .with_host_context(HostContext::with_rules(vec![]))
582            .cache_key();
583        assert_ne!(a, b);
584        assert_ne!(a, c);
585        assert_ne!(b, c);
586    }
587
588    #[test]
589    fn latest_enables_all_extensions() {
590        let p = ExprProfile::latest();
591        assert_eq!(p.revision(), ExprRevision::CURRENT);
592        // Every extension in ALL must be present in the set.
593        for ext in ExprExtension::ALL {
594            assert!(
595                p.has_extension(*ext),
596                "ExprProfile::latest() must enable every extension in ExprExtension::ALL; missing {ext:?}"
597            );
598        }
599        assert_eq!(p.extensions().len(), ExprExtension::ALL.len());
600        assert!(matches!(p.host_context(), HostContext::None));
601    }
602
603    #[test]
604    fn v2026_02_rejects_every_syntax_feature() {
605        let p = ExprProfile::new(ExprRevision::V2026_02);
606        // The full feature set must be rejected by the baseline 2026-02 profile.
607        // If a future revision flips any of these to allowed, move it out of
608        // this list and document the change.
609        let all_features = [
610            SyntaxFeature::Walrus,
611            SyntaxFeature::Lambda,
612            SyntaxFeature::TupleLiteral,
613            SyntaxFeature::DictLiteral,
614            SyntaxFeature::SetLiteral,
615            SyntaxFeature::DictComprehension,
616            SyntaxFeature::SetComprehension,
617            SyntaxFeature::GeneratorExpression,
618            SyntaxFeature::FString,
619            SyntaxFeature::Ellipsis,
620            SyntaxFeature::Starred,
621            SyntaxFeature::Await,
622            SyntaxFeature::UnicodeStringPrefix,
623            SyntaxFeature::BytesLiteral,
624            SyntaxFeature::BitwiseAnd,
625            SyntaxFeature::BitwiseOr,
626            SyntaxFeature::BitwiseXor,
627            SyntaxFeature::BitwiseNot,
628            SyntaxFeature::LeftShift,
629            SyntaxFeature::RightShift,
630            SyntaxFeature::MatMult,
631            SyntaxFeature::IsOperator,
632            SyntaxFeature::IsNotOperator,
633            SyntaxFeature::KeywordArguments,
634            SyntaxFeature::MultipleForClauses,
635            SyntaxFeature::TupleUnpackingInComprehension,
636            SyntaxFeature::MultipleIfClauses,
637        ];
638        for f in all_features {
639            assert!(
640                !p.allows_syntax(f),
641                "Under V2026_02, SyntaxFeature::{f:?} must be rejected"
642            );
643        }
644    }
645
646    #[test]
647    fn latest_rejects_same_features_as_current_for_v2026_02() {
648        // With only one revision today, latest() and current() accept the
649        // same syntax features. When a second revision ships this test
650        // may need updating along with the feature gates.
651        let cur = ExprProfile::current();
652        let lat = ExprProfile::latest();
653        assert!(!cur.allows_syntax(SyntaxFeature::Lambda));
654        assert!(!lat.allows_syntax(SyntaxFeature::Lambda));
655    }
656
657    #[test]
658    fn extension_layer_does_not_reject_baseline_allowed_features() {
659        // Contract: extensions are additive. If the baseline accepts a
660        // feature, no combination of extensions can cause
661        // `allows_syntax` to return false. Today no SyntaxFeature is
662        // baseline-allowed under V2026_02, so this test is vacuous on
663        // the current revision set; it is kept as a guard for future
664        // revisions that flip features into the baseline.
665        let p_no_ext = ExprProfile::current();
666        let p_all_ext = ExprProfile::latest();
667        for f in [
668            SyntaxFeature::Walrus,
669            SyntaxFeature::Lambda,
670            SyntaxFeature::DictLiteral,
671            SyntaxFeature::SetLiteral,
672            SyntaxFeature::FString,
673            SyntaxFeature::KeywordArguments,
674        ] {
675            // If future baseline accepts f, extension-less and all-
676            // extension profiles must both accept it (additivity).
677            assert_eq!(p_no_ext.allows_syntax(f), p_all_ext.allows_syntax(f));
678        }
679    }
680}