Skip to main content

bamts_compiler/
pipeline.rs

1//! Compiler-owned frontend orchestration.
2//!
3//! [`compile_frontend`] drives the fixed scan -> parse -> check -> optional emit
4//! pipeline over one source and returns a single recovered [`FrontendOutput`].
5//! The pipeline never stops on user diagnostics: every stage always yields a
6//! product, and their diagnostics are unioned into one canonically ordered,
7//! duplicate-free vector without losing any distinct severity, range, or code.
8//!
9//! This module owns no filesystem, CLI, lowerer, runtime, or backend. It only
10//! composes the existing scanner, parser, checker, and emitter surfaces.
11
12use std::sync::Arc;
13
14use crate::checker::{self, SemanticModel};
15use crate::diagnostic::Diagnostic;
16use crate::emitter::{self, EmitOptions, EmitOutput};
17use crate::lint::{LintProfile, LintTable};
18use crate::parser;
19use crate::program::ResolvedProgram;
20use crate::scanner;
21use crate::source::{ScriptKind, SourceId, SourceText};
22use crate::syntax::SourceFile;
23
24/// The frontend product a caller wants produced for one source.
25#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
26pub enum FrontendMode {
27    /// Scan, parse, and type-check only; no emit is produced.
28    Check,
29    /// Additionally emit runtime JavaScript with type-only syntax erased.
30    JavaScript,
31    /// Additionally emit a TypeScript declaration file.
32    Declaration,
33}
34
35impl FrontendMode {
36    /// Returns the emit options this mode requests, or `None` for check-only.
37    #[must_use]
38    const fn emit_options(self) -> Option<EmitOptions> {
39        match self {
40            Self::Check => None,
41            Self::JavaScript => Some(EmitOptions::javascript()),
42            Self::Declaration => Some(EmitOptions::declaration()),
43        }
44    }
45}
46
47/// One immutable frontend compilation request.
48#[derive(Clone, Debug)]
49pub struct FrontendRequest {
50    /// The compilation-assigned identity of the source.
51    pub source_id: SourceId,
52    /// The syntax accepted for the source.
53    pub script_kind: ScriptKind,
54    /// The shared, immutable source text.
55    pub source: Arc<SourceText>,
56    /// The frontend product to produce.
57    pub mode: FrontendMode,
58}
59
60/// The immutable product of a full frontend compilation.
61///
62/// The recovered [`SourceFile`] and [`SemanticModel`] are always present, the
63/// [`EmitOutput`] is present exactly when the request asked for one, and
64/// [`FrontendOutput::diagnostics`] is the single canonically ordered union of
65/// every stage's diagnostics.
66pub struct FrontendOutput {
67    mode: FrontendMode,
68    source_file: SourceFile,
69    semantic_model: SemanticModel,
70    emit: Option<EmitOutput>,
71    diagnostics: Vec<Diagnostic>,
72}
73
74impl FrontendOutput {
75    /// Returns the mode this output was produced for.
76    #[must_use]
77    pub const fn mode(&self) -> FrontendMode {
78        self.mode
79    }
80
81    /// Returns the recovered parser product, always present even on syntax errors.
82    #[must_use]
83    pub const fn source_file(&self) -> &SourceFile {
84        &self.source_file
85    }
86
87    /// Returns the recovered semantic model, always present even on type errors.
88    #[must_use]
89    pub const fn semantic_model(&self) -> &SemanticModel {
90        &self.semantic_model
91    }
92
93    /// Returns the emit product, present exactly when the request asked to emit.
94    #[must_use]
95    pub const fn emit(&self) -> Option<&EmitOutput> {
96        self.emit.as_ref()
97    }
98
99    /// Returns every stage's diagnostics in canonical order with no duplicates.
100    #[must_use]
101    pub fn diagnostics(&self) -> &[Diagnostic] {
102        &self.diagnostics
103    }
104
105    /// Returns whether any diagnostic is an error.
106    #[must_use]
107    pub fn has_errors(&self) -> bool {
108        self.diagnostics
109            .iter()
110            .any(|diagnostic| !diagnostic.is_warning())
111    }
112
113    /// Consumes the output into its parts.
114    #[must_use]
115    pub fn into_parts(
116        self,
117    ) -> (
118        SourceFile,
119        SemanticModel,
120        Option<EmitOutput>,
121        Vec<Diagnostic>,
122    ) {
123        (
124            self.source_file,
125            self.semantic_model,
126            self.emit,
127            self.diagnostics,
128        )
129    }
130}
131
132/// Frontend products for every module of one canonical resolved program.
133pub struct ProgramFrontendOutput {
134    entrypoint: SourceId,
135    modules: Vec<FrontendOutput>,
136}
137
138impl ProgramFrontendOutput {
139    #[must_use]
140    pub const fn entrypoint_id(&self) -> SourceId {
141        self.entrypoint
142    }
143
144    /// Products in the same dependency-first order as the resolved program.
145    #[must_use]
146    pub fn modules(&self) -> &[FrontendOutput] {
147        &self.modules
148    }
149
150    #[must_use]
151    pub fn module(&self, source_id: SourceId) -> Option<&FrontendOutput> {
152        self.modules
153            .iter()
154            .find(|output| output.source_file().source_id() == source_id)
155    }
156}
157
158/// Runs the frontend for every module while preserving the graph's canonical identities.
159#[must_use]
160pub fn compile_program_frontend(
161    program: &ResolvedProgram,
162    mode: FrontendMode,
163) -> ProgramFrontendOutput {
164    compile_program_frontend_with_lints(program, mode, &LintTable::new(LintProfile::Default))
165}
166
167/// Runs the frontend for every module with caller-resolved lint levels.
168#[must_use]
169pub fn compile_program_frontend_with_lints(
170    program: &ResolvedProgram,
171    mode: FrontendMode,
172    levels: &LintTable,
173) -> ProgramFrontendOutput {
174    let modules = program
175        .modules()
176        .iter()
177        .map(|module| {
178            compile_frontend_with_lints(
179                FrontendRequest {
180                    source_id: module.source_id(),
181                    script_kind: module.script_kind(),
182                    source: Arc::clone(module.source()),
183                    mode,
184                },
185                levels,
186            )
187        })
188        .collect();
189    ProgramFrontendOutput {
190        entrypoint: program.entrypoint_id(),
191        modules,
192    }
193}
194
195/// Runs the fixed frontend pipeline with settled default lint levels.
196#[must_use]
197pub fn compile_frontend(request: FrontendRequest) -> FrontendOutput {
198    compile_frontend_with_lints(request, &LintTable::new(LintProfile::Default))
199}
200
201/// Runs the fixed scan -> parse -> check -> optional emit frontend pipeline
202/// using the caller's resolved lint table.
203///
204/// Every stage runs regardless of the diagnostics its predecessor produced, so
205/// the returned [`FrontendOutput`] always carries a recovered `SourceFile` and
206/// `SemanticModel`, and (for emitting modes) an [`EmitOutput`] even when earlier
207/// stages reported errors. All stage diagnostics are merged, canonically
208/// ordered, and de-duplicated into one vector.
209#[must_use]
210pub fn compile_frontend_with_lints(request: FrontendRequest, levels: &LintTable) -> FrontendOutput {
211    let FrontendRequest {
212        source_id,
213        script_kind,
214        source,
215        mode,
216    } = request;
217
218    let scanned = scanner::scan(source_id, script_kind, source);
219    let parsed = parser::parse(scanned);
220    let checked = checker::check_with_lints(&parsed, levels);
221
222    // Emit runs against the recovered tree; it never gates on prior diagnostics.
223    let emit = mode
224        .emit_options()
225        .map(|options| emitter::emit(parsed.product(), options));
226
227    let (source_file, parse_diagnostics) = parsed.into_parts();
228    let (semantic_model, check_diagnostics) = checked.into_parts();
229
230    let mut diagnostics = parse_diagnostics;
231    diagnostics.extend(check_diagnostics);
232    if let Some(output) = &emit {
233        diagnostics.extend(output.diagnostics.iter().cloned());
234    }
235    let diagnostics = canonicalize(diagnostics);
236
237    FrontendOutput {
238        mode,
239        source_file,
240        semantic_model,
241        emit,
242        diagnostics,
243    }
244}
245
246/// Orders diagnostics by the canonical [`Diagnostic`] key and removes exact
247/// duplicates.
248///
249/// The canonical order is a total order over `(source, range, code, severity,
250/// message)`, so sorting groups every identical diagnostic and `dedup` collapses
251/// only exact duplicates: two diagnostics differing in any of severity, range,
252/// or code are never merged.
253fn canonicalize(mut diagnostics: Vec<Diagnostic>) -> Vec<Diagnostic> {
254    diagnostics.sort();
255    diagnostics.dedup();
256    diagnostics
257}
258
259#[cfg(test)]
260mod tests {
261    use super::{FrontendMode, FrontendRequest, canonicalize, compile_frontend};
262    use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticSeverity};
263    use crate::source::{ScriptKind, SourceId, SourceText, TextRange, Utf16Pos};
264    use std::sync::Arc;
265
266    fn request(source: &str, mode: FrontendMode) -> FrontendRequest {
267        FrontendRequest {
268            source_id: SourceId::new(0),
269            script_kind: ScriptKind::TypeScript,
270            source: Arc::new(SourceText::new(source)),
271            mode,
272        }
273    }
274
275    fn range(start: usize, end: usize) -> TextRange {
276        TextRange::new(Utf16Pos::new(start), Utf16Pos::new(end)).expect("ordered range")
277    }
278
279    fn has_code(diagnostics: &[Diagnostic], code: &str) -> bool {
280        diagnostics
281            .iter()
282            .any(|diagnostic| diagnostic.code().as_str() == code)
283    }
284
285    fn is_sorted_unique(diagnostics: &[Diagnostic]) -> bool {
286        diagnostics.windows(2).all(|pair| pair[0] < pair[1])
287    }
288
289    #[test]
290    fn merges_syntax_type_and_warning_diagnostics_into_one_ordered_vector() {
291        // Warning (unchecked catch property access), a type error (string not
292        // assignable to number), and a trailing syntax error (missing initializer)
293        // all coexist in one source.
294        let source =
295            "try {} catch (e) { e.message }\nconst n: number = \"oops\";\nconst bad: number =";
296        let output = compile_frontend(request(source, FrontendMode::Check));
297        let diagnostics = output.diagnostics();
298
299        // The three stages each contributed at least one diagnostic.
300        assert!(has_code(diagnostics, "BAMTS-W005"), "warning stage missing");
301        assert!(has_code(diagnostics, "BAMTS-C004"), "type stage missing");
302        assert!(
303            diagnostics.iter().any(
304                |diagnostic| diagnostic.severity() == DiagnosticSeverity::Error
305                    && diagnostic.code().as_str() != "BAMTS-C004"
306            ),
307            "syntax stage missing",
308        );
309
310        // Both severities survive the merge.
311        assert!(diagnostics.iter().any(Diagnostic::is_warning));
312        assert!(
313            diagnostics
314                .iter()
315                .any(|diagnostic| !diagnostic.is_warning())
316        );
317
318        // The single vector is canonically ordered and free of exact duplicates.
319        assert!(is_sorted_unique(diagnostics), "diagnostics not canonical");
320    }
321
322    #[test]
323    fn emits_despite_earlier_errors() {
324        // A type error must not suppress the emit product.
325        let source = "const n: number = \"oops\";";
326        let output = compile_frontend(request(source, FrontendMode::JavaScript));
327
328        assert!(output.has_errors(), "expected a type error");
329        let emit = output.emit().expect("javascript mode must emit");
330        assert!(
331            emit.code.contains("oops"),
332            "emit should still print the recovered program",
333        );
334    }
335
336    #[test]
337    fn check_mode_produces_no_emit_while_js_and_declaration_do() {
338        let source = "let value: number = 1;";
339
340        let check = compile_frontend(request(source, FrontendMode::Check));
341        assert!(check.emit().is_none(), "check mode must not emit");
342
343        let js = compile_frontend(request(source, FrontendMode::JavaScript));
344        let js_emit = js.emit().expect("javascript mode emits");
345
346        let declaration = compile_frontend(request(source, FrontendMode::Declaration));
347        let declaration_emit = declaration.emit().expect("declaration mode emits");
348
349        // JavaScript erases the type annotation; the declaration surface keeps it.
350        assert!(!js_emit.code.contains("number"), "js must erase the type");
351        assert!(
352            declaration_emit.code.contains("number"),
353            "declaration must retain the type",
354        );
355        assert_ne!(js_emit.code, declaration_emit.code);
356    }
357
358    #[test]
359    fn canonicalize_collapses_exact_duplicates_and_preserves_distinct_ones() {
360        let source_id = SourceId::new(0);
361        let code = DiagnosticCode::new("BAMTS-C001");
362        let base = Diagnostic::error(code, source_id, range(0, 1), "duplicate");
363        let duplicate = base.clone();
364        // Distinct in severity only.
365        let as_warning = Diagnostic::warning(code, source_id, range(0, 1), "duplicate");
366        // Distinct in range only.
367        let elsewhere = Diagnostic::error(code, source_id, range(2, 3), "duplicate");
368        // Distinct in code only.
369        let other_code = Diagnostic::error(
370            DiagnosticCode::new("BAMTS-C002"),
371            source_id,
372            range(0, 1),
373            "duplicate",
374        );
375
376        let merged = canonicalize(vec![
377            base.clone(),
378            duplicate,
379            as_warning.clone(),
380            elsewhere.clone(),
381            other_code.clone(),
382            base.clone(),
383        ]);
384
385        // The two exact copies collapse to one; every distinct diagnostic remains.
386        assert_eq!(merged.len(), 4);
387        assert_eq!(merged.iter().filter(|d| **d == base).count(), 1);
388        assert!(merged.contains(&as_warning));
389        assert!(merged.contains(&elsewhere));
390        assert!(merged.contains(&other_code));
391        assert!(is_sorted_unique(&merged));
392    }
393
394    #[test]
395    fn frontend_output_never_contains_duplicate_diagnostics() {
396        let source =
397            "try {} catch (e) { e.message }\nconst n: number = \"oops\";\nconst bad: number =";
398        let output = compile_frontend(request(source, FrontendMode::JavaScript));
399        assert!(is_sorted_unique(output.diagnostics()));
400    }
401}