Skip to main content

merman_analysis/
analyzer.rs

1use crate::diagnostic_projection::{
2    core_error_diagnostic, flowchart_facts_projection_diagnostic, no_diagram_diagnostic,
3    panic_diagnostic,
4};
5use crate::recovery::{
6    AnalysisRecoveryDiagnostic, core_error_recovery_diagnostics, editor_recovery_diagnostics,
7    merge_recovery_diagnostics,
8};
9use crate::rules::AnalysisRuleConfig;
10use crate::{
11    AnalysisDiagnostic, AnalysisFlowchartFacts, AnalysisPayload, AnalysisResult,
12    AnalysisSyntaxFacts, AnalyzedDiagram, DocumentDiagram, FenceTextIndex, SourceDescriptor,
13    SourceMap,
14};
15use merman_core::{
16    EditorSemanticFacts, Engine, Error as CoreError, MermaidConfig, ParseOptions, ParsedDiagram,
17    ParsedDiagramWithEditorFacts, ParsedEditorFacts,
18};
19use std::panic::{self, AssertUnwindSafe};
20use std::sync::Arc;
21
22#[derive(Debug, Clone, PartialEq)]
23pub struct AnalysisOptions {
24    pub parse: ParseOptions,
25    pub source: SourceDescriptor,
26    pub site_config: Option<MermaidConfig>,
27    pub fixed_today: Option<chrono::NaiveDate>,
28    pub fixed_local_offset_minutes: Option<i32>,
29    pub max_source_bytes: Option<usize>,
30    pub rule_config: AnalysisRuleConfig,
31}
32
33impl Default for AnalysisOptions {
34    fn default() -> Self {
35        Self {
36            parse: ParseOptions::strict(),
37            source: SourceDescriptor::diagram(),
38            site_config: None,
39            fixed_today: None,
40            fixed_local_offset_minutes: None,
41            max_source_bytes: None,
42            rule_config: AnalysisRuleConfig::default(),
43        }
44    }
45}
46
47impl AnalysisOptions {
48    pub fn with_parse_options(mut self, parse: ParseOptions) -> Self {
49        self.parse = parse;
50        self
51    }
52
53    pub fn with_source(mut self, source: SourceDescriptor) -> Self {
54        self.source = source;
55        self
56    }
57
58    pub fn with_site_config(mut self, site_config: MermaidConfig) -> Self {
59        self.site_config = Some(site_config);
60        self
61    }
62
63    pub fn with_fixed_today(mut self, today: Option<chrono::NaiveDate>) -> Self {
64        self.fixed_today = today;
65        self
66    }
67
68    pub fn with_fixed_local_offset_minutes(mut self, offset_minutes: Option<i32>) -> Self {
69        self.fixed_local_offset_minutes = offset_minutes;
70        self
71    }
72
73    pub fn with_max_source_bytes(mut self, max_source_bytes: Option<usize>) -> Self {
74        self.max_source_bytes = max_source_bytes;
75        self
76    }
77
78    pub fn with_rule_config(mut self, rule_config: AnalysisRuleConfig) -> Self {
79        self.rule_config = rule_config;
80        self
81    }
82
83    pub fn snapshot_affecting_eq(&self, other: &Self) -> bool {
84        self.parse == other.parse
85            && self.site_config == other.site_config
86            && self.fixed_today == other.fixed_today
87            && self.fixed_local_offset_minutes == other.fixed_local_offset_minutes
88            && self.max_source_bytes == other.max_source_bytes
89            && self.source == other.source
90    }
91}
92
93#[derive(Debug, Clone)]
94pub struct Analyzer {
95    engine: Engine,
96    options: AnalysisOptions,
97}
98
99impl Default for Analyzer {
100    fn default() -> Self {
101        Self::new()
102    }
103}
104
105impl Analyzer {
106    pub fn new() -> Self {
107        Self::with_options(AnalysisOptions::default())
108    }
109
110    pub fn with_options(options: AnalysisOptions) -> Self {
111        let engine = engine_from_options(&options);
112        Self { engine, options }
113    }
114
115    pub fn with_engine_and_options(engine: Engine, options: AnalysisOptions) -> Self {
116        Self { engine, options }
117    }
118
119    pub fn options(&self) -> &AnalysisOptions {
120        &self.options
121    }
122
123    pub fn analyze_result(&self, source: &str) -> AnalysisResult {
124        if let Some(result) = self.source_limit_result(source, self.options.source.clone()) {
125            return result;
126        }
127
128        let source_text = Arc::<str>::from(source);
129        let source_map = SourceMap::new(Arc::clone(&source_text));
130        let diagram = crate::document::whole_document_diagram(source_text, &self.options.source);
131        let analyzed = self.analyze_diagram(&diagram);
132        AnalysisResult::new(
133            self.options.source.clone(),
134            source_map,
135            analyzed.diagnostics.clone(),
136            vec![analyzed],
137        )
138    }
139
140    pub fn analyze(&self, source: &str) -> AnalysisPayload {
141        if let Some(result) = self.source_limit_result(source, self.options.source.clone()) {
142            return result.into_payload();
143        }
144
145        AnalysisPayload::new(
146            self.options.source.clone(),
147            self.analyze_source_diagnostics(source),
148        )
149    }
150
151    pub fn analyze_facts(&self, source: &str) -> crate::AnalysisFactsPayload {
152        self.analyze_result(source).to_facts_payload()
153    }
154
155    pub fn analyze_json(&self, source: &str) -> Result<Vec<u8>, serde_json::Error> {
156        self.analyze(source).to_json_bytes()
157    }
158
159    pub fn analyze_facts_json(&self, source: &str) -> Result<Vec<u8>, serde_json::Error> {
160        self.analyze_facts(source).to_json_bytes()
161    }
162
163    pub(crate) fn analyze_diagram(&self, diagram: &DocumentDiagram) -> AnalyzedDiagram {
164        let local = self.analyze_local(&diagram.text, AnalysisMode::RichFacts);
165        AnalyzedDiagram::from_document_diagram(diagram, local.diagnostics, local.syntax)
166    }
167
168    pub(crate) fn analyze_diagram_diagnostics(
169        &self,
170        diagram: &DocumentDiagram,
171    ) -> Vec<AnalysisDiagnostic> {
172        self.analyze_source_diagnostics(&diagram.text)
173    }
174
175    pub(crate) fn analyze_source_diagnostics(&self, source: &str) -> Vec<AnalysisDiagnostic> {
176        self.analyze_local(source, AnalysisMode::Diagnostics)
177            .diagnostics
178    }
179
180    fn analyze_local(&self, source: &str, mode: AnalysisMode) -> LocalAnalysis {
181        if let Some(diagnostics) = self.source_limit_diagnostics(source) {
182            return LocalAnalysis::empty_syntax(diagnostics);
183        }
184
185        let source_map = SourceMap::new(source);
186
187        if source.trim().is_empty() {
188            let diagnostics = no_diagram_diagnostic(&source_map, &self.options.rule_config)
189                .into_iter()
190                .collect();
191            return mode.text_scan_or_empty(source, None, diagnostics);
192        }
193
194        let source_lints =
195            crate::rules::source_lint_diagnostics(source, &source_map, &self.options.rule_config);
196
197        let parse_result = panic::catch_unwind(AssertUnwindSafe(|| match mode {
198            AnalysisMode::Diagnostics => self
199                .engine
200                .parse_diagram_sync(source, self.options.parse)
201                .map(|parsed| parsed.map(ParsedAnalysisDiagram::Diagnostics)),
202            AnalysisMode::RichFacts => self
203                .engine
204                .parse_diagram_with_editor_facts_sync(source, self.options.parse)
205                .map(|parsed| parsed.map(ParsedAnalysisDiagram::RichFacts)),
206        }));
207
208        match parse_result {
209            Err(panic_payload) => {
210                let mut diagnostics = source_lints;
211                if let Some(diagnostic) =
212                    panic_diagnostic(panic_payload, &source_map, &self.options.rule_config)
213                {
214                    diagnostics.push(diagnostic);
215                }
216                mode.text_scan_or_empty(source, None, diagnostics)
217            }
218            Ok(parse_result) => match parse_result {
219                Ok(Some(parsed)) => {
220                    self.analyze_parsed_diagram(source, &source_map, parsed, source_lints, mode)
221                }
222                Ok(None) => mode.text_scan_or_empty(source, None, source_lints),
223                Err(error) => {
224                    self.analyze_parse_error(source, &source_map, source_lints, error, mode)
225                }
226            },
227        }
228    }
229
230    fn analyze_parsed_diagram(
231        &self,
232        source: &str,
233        source_map: &SourceMap,
234        parsed: ParsedAnalysisDiagram,
235        mut diagnostics: Vec<AnalysisDiagnostic>,
236        mode: AnalysisMode,
237    ) -> LocalAnalysis {
238        let (parsed, precomputed_editor_facts) = parsed.into_parts();
239        let diagram_type = parsed.meta.diagram_type;
240        diagnostics.extend(crate::rules::parsed_source_lint_diagnostics(
241            source,
242            source_map,
243            &self.options.rule_config,
244            &diagram_type,
245        ));
246        diagnostics.extend(crate::rules::semantic_warning_diagnostics(
247            &diagram_type,
248            &parsed.model,
249            source_map,
250            &self.options.rule_config,
251        ));
252
253        match mode {
254            AnalysisMode::Diagnostics => {
255                LocalAnalysis::empty_syntax_with_type(Some(diagram_type), diagnostics)
256            }
257            AnalysisMode::RichFacts => {
258                let flowchart_projection =
259                    self.flowchart_facts_projection(&parsed.model, &diagram_type, source_map);
260                diagnostics.extend(flowchart_projection.diagnostics);
261                let editor_projection = match precomputed_editor_facts {
262                    Some(ParsedEditorFacts::Available(facts)) => self
263                        .editor_facts_projection_from_facts(
264                            source,
265                            &diagram_type,
266                            source_map,
267                            mode,
268                            Some(facts),
269                        ),
270                    Some(ParsedEditorFacts::Unavailable) => self
271                        .editor_facts_projection_from_facts(
272                            source,
273                            &diagram_type,
274                            source_map,
275                            mode,
276                            None,
277                        ),
278                    Some(ParsedEditorFacts::Error(error)) => self
279                        .editor_facts_projection_from_error(
280                            source,
281                            &diagram_type,
282                            source_map,
283                            mode,
284                            error,
285                        ),
286                    None => self.editor_facts_projection(source, &diagram_type, source_map, mode),
287                };
288                diagnostics.extend(
289                    editor_projection
290                        .diagnostics
291                        .into_iter()
292                        .map(|recovery| recovery.diagnostic),
293                );
294                LocalAnalysis {
295                    diagnostics,
296                    syntax: AnalysisSyntaxFacts::new(
297                        Some(diagram_type),
298                        editor_projection.text_index,
299                    )
300                    .with_flowchart(flowchart_projection.facts),
301                }
302            }
303        }
304    }
305
306    fn analyze_parse_error(
307        &self,
308        source: &str,
309        source_map: &SourceMap,
310        mut diagnostics: Vec<AnalysisDiagnostic>,
311        error: CoreError,
312        mode: AnalysisMode,
313    ) -> LocalAnalysis {
314        let core_diagnostic = core_error_diagnostic(error, source_map, &self.options.rule_config);
315        if let Some(diagnostic) = core_diagnostic.diagnostic {
316            diagnostics.push(diagnostic);
317        }
318        let syntax = match core_diagnostic.diagram_type {
319            Some(diagram_type) => {
320                let editor_projection =
321                    self.editor_facts_projection(source, &diagram_type, source_map, mode);
322                merge_recovery_diagnostics(
323                    &mut diagnostics,
324                    editor_projection.diagnostics,
325                    core_diagnostic.parse_location,
326                );
327                AnalysisSyntaxFacts::new(Some(diagram_type), editor_projection.text_index)
328            }
329            None if mode == AnalysisMode::Diagnostics => {
330                AnalysisSyntaxFacts::new(None, FenceTextIndex::default())
331            }
332            None => AnalysisSyntaxFacts::text_scan(source, None),
333        };
334        LocalAnalysis {
335            diagnostics,
336            syntax,
337        }
338    }
339
340    fn editor_facts_projection(
341        &self,
342        source: &str,
343        diagram_type: &str,
344        source_map: &SourceMap,
345        mode: AnalysisMode,
346    ) -> EditorFactsProjection {
347        match self.parse_editor_semantic_facts(source, diagram_type, source_map) {
348            Err(diagnostics) => {
349                EditorFactsProjection::fallback(source, Some(diagram_type), diagnostics, mode)
350            }
351            Ok(Some(facts)) => self.editor_facts_projection_from_facts(
352                source,
353                diagram_type,
354                source_map,
355                mode,
356                Some(facts),
357            ),
358            Ok(None) => self.editor_facts_projection_from_facts(
359                source,
360                diagram_type,
361                source_map,
362                mode,
363                None,
364            ),
365        }
366    }
367
368    fn editor_facts_projection_from_facts(
369        &self,
370        source: &str,
371        diagram_type: &str,
372        source_map: &SourceMap,
373        mode: AnalysisMode,
374        facts: Option<EditorSemanticFacts>,
375    ) -> EditorFactsProjection {
376        let Some(facts) = facts else {
377            return EditorFactsProjection::fallback(source, Some(diagram_type), Vec::new(), mode);
378        };
379
380        let source_mapped_spans = facts.span_coordinate_space.is_original_source();
381        let diagnostics = editor_recovery_diagnostics(
382            facts.diagnostics.iter().cloned(),
383            diagram_type,
384            source_map,
385            &self.options.rule_config,
386            source_mapped_spans,
387        );
388        EditorFactsProjection {
389            text_index: match mode {
390                AnalysisMode::Diagnostics => FenceTextIndex::default(),
391                AnalysisMode::RichFacts => FenceTextIndex::from_core_facts(facts),
392            },
393            diagnostics,
394        }
395    }
396
397    fn editor_facts_projection_from_error(
398        &self,
399        source: &str,
400        diagram_type: &str,
401        source_map: &SourceMap,
402        mode: AnalysisMode,
403        error: CoreError,
404    ) -> EditorFactsProjection {
405        if matches!(error, CoreError::UnsupportedDiagram { .. }) {
406            return EditorFactsProjection::fallback(source, Some(diagram_type), Vec::new(), mode);
407        }
408
409        let diagnostics =
410            core_error_recovery_diagnostics(error, source_map, &self.options.rule_config);
411        EditorFactsProjection::fallback(source, Some(diagram_type), diagnostics, mode)
412    }
413
414    fn flowchart_facts_projection(
415        &self,
416        model: &serde_json::Value,
417        diagram_type: &str,
418        source_map: &SourceMap,
419    ) -> FlowchartFactsProjection {
420        match AnalysisFlowchartFacts::try_from_model(model) {
421            Ok(facts) => FlowchartFactsProjection {
422                facts,
423                diagnostics: Vec::new(),
424            },
425            Err(error) => FlowchartFactsProjection {
426                facts: None,
427                diagnostics: flowchart_facts_projection_diagnostic(
428                    error,
429                    diagram_type,
430                    source_map,
431                    &self.options.rule_config,
432                )
433                .into_iter()
434                .collect(),
435            },
436        }
437    }
438
439    fn parse_editor_semantic_facts(
440        &self,
441        source: &str,
442        diagram_type: &str,
443        source_map: &SourceMap,
444    ) -> Result<Option<EditorSemanticFacts>, Vec<AnalysisRecoveryDiagnostic>> {
445        let facts_result = panic::catch_unwind(AssertUnwindSafe(|| {
446            self.engine.parse_editor_semantic_facts_with_type_sync(
447                diagram_type,
448                source,
449                self.options.parse,
450            )
451        }));
452
453        match facts_result {
454            Err(panic_payload) => {
455                Err(
456                    panic_diagnostic(panic_payload, source_map, &self.options.rule_config)
457                        .map(AnalysisRecoveryDiagnostic::plain)
458                        .into_iter()
459                        .collect(),
460                )
461            }
462            Ok(Ok(facts)) => Ok(facts),
463            Ok(Err(CoreError::UnsupportedDiagram { .. })) => Ok(None),
464            Ok(Err(error)) => Err(core_error_recovery_diagnostics(
465                error,
466                source_map,
467                &self.options.rule_config,
468            )),
469        }
470    }
471
472    pub(crate) fn source_limit_result(
473        &self,
474        source: &str,
475        descriptor: SourceDescriptor,
476    ) -> Option<AnalysisResult> {
477        crate::source_limits::source_limit_result(source, descriptor, self.options.max_source_bytes)
478    }
479
480    fn source_limit_diagnostics(&self, source: &str) -> Option<Vec<AnalysisDiagnostic>> {
481        crate::source_limits::source_limit_diagnostics(source, self.options.max_source_bytes)
482    }
483}
484
485#[derive(Debug, Clone, Copy, PartialEq, Eq)]
486enum AnalysisMode {
487    Diagnostics,
488    RichFacts,
489}
490
491enum ParsedAnalysisDiagram {
492    Diagnostics(ParsedDiagram),
493    RichFacts(ParsedDiagramWithEditorFacts),
494}
495
496impl ParsedAnalysisDiagram {
497    fn into_parts(self) -> (ParsedDiagram, Option<ParsedEditorFacts>) {
498        match self {
499            Self::Diagnostics(parsed) => (parsed, None),
500            Self::RichFacts(parsed) => (parsed.diagram, Some(parsed.editor_facts)),
501        }
502    }
503}
504
505impl AnalysisMode {
506    fn text_scan_or_empty(
507        self,
508        source: &str,
509        diagram_type: Option<String>,
510        diagnostics: Vec<AnalysisDiagnostic>,
511    ) -> LocalAnalysis {
512        match self {
513            Self::Diagnostics => LocalAnalysis::empty_syntax_with_type(diagram_type, diagnostics),
514            Self::RichFacts => LocalAnalysis::text_scan(source, diagram_type, diagnostics),
515        }
516    }
517}
518
519#[derive(Debug, Clone)]
520struct LocalAnalysis {
521    diagnostics: Vec<AnalysisDiagnostic>,
522    syntax: AnalysisSyntaxFacts,
523}
524
525impl LocalAnalysis {
526    fn empty_syntax(diagnostics: Vec<AnalysisDiagnostic>) -> Self {
527        Self::empty_syntax_with_type(None, diagnostics)
528    }
529
530    fn empty_syntax_with_type(
531        diagram_type: Option<String>,
532        diagnostics: Vec<AnalysisDiagnostic>,
533    ) -> Self {
534        Self {
535            diagnostics,
536            syntax: AnalysisSyntaxFacts::new(diagram_type, FenceTextIndex::default()),
537        }
538    }
539
540    fn text_scan(
541        source: &str,
542        diagram_type: Option<String>,
543        diagnostics: Vec<AnalysisDiagnostic>,
544    ) -> Self {
545        Self {
546            diagnostics,
547            syntax: AnalysisSyntaxFacts::text_scan(source, diagram_type),
548        }
549    }
550}
551
552#[derive(Debug, Clone)]
553struct EditorFactsProjection {
554    text_index: FenceTextIndex,
555    diagnostics: Vec<AnalysisRecoveryDiagnostic>,
556}
557
558impl EditorFactsProjection {
559    fn fallback(
560        source: &str,
561        diagram_type: Option<&str>,
562        diagnostics: Vec<AnalysisRecoveryDiagnostic>,
563        mode: AnalysisMode,
564    ) -> Self {
565        Self {
566            text_index: match mode {
567                AnalysisMode::Diagnostics => FenceTextIndex::default(),
568                AnalysisMode::RichFacts => FenceTextIndex::from_text(source, diagram_type),
569            },
570            diagnostics,
571        }
572    }
573}
574
575#[derive(Debug, Clone)]
576struct FlowchartFactsProjection {
577    facts: Option<AnalysisFlowchartFacts>,
578    diagnostics: Vec<AnalysisDiagnostic>,
579}
580
581pub fn engine_from_options(options: &AnalysisOptions) -> Engine {
582    let mut engine = Engine::new()
583        .with_fixed_today(options.fixed_today)
584        .with_fixed_local_offset_minutes(options.fixed_local_offset_minutes);
585
586    if let Some(site_config) = options.site_config.clone() {
587        engine = engine.with_site_config(site_config);
588    }
589
590    engine
591}
592
593#[cfg(test)]
594mod tests;