Skip to main content

plsql_ir/
lower.rs

1//! AST → IR lowering for top-level declarations.
2//!
3//! Walks `plsql_parser::Ast::root.declarations` and produces a
4//! [`LoweredFile`] containing one [`Declaration`] per recognized
5//! `AstDecl` variant plus a `Vec<Diagnostic>` for unclassified rows
6//! (R13 — typed uncertainty, never silent drops).
7//!
8//! Pipeline:
9//!
10//! 1. Iterate `ast.root.declarations`.
11//! 2. For each variant emit a [`Declaration`] with the source name
12//!    interned into the supplied [`SymbolInterner`].
13//! 3. `AstDecl::Unknown` becomes a typed `parser-recovery-region`
14//!    diagnostic so the engine's `CompletenessReport` reflects the
15//!    unclassified region instead of dropping it.
16//! 4. `AstDecl::Ddl` is currently informational — the rule engine that
17//!    classifies `CREATE / ALTER / DROP / GRANT` lives in the catalog +
18//!    ChangeSet layers; here we record the verb in a diagnostic and let
19//!    callers decide.
20
21use plsql_core::{Diagnostic, Evidence, Severity, SymbolInterner};
22use plsql_parser::Ast;
23use plsql_parser::ast::AstDecl;
24use tracing::instrument;
25
26/// The evidence code + attribute key the USR-loop capture
27/// (`plsql_accretion::gap::antlr_rule_path_of`
28/// §2.1`) reads to recover the ANTLR grammar position a repairable
29/// diagnostic arose at. Keeping the contract in one place means the
30/// producer (here) and the consumer (capture) cannot drift.
31const ANTLR_RULE_PATH_EVIDENCE_CODE: &str = "ANTLR_RULE_PATH";
32const ANTLR_RULE_PATH_ATTR_KEY: &str = "antlr_rule_path";
33
34/// Stamp the ANTLR `rule_path` (a `>`-joined path of *grammar rule
35/// names* — never source text/identifiers, see
36/// `plsql_parser::ast::AstDecl::Ddl`) onto `diag` as a structured
37/// [`Evidence`] attribute, exactly where the USR-loop capture reads
38/// it. A no-op when the declaration carried no rule path (text
39/// scanner fallback), so signatures stay honest: a `None` here is a
40/// real "no parse-tree position", not a fabricated one.
41fn stamp_antlr_rule_path(diag: Diagnostic, rule_path: Option<&str>) -> Diagnostic {
42    match rule_path {
43        Some(path) if !path.is_empty() => diag.with_evidence(
44            Evidence::new(
45                ANTLR_RULE_PATH_EVIDENCE_CODE,
46                "ANTLR grammar rule position of the unlowered declaration",
47            )
48            .with_attribute(
49                ANTLR_RULE_PATH_ATTR_KEY,
50                serde_json::Value::String(path.to_string()),
51            ),
52        ),
53        _ => diag,
54    }
55}
56
57use crate::decl::{
58    DeclCommon, Declaration, FunctionDecl, PackageDecl, ProcedureDecl, TriggerDecl, TypeDecl,
59    ViewDecl,
60};
61
62/// Bundle of declarations + diagnostics produced by [`lower_top_level`].
63#[derive(Clone, Debug, Default, PartialEq)]
64pub struct LoweredFile {
65    /// One [`Declaration`] per recognized `AstDecl` variant, in source
66    /// order.
67    pub declarations: Vec<Declaration>,
68    /// Typed diagnostics for unclassified / informational rows. The
69    /// engine merges these into the per-run `Diagnostic` stream.
70    pub diagnostics: Vec<Diagnostic>,
71}
72
73impl LoweredFile {
74    #[must_use]
75    pub fn is_empty(&self) -> bool {
76        self.declarations.is_empty() && self.diagnostics.is_empty()
77    }
78}
79
80/// Lower every top-level `AstDecl` in `ast` to an IR `Declaration`.
81///
82/// `interner` is mutated so the produced `Declaration::name`s are
83/// re-resolvable through the same symbol table the rest of the engine
84/// uses.
85#[must_use]
86#[instrument(level = "trace", skip(ast, interner))]
87pub fn lower_top_level(ast: &Ast, interner: &mut SymbolInterner) -> LoweredFile {
88    let mut out = LoweredFile::default();
89    for decl in &ast.root.declarations {
90        match decl {
91            AstDecl::PackageSpec { name, span } => {
92                let common = make_common(name, *span, interner);
93                out.declarations.push(Declaration::Package(PackageDecl {
94                    common,
95                    members: Vec::new(),
96                    body: None,
97                }));
98            }
99            AstDecl::PackageBody { name, span } => {
100                // The body's `common` carries the same name as the spec;
101                // the spec/body pairing is wired up in the symbol pass
102                // (PLSQL-SYM-001). Emit the body as a Package decl with
103                // no members for now so the file-level top_level list
104                // includes both spec and body in source order.
105                let common = make_common(name, *span, interner);
106                out.declarations.push(Declaration::Package(PackageDecl {
107                    common,
108                    members: Vec::new(),
109                    body: None,
110                }));
111            }
112            AstDecl::Procedure { name, span } => {
113                let common = make_common(name, *span, interner);
114                out.declarations.push(Declaration::Procedure(ProcedureDecl {
115                    common,
116                    params: Vec::new(),
117                }));
118            }
119            AstDecl::Function { name, span } => {
120                let common = make_common(name, *span, interner);
121                out.declarations.push(Declaration::Function(FunctionDecl {
122                    common,
123                    params: Vec::new(),
124                    return_type: None,
125                }));
126            }
127            AstDecl::Trigger { name, span } => {
128                let common = make_common(name, *span, interner);
129                out.declarations
130                    .push(Declaration::Trigger(TriggerDecl { common }));
131            }
132            AstDecl::View { name, span } => {
133                let common = make_common(name, *span, interner);
134                out.declarations.push(Declaration::View(ViewDecl {
135                    common,
136                    columns: Vec::new(),
137                }));
138            }
139            AstDecl::TypeSpec { name, span } | AstDecl::TypeBody { name, span } => {
140                let common = make_common(name, *span, interner);
141                out.declarations
142                    .push(Declaration::Type(TypeDecl { common }));
143            }
144            AstDecl::Ddl {
145                kind,
146                span,
147                antlr_rule_path,
148            } => {
149                // CREATE / ALTER / DROP / GRANT lives in the changeset
150                // path; record it informationally so the file-level
151                // CompletenessReport reflects it.
152                let mut diagnostic = Diagnostic::new(
153                    "IR_DDL_NOT_LOWERED",
154                    Severity::Info,
155                    format!("DDL `{kind}` recorded but not lowered (handled by ChangeSet path)"),
156                );
157                diagnostic.primary_span = Some(*span);
158                // USR-loop §2.1: stamp the ANTLR grammar position so
159                // gap signatures are fine-grained (rule names only —
160                // I-PRIVACY: never source text/identifiers).
161                let diagnostic = stamp_antlr_rule_path(diagnostic, antlr_rule_path.as_deref());
162                out.diagnostics.push(diagnostic);
163            }
164            AstDecl::Unknown {
165                span,
166                antlr_rule_path,
167            } => {
168                // R13: emit a typed parser-recovery diagnostic so the
169                // engine's CompletenessReport sees the unclassified
170                // region instead of dropping it.
171                let mut diagnostic = Diagnostic::new(
172                    "IR_UNCLASSIFIED_DECL",
173                    Severity::Warn,
174                    "AST classifier returned `Unknown` — declaration not lowered",
175                );
176                diagnostic.primary_span = Some(*span);
177                diagnostic
178                    .unknown_reasons
179                    .push(plsql_core::UnknownReason::ParserRecoveryRegion);
180                let diagnostic = stamp_antlr_rule_path(diagnostic, antlr_rule_path.as_deref());
181                out.diagnostics.push(diagnostic);
182            }
183        }
184    }
185    out
186}
187
188fn make_common(name: &str, span: plsql_core::Span, interner: &mut SymbolInterner) -> DeclCommon {
189    let interned = interner.intern(name).unwrap_or_else(|| {
190        // SymbolInterner only fails on u64-overflow; in practice
191        // intern() returns None when the interner has run out of slots.
192        // We surface a 0 marker symbol so the caller can flag it
193        // upstream via the diagnostic shoot at lower_top_level's end.
194        plsql_core::SymbolId::new(0)
195    });
196    DeclCommon::new(interned, span)
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use plsql_core::{FileId, Position};
203    use plsql_parser::ast::SourceFile;
204
205    fn span(offset: u32, len: u32) -> plsql_core::Span {
206        plsql_core::Span::new(
207            FileId::new(0),
208            Position::new(1, 1, offset),
209            Position::new(1, 1, offset + len),
210        )
211    }
212
213    fn ast_with(decls: Vec<AstDecl>) -> Ast {
214        Ast {
215            root: SourceFile {
216                span: span(0, 0),
217                declarations: decls,
218            },
219            source_map: plsql_parser::ast::SourceMap::new(),
220            body_statements: Vec::new(),
221        }
222    }
223
224    #[test]
225    fn empty_ast_yields_empty_lowered_file() {
226        let mut interner = SymbolInterner::new();
227        let out = lower_top_level(&ast_with(vec![]), &mut interner);
228        assert!(out.is_empty());
229    }
230
231    #[test]
232    fn package_spec_lowers_to_package_decl() {
233        let mut interner = SymbolInterner::new();
234        let out = lower_top_level(
235            &ast_with(vec![AstDecl::PackageSpec {
236                name: String::from("BILLING_API"),
237                span: span(0, 12),
238            }]),
239            &mut interner,
240        );
241        assert_eq!(out.declarations.len(), 1);
242        assert!(matches!(out.declarations[0], Declaration::Package(_)));
243        // The interner now resolves the name.
244        let symbol = out.declarations[0].common().name;
245        assert_eq!(interner.resolve(symbol), Some("BILLING_API"));
246    }
247
248    #[test]
249    fn body_pairs_with_spec_in_source_order() {
250        let mut interner = SymbolInterner::new();
251        let out = lower_top_level(
252            &ast_with(vec![
253                AstDecl::PackageSpec {
254                    name: String::from("BILLING_API"),
255                    span: span(0, 12),
256                },
257                AstDecl::PackageBody {
258                    name: String::from("BILLING_API"),
259                    span: span(13, 12),
260                },
261            ]),
262            &mut interner,
263        );
264        assert_eq!(out.declarations.len(), 2);
265        assert!(out.diagnostics.is_empty());
266    }
267
268    #[test]
269    fn procedure_function_trigger_view_each_lower() {
270        let mut interner = SymbolInterner::new();
271        let out = lower_top_level(
272            &ast_with(vec![
273                AstDecl::Procedure {
274                    name: String::from("RESET_BALANCE"),
275                    span: span(0, 8),
276                },
277                AstDecl::Function {
278                    name: String::from("CURRENT_BALANCE"),
279                    span: span(10, 8),
280                },
281                AstDecl::Trigger {
282                    name: String::from("INVOICES_BIU"),
283                    span: span(20, 8),
284                },
285                AstDecl::View {
286                    name: String::from("V_BALANCE"),
287                    span: span(30, 8),
288                },
289                AstDecl::TypeSpec {
290                    name: String::from("ADDRESS_T"),
291                    span: span(40, 8),
292                },
293                AstDecl::TypeBody {
294                    name: String::from("ADDRESS_T"),
295                    span: span(50, 8),
296                },
297            ]),
298            &mut interner,
299        );
300        assert_eq!(out.declarations.len(), 6);
301        // Verify variant types so a future refactor can't silently swap.
302        assert!(matches!(out.declarations[0], Declaration::Procedure(_)));
303        assert!(matches!(out.declarations[1], Declaration::Function(_)));
304        assert!(matches!(out.declarations[2], Declaration::Trigger(_)));
305        assert!(matches!(out.declarations[3], Declaration::View(_)));
306        assert!(matches!(out.declarations[4], Declaration::Type(_)));
307        assert!(matches!(out.declarations[5], Declaration::Type(_)));
308    }
309
310    #[test]
311    fn ddl_emits_informational_diagnostic_no_declaration() {
312        let mut interner = SymbolInterner::new();
313        let out = lower_top_level(
314            &ast_with(vec![AstDecl::Ddl {
315                kind: String::from("CREATE TABLE"),
316                span: span(0, 12),
317                antlr_rule_path: None,
318            }]),
319            &mut interner,
320        );
321        assert!(out.declarations.is_empty());
322        assert_eq!(out.diagnostics.len(), 1);
323        assert_eq!(out.diagnostics[0].code, "IR_DDL_NOT_LOWERED");
324        assert_eq!(out.diagnostics[0].severity, Severity::Info);
325        // No rule path supplied → no fabricated evidence (honest None).
326        assert!(
327            out.diagnostics[0]
328                .evidence
329                .iter()
330                .all(|e| e.code != "ANTLR_RULE_PATH")
331        );
332    }
333
334    #[test]
335    fn ddl_rule_path_is_stamped_as_capture_evidence() {
336        let mut interner = SymbolInterner::new();
337        let out = lower_top_level(
338            &ast_with(vec![AstDecl::Ddl {
339                kind: String::from("CREATE SEQUENCE"),
340                span: span(0, 15),
341                antlr_rule_path: Some(String::from("unit_statement>create_sequence")),
342            }]),
343            &mut interner,
344        );
345        let diag = &out.diagnostics[0];
346        assert_eq!(diag.code, "IR_DDL_NOT_LOWERED");
347        // The capture contract: an `ANTLR_RULE_PATH` evidence whose
348        // `antlr_rule_path` attribute is the verbatim grammar path.
349        let ev = diag
350            .evidence
351            .iter()
352            .find(|e| e.code == "ANTLR_RULE_PATH")
353            .expect("rule-path evidence must be stamped");
354        assert_eq!(
355            ev.attributes
356                .get("antlr_rule_path")
357                .and_then(|v| v.as_str()),
358            Some("unit_statement>create_sequence")
359        );
360    }
361
362    #[test]
363    fn unknown_decl_emits_typed_warning_with_unknown_reason() {
364        let mut interner = SymbolInterner::new();
365        let out = lower_top_level(
366            &ast_with(vec![AstDecl::Unknown {
367                span: span(0, 4),
368                antlr_rule_path: None,
369            }]),
370            &mut interner,
371        );
372        assert!(out.declarations.is_empty());
373        assert_eq!(out.diagnostics.len(), 1);
374        assert_eq!(out.diagnostics[0].code, "IR_UNCLASSIFIED_DECL");
375        assert_eq!(out.diagnostics[0].severity, Severity::Warn);
376        assert!(
377            out.diagnostics[0]
378                .unknown_reasons
379                .contains(&plsql_core::UnknownReason::ParserRecoveryRegion)
380        );
381    }
382
383    #[test]
384    fn span_propagates_into_declcommon() {
385        let mut interner = SymbolInterner::new();
386        let in_span = span(42, 8);
387        let out = lower_top_level(
388            &ast_with(vec![AstDecl::Procedure {
389                name: String::from("FOO"),
390                span: in_span,
391            }]),
392            &mut interner,
393        );
394        assert_eq!(out.declarations[0].common().span, in_span);
395    }
396}