Skip to main content

plsql_parser/
dialect.rs

1//! Dialect-mismatch diagnostics for the parser.
2//!
3//! When the grammar recognizes a token that maps to an `OracleFeature` that is
4//! not available on the run's `OracleTargetVersion`, the parser emits a
5//! `Diagnostic` with the stable code [`UNSUPPORTED_DIALECT_FEATURE_CODE`],
6//! carrying an [`UnknownReason::UnsupportedDialectFeature`] tag and a
7//! version-aware remediation hint.
8//!
9//! Per `plan.md` R13 (no uncertainty silently dropped), every dialect-mismatch
10//! is a typed blind spot, never a panic.
11
12use plsql_core::{Diagnostic, OracleFeature, OracleVersion, Severity, Span, UnknownReason};
13
14use crate::OracleTargetVersion;
15
16/// Stable diagnostic code emitted whenever the parser encounters an
17/// `OracleFeature` token outside its target version.
18pub const UNSUPPORTED_DIALECT_FEATURE_CODE: &str = "PARSE_UNSUPPORTED_DIALECT_FEATURE";
19
20/// Returns the earliest Oracle version that supports `feature`.
21///
22/// Mirrors the per-version `default_features()` table in
23/// `plsql_core::OracleVersion`.
24#[must_use]
25pub fn earliest_supporting_version(feature: OracleFeature) -> OracleVersion {
26    match feature {
27        OracleFeature::SqlMacros | OracleFeature::PolymorphicTableFunctions => {
28            OracleVersion::Oracle21c
29        }
30        OracleFeature::SqlBoolean23ai
31        | OracleFeature::PlsqlVector23ai
32        | OracleFeature::JsonRelationalDuality23ai => OracleVersion::Oracle23ai,
33        OracleFeature::BinaryVector26ai
34        | OracleFeature::SparseVector26ai
35        | OracleFeature::VectorArithmetic26ai
36        | OracleFeature::PackageResettable26ai
37        | OracleFeature::MultilingualEngineCallSpecs => OracleVersion::Oracle26ai,
38    }
39}
40
41/// Human-friendly label for an `OracleFeature`, used in diagnostic messages.
42#[must_use]
43pub fn feature_label(feature: OracleFeature) -> &'static str {
44    match feature {
45        OracleFeature::SqlBoolean23ai => "SQL `BOOLEAN`",
46        OracleFeature::PlsqlVector23ai => "PL/SQL `VECTOR`",
47        OracleFeature::BinaryVector26ai => "`BINARY VECTOR`",
48        OracleFeature::SparseVector26ai => "`SPARSE VECTOR`",
49        OracleFeature::VectorArithmetic26ai => "vector arithmetic operators",
50        OracleFeature::PackageResettable26ai => "package `RESETTABLE` clause",
51        OracleFeature::JsonRelationalDuality23ai => "JSON relational duality",
52        OracleFeature::SqlMacros => "SQL macros",
53        OracleFeature::PolymorphicTableFunctions => "polymorphic table functions",
54        OracleFeature::MultilingualEngineCallSpecs => "multilingual engine call specs",
55    }
56}
57
58fn target_version_label(target: OracleTargetVersion) -> &'static str {
59    match target {
60        OracleTargetVersion::Oracle11g => "Oracle 11g",
61        OracleTargetVersion::Oracle12c => "Oracle 12c",
62        OracleTargetVersion::Oracle19c => "Oracle 19c",
63        OracleTargetVersion::Oracle21c => "Oracle 21c",
64        OracleTargetVersion::Oracle23ai => "Oracle 23ai",
65        OracleTargetVersion::Oracle26ai => "Oracle 26ai",
66    }
67}
68
69fn version_label(version: OracleVersion) -> &'static str {
70    match version {
71        OracleVersion::Oracle11g => "Oracle 11g",
72        OracleVersion::Oracle12c => "Oracle 12c",
73        OracleVersion::Oracle19c => "Oracle 19c",
74        OracleVersion::Oracle21c => "Oracle 21c",
75        OracleVersion::Oracle23ai => "Oracle 23ai",
76        OracleVersion::Oracle26ai => "Oracle 26ai",
77    }
78}
79
80fn target_supports_feature(target: OracleTargetVersion, feature: OracleFeature) -> bool {
81    let target_version = match target {
82        OracleTargetVersion::Oracle11g => OracleVersion::Oracle11g,
83        OracleTargetVersion::Oracle12c => OracleVersion::Oracle12c,
84        OracleTargetVersion::Oracle19c => OracleVersion::Oracle19c,
85        OracleTargetVersion::Oracle21c => OracleVersion::Oracle21c,
86        OracleTargetVersion::Oracle23ai => OracleVersion::Oracle23ai,
87        OracleTargetVersion::Oracle26ai => OracleVersion::Oracle26ai,
88    };
89    target_version.default_features().contains(&feature)
90}
91
92/// Build a version-aware remediation hint for `feature` against `target`.
93///
94/// Examples:
95/// - target 19c, feature SQL BOOLEAN → "SQL `BOOLEAN` is available in Oracle
96///   23ai or later. Either upgrade the target version, or use NUMBER(1) with
97///   a CHECK constraint."
98/// - target 23ai, feature SPARSE VECTOR → "SPARSE VECTOR is available in
99///   Oracle 26ai or later. Either upgrade the target version, or model the
100///   sparse storage explicitly."
101#[must_use]
102pub fn unsupported_dialect_feature_remediation(
103    feature: OracleFeature,
104    target: OracleTargetVersion,
105) -> String {
106    let label = feature_label(feature);
107    let earliest = earliest_supporting_version(feature);
108    let earliest_label = version_label(earliest);
109    let target_label = target_version_label(target);
110    let mut hint = format!(
111        "{label} is available in {earliest_label} or later, but the parse target is {target_label}. Either raise the `parse_options.oracle_version` to a version that supports it, or rewrite the source to avoid this construct."
112    );
113    if let Some(extra) = workaround_hint(feature) {
114        hint.push(' ');
115        hint.push_str(extra);
116    }
117    hint
118}
119
120/// Returns a feature-specific concrete workaround suggestion appended to the
121/// version remediation, if one is known.
122fn workaround_hint(feature: OracleFeature) -> Option<&'static str> {
123    match feature {
124        OracleFeature::SqlBoolean23ai => Some(
125            "Workaround: model the column as `NUMBER(1)` with a `CHECK (col IN (0,1))` constraint.",
126        ),
127        OracleFeature::PlsqlVector23ai
128        | OracleFeature::BinaryVector26ai
129        | OracleFeature::SparseVector26ai
130        | OracleFeature::VectorArithmetic26ai => Some(
131            "Workaround: store the vector as a CLOB / BLOB and compute distances in PL/SQL until upgrade.",
132        ),
133        OracleFeature::PackageResettable26ai => Some(
134            "Workaround: avoid `RESETTABLE` on the package; reset state explicitly in an initialization routine.",
135        ),
136        OracleFeature::JsonRelationalDuality23ai => Some(
137            "Workaround: model the duality view as a regular view plus an INSTEAD OF trigger until upgrade.",
138        ),
139        OracleFeature::SqlMacros => Some(
140            "Workaround: expand the macro manually in callers, or wrap it in a function (with a perf cost).",
141        ),
142        OracleFeature::PolymorphicTableFunctions => Some(
143            "Workaround: write a per-shape table function until polymorphic table functions are available.",
144        ),
145        OracleFeature::MultilingualEngineCallSpecs => Some(
146            "Workaround: use the equivalent Java / external procedure call spec for the older target.",
147        ),
148    }
149}
150
151/// Build a `Diagnostic` reporting that `feature` is not available on the
152/// `parse_options.oracle_version` target.
153///
154/// Returns `None` when the feature *is* supported on the target — callers can
155/// use this as both a check and a builder.
156#[must_use]
157pub fn unsupported_dialect_feature_diagnostic(
158    feature: OracleFeature,
159    target: OracleTargetVersion,
160    span: Option<Span>,
161) -> Option<Diagnostic> {
162    if target_supports_feature(target, feature) {
163        return None;
164    }
165    let label = feature_label(feature);
166    let target_label = target_version_label(target);
167    let mut diagnostic = Diagnostic::new(
168        UNSUPPORTED_DIALECT_FEATURE_CODE,
169        Severity::Error,
170        format!("{label} is not supported when parsing against {target_label}"),
171    );
172    diagnostic.primary_span = span;
173    diagnostic.help = Some(unsupported_dialect_feature_remediation(feature, target));
174    diagnostic
175        .unknown_reasons
176        .push(UnknownReason::UnsupportedDialectFeature);
177    Some(diagnostic)
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use plsql_core::{FileId, Position};
184
185    #[test]
186    fn earliest_version_table_matches_oracle_version_defaults() {
187        // Spot-check a few features against the core version table.
188        assert_eq!(
189            earliest_supporting_version(OracleFeature::SqlBoolean23ai),
190            OracleVersion::Oracle23ai
191        );
192        assert_eq!(
193            earliest_supporting_version(OracleFeature::SqlMacros),
194            OracleVersion::Oracle21c
195        );
196        assert_eq!(
197            earliest_supporting_version(OracleFeature::BinaryVector26ai),
198            OracleVersion::Oracle26ai
199        );
200    }
201
202    #[test]
203    fn diagnostic_emitted_for_unsupported_feature_on_lower_target() {
204        let span = Some(Span::new(
205            FileId::new(0),
206            Position::new(1, 1, 0),
207            Position::new(1, 11, 10),
208        ));
209        let diagnostic = unsupported_dialect_feature_diagnostic(
210            OracleFeature::SqlBoolean23ai,
211            OracleTargetVersion::Oracle19c,
212            span,
213        )
214        .expect("expected diagnostic for 23ai feature on 19c target");
215
216        assert_eq!(diagnostic.code, UNSUPPORTED_DIALECT_FEATURE_CODE);
217        assert_eq!(diagnostic.severity, Severity::Error);
218        assert!(diagnostic.message.contains("SQL `BOOLEAN`"));
219        assert!(diagnostic.message.contains("Oracle 19c"));
220        let help = diagnostic.help.as_deref().expect("help");
221        assert!(help.contains("Oracle 23ai or later"));
222        assert!(help.contains("NUMBER(1)"));
223        assert_eq!(diagnostic.primary_span, span);
224        assert_eq!(diagnostic.unknown_reasons.len(), 1);
225        assert!(matches!(
226            diagnostic.unknown_reasons[0],
227            UnknownReason::UnsupportedDialectFeature
228        ));
229    }
230
231    #[test]
232    fn no_diagnostic_when_target_supports_feature() {
233        assert!(
234            unsupported_dialect_feature_diagnostic(
235                OracleFeature::SqlBoolean23ai,
236                OracleTargetVersion::Oracle23ai,
237                None,
238            )
239            .is_none()
240        );
241    }
242
243    #[test]
244    fn vector_workarounds_consolidate_under_one_hint() {
245        for feature in [
246            OracleFeature::PlsqlVector23ai,
247            OracleFeature::BinaryVector26ai,
248            OracleFeature::SparseVector26ai,
249            OracleFeature::VectorArithmetic26ai,
250        ] {
251            let hint =
252                unsupported_dialect_feature_remediation(feature, OracleTargetVersion::Oracle19c);
253            assert!(
254                hint.contains("CLOB"),
255                "feature {feature:?} hint missing workaround"
256            );
257        }
258    }
259
260    #[test]
261    fn remediation_lists_earliest_version_label() {
262        let hint = unsupported_dialect_feature_remediation(
263            OracleFeature::PackageResettable26ai,
264            OracleTargetVersion::Oracle23ai,
265        );
266        assert!(hint.contains("Oracle 26ai or later"));
267        assert!(hint.contains("Oracle 23ai"));
268        assert!(hint.contains("RESETTABLE"));
269    }
270
271    #[test]
272    fn all_features_have_workaround_hints() {
273        for feature in [
274            OracleFeature::SqlBoolean23ai,
275            OracleFeature::PlsqlVector23ai,
276            OracleFeature::BinaryVector26ai,
277            OracleFeature::SparseVector26ai,
278            OracleFeature::VectorArithmetic26ai,
279            OracleFeature::PackageResettable26ai,
280            OracleFeature::JsonRelationalDuality23ai,
281            OracleFeature::SqlMacros,
282            OracleFeature::PolymorphicTableFunctions,
283            OracleFeature::MultilingualEngineCallSpecs,
284        ] {
285            let hint =
286                unsupported_dialect_feature_remediation(feature, OracleTargetVersion::Oracle11g);
287            assert!(
288                hint.to_lowercase().contains("workaround"),
289                "feature {feature:?} should carry a workaround hint"
290            );
291        }
292    }
293
294    #[test]
295    fn earliest_version_is_consistent_with_core_default_features() {
296        // The pre-existing spot-check only asserts 3 literals. The
297        // real invariant binding these two hand-maintained tables in
298        // different crates: for EVERY feature and version,
299        // `default_features(v)` contains `f` IFF `v` is at or after
300        // `earliest_supporting_version(f)`. Catches drift in both
301        // directions (core adds a feature to a version without
302        // updating dialect.rs, or vice-versa).
303        let ordered = [
304            OracleVersion::Oracle11g,
305            OracleVersion::Oracle12c,
306            OracleVersion::Oracle19c,
307            OracleVersion::Oracle21c,
308            OracleVersion::Oracle23ai,
309            OracleVersion::Oracle26ai,
310        ];
311        let features = [
312            OracleFeature::SqlBoolean23ai,
313            OracleFeature::PlsqlVector23ai,
314            OracleFeature::BinaryVector26ai,
315            OracleFeature::SparseVector26ai,
316            OracleFeature::VectorArithmetic26ai,
317            OracleFeature::PackageResettable26ai,
318            OracleFeature::JsonRelationalDuality23ai,
319            OracleFeature::SqlMacros,
320            OracleFeature::PolymorphicTableFunctions,
321            OracleFeature::MultilingualEngineCallSpecs,
322        ];
323        let idx = |v: OracleVersion| ordered.iter().position(|x| *x == v).unwrap();
324
325        for f in features {
326            let earliest = earliest_supporting_version(f);
327            for &v in &ordered {
328                let expected = idx(v) >= idx(earliest);
329                assert_eq!(
330                    v.default_features().contains(&f),
331                    expected,
332                    "{f:?}: default_features({v:?}).contains == {} but earliest is {earliest:?}",
333                    !expected
334                );
335            }
336        }
337    }
338}