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}