Skip to main content

webspec_index/analyze/
file.rs

1//! High-level file analysis: scan, scope, validate, and compute coverage.
2//!
3//! Returns rich domain types ([`FileAnalysis`], [`ScopeAnalysis`]) suitable for
4//! both the LSP server and the CLI.  Serializable "view" types are provided for
5//! JSON / searchfox output via [`FileAnalysisView`].
6
7use serde::Serialize;
8
9use super::coverage::{compute_coverage, CoverageResult, StepValidation};
10use super::matcher::{classify_match, MatchResult};
11use super::scanner::{
12    build_scopes, build_spec_lookup, build_url_pattern, scan_document, scan_steps, SpecUrl,
13    UrlMatch,
14};
15use super::steps::{find_step, parse_steps};
16
17// ── Rich domain types (used by LSP + CLI) ────────────────────────────
18
19/// Result of analyzing a single source file.
20#[derive(Debug, Clone)]
21pub struct FileAnalysis {
22    /// All spec URL matches found in the file (for position lookups).
23    pub url_matches: Vec<UrlMatch>,
24    /// Per-scope analysis results.
25    pub scopes: Vec<ScopeAnalysis>,
26}
27
28/// Analysis of a single spec scope within a file.
29#[derive(Debug, Clone)]
30pub struct ScopeAnalysis {
31    pub url_match: UrlMatch,
32    pub validations: Vec<StepValidation>,
33    pub coverage: Option<CoverageResult>,
34}
35
36// ── Serializable view types (for JSON / searchfox output) ────────────
37
38/// Serializable view of [`FileAnalysis`].
39#[derive(Debug, Serialize)]
40pub struct FileAnalysisView {
41    pub scopes: Vec<ScopeAnalysisView>,
42}
43
44/// Serializable view of [`ScopeAnalysis`].
45#[derive(Debug, Serialize)]
46pub struct ScopeAnalysisView {
47    pub spec: String,
48    pub anchor: String,
49    pub url: String,
50    pub line: usize,
51    pub col: usize,
52    pub validations: Vec<StepAnalysisView>,
53    pub coverage: Option<CoverageSummary>,
54}
55
56/// Serializable view of a single step validation.
57#[derive(Debug, Serialize)]
58pub struct StepAnalysisView {
59    pub line: usize,
60    pub col: usize,
61    pub step: Vec<u32>,
62    pub comment_text: String,
63    pub result: String,
64    pub spec_text: String,
65}
66
67/// Coverage summary for a scope.
68#[derive(Debug, Serialize)]
69pub struct CoverageSummary {
70    pub total: usize,
71    pub implemented: usize,
72    pub missing: Vec<Vec<u32>>,
73    pub warnings: usize,
74    pub reordered: usize,
75}
76
77impl From<&CoverageResult> for CoverageSummary {
78    fn from(cr: &CoverageResult) -> Self {
79        CoverageSummary {
80            total: cr.total_steps,
81            implemented: cr.implemented_count(),
82            missing: cr.missing.clone(),
83            warnings: cr.warnings,
84            reordered: cr.reordered,
85        }
86    }
87}
88
89impl From<&FileAnalysis> for FileAnalysisView {
90    fn from(fa: &FileAnalysis) -> Self {
91        FileAnalysisView {
92            scopes: fa.scopes.iter().map(ScopeAnalysisView::from).collect(),
93        }
94    }
95}
96
97impl From<&ScopeAnalysis> for ScopeAnalysisView {
98    fn from(sa: &ScopeAnalysis) -> Self {
99        ScopeAnalysisView {
100            spec: sa.url_match.spec.clone(),
101            anchor: sa.url_match.anchor.clone(),
102            url: sa.url_match.url.clone(),
103            line: sa.url_match.line,
104            col: sa.url_match.indent,
105            validations: sa.validations.iter().map(StepAnalysisView::from).collect(),
106            coverage: sa.coverage.as_ref().map(CoverageSummary::from),
107        }
108    }
109}
110
111impl From<&StepValidation> for StepAnalysisView {
112    fn from(sv: &StepValidation) -> Self {
113        StepAnalysisView {
114            line: sv.step.line,
115            col: sv.step.indent,
116            step: sv.step.number.clone(),
117            comment_text: sv.step.text.clone(),
118            result: sv.result.as_str().to_string(),
119            spec_text: sv.spec_text.clone(),
120        }
121    }
122}
123
124// ── Spec resolution trait ────────────────────────────────────────────
125
126/// Resolve a spec section's algorithm content.
127///
128/// Implementors provide access to spec data — either from a database or from
129/// pre-loaded JSON fixtures.
130pub trait SpecResolver {
131    /// Return the algorithm markdown content for a spec section, if available.
132    fn resolve(&self, spec: &str, anchor: &str) -> Option<String>;
133}
134
135// ── Core analysis ────────────────────────────────────────────────────
136
137/// Analyze a source file against spec data.
138///
139/// Scans `text` for spec URLs and step comments, builds indentation-based
140/// scopes, validates each step against the spec algorithm, and computes
141/// coverage metrics.
142pub fn analyze_file(
143    text: &str,
144    spec_urls: &[SpecUrl],
145    resolver: &dyn SpecResolver,
146    threshold: f64,
147) -> FileAnalysis {
148    let pattern = build_url_pattern(spec_urls);
149    let spec_lookup = build_spec_lookup(spec_urls);
150    let url_matches = scan_document(text, &pattern, &spec_lookup);
151    let step_comments = scan_steps(text);
152    let scopes = build_scopes(text, &url_matches, &step_comments);
153
154    let mut scope_results = Vec::new();
155
156    for (url_match, steps_in_scope) in &scopes {
157        let content = resolver.resolve(&url_match.spec, &url_match.anchor);
158
159        let algo_steps = content
160            .as_deref()
161            .filter(|c| !c.is_empty())
162            .map(parse_steps);
163
164        let mut validations = Vec::new();
165
166        for sc in steps_in_scope {
167            let (match_result, spec_text) = if let Some(ref steps) = algo_steps {
168                if let Some(ss) = find_step(steps, &sc.number) {
169                    (
170                        classify_match(&sc.text, &ss.text, threshold),
171                        ss.text.clone(),
172                    )
173                } else {
174                    (MatchResult::NotFound, String::new())
175                }
176            } else {
177                // No algorithm content available — can't validate.
178                continue;
179            };
180
181            validations.push(StepValidation {
182                step: sc.clone(),
183                result: match_result,
184                spec_text,
185                algo_anchor: url_match.anchor.clone(),
186            });
187        }
188
189        let coverage = algo_steps
190            .as_deref()
191            .map(|steps| compute_coverage(&validations, steps, &url_match.anchor));
192
193        scope_results.push(ScopeAnalysis {
194            url_match: url_match.clone(),
195            validations,
196            coverage,
197        });
198    }
199
200    FileAnalysis {
201        url_matches,
202        scopes: scope_results,
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    struct FakeResolver {
211        sections: std::collections::HashMap<(String, String), String>,
212    }
213
214    impl FakeResolver {
215        fn new() -> Self {
216            let mut sections = std::collections::HashMap::new();
217            sections.insert(
218                ("HTML".to_string(), "navigate".to_string()),
219                "1. Let *cspNavigationType* be \"`form-submission`\" if *formDataEntryList* is non-null; otherwise \"`other`\".\n\
220                 2. Let *sourceSnapshotParams* be the result of snapshotting source snapshot params given *sourceDocument*.\n\
221                 3. If *url* is `about:blank`, then return.".to_string(),
222            );
223            FakeResolver { sections }
224        }
225    }
226
227    impl SpecResolver for FakeResolver {
228        fn resolve(&self, spec: &str, anchor: &str) -> Option<String> {
229            self.sections
230                .get(&(spec.to_string(), anchor.to_string()))
231                .cloned()
232        }
233    }
234
235    fn spec_urls() -> Vec<SpecUrl> {
236        vec![SpecUrl {
237            spec: "HTML".into(),
238            base_url: "https://html.spec.whatwg.org".into(),
239        }]
240    }
241
242    #[test]
243    fn analyze_simple_file() {
244        let text = "\
245// https://html.spec.whatwg.org/#navigate
246void DoNavigate() {
247  // Step 1. Let cspNavigationType be form-submission
248  auto csp = GetCSPNavType();
249
250  // Step 2. Let sourceSnapshotParams be the result of snapshotting
251  auto params = Snapshot();
252
253  // Step 3. If url is about:blank, then return
254  if (IsAboutBlank(url)) {
255    return;
256  }
257}
258";
259        let result = analyze_file(text, &spec_urls(), &FakeResolver::new(), 0.85);
260        assert_eq!(result.scopes.len(), 1);
261
262        let scope = &result.scopes[0];
263        assert_eq!(scope.url_match.anchor, "navigate");
264        assert_eq!(scope.validations.len(), 3);
265
266        assert!(matches!(
267            scope.validations[0].result,
268            MatchResult::Fuzzy | MatchResult::Exact
269        ));
270        assert_ne!(scope.validations[2].result, MatchResult::NotFound);
271
272        let cov = scope.coverage.as_ref().unwrap();
273        assert_eq!(cov.total_steps, 3);
274        assert_eq!(cov.implemented_count(), 3);
275        assert!(cov.missing.is_empty());
276    }
277
278    #[test]
279    fn analyze_with_not_found_step() {
280        let text = "\
281// https://html.spec.whatwg.org/#navigate
282void DoNavigate() {
283  // Step 99. Nonexistent step
284  DoSomething();
285}
286";
287        let result = analyze_file(text, &spec_urls(), &FakeResolver::new(), 0.85);
288        assert_eq!(result.scopes[0].validations.len(), 1);
289        assert_eq!(
290            result.scopes[0].validations[0].result,
291            MatchResult::NotFound
292        );
293        assert_eq!(result.scopes[0].coverage.as_ref().unwrap().warnings, 1);
294    }
295
296    #[test]
297    fn analyze_no_spec_urls() {
298        let text = "void foo() { code(); }";
299        let result = analyze_file(text, &spec_urls(), &FakeResolver::new(), 0.85);
300        assert!(result.scopes.is_empty());
301    }
302
303    #[test]
304    fn analyze_unknown_section() {
305        let text = "\
306// https://html.spec.whatwg.org/#nonexistent-section
307void foo() {
308  // Step 1. Something
309  code();
310}
311";
312        let result = analyze_file(text, &spec_urls(), &FakeResolver::new(), 0.85);
313        assert_eq!(result.scopes.len(), 1);
314        assert!(result.scopes[0].validations.is_empty());
315        assert!(result.scopes[0].coverage.is_none());
316    }
317
318    #[test]
319    fn analyze_scoping_isolates_functions() {
320        let text = "\
321class Foo {
322  // https://html.spec.whatwg.org/#navigate
323  void navigate() {
324    // Step 1. Let cspNavigationType be form-submission
325    code();
326  }
327
328  void other() {
329    // Step 2. This should NOT be in navigate scope
330    other_code();
331  }
332}
333";
334        let result = analyze_file(text, &spec_urls(), &FakeResolver::new(), 0.85);
335        assert_eq!(result.scopes.len(), 1);
336        assert_eq!(result.scopes[0].validations.len(), 1);
337        assert_eq!(result.scopes[0].validations[0].step.number, vec![1]);
338    }
339
340    #[test]
341    fn view_roundtrip() {
342        let text = "\
343// https://html.spec.whatwg.org/#navigate
344void foo() {
345  // Step 1. Let cspNavigationType be form-submission
346  code();
347}
348";
349        let result = analyze_file(text, &spec_urls(), &FakeResolver::new(), 0.85);
350        let view = FileAnalysisView::from(&result);
351        assert_eq!(view.scopes.len(), 1);
352        assert_eq!(view.scopes[0].anchor, "navigate");
353        assert_eq!(view.scopes[0].validations.len(), 1);
354        assert!(
355            view.scopes[0].validations[0].result == "fuzzy"
356                || view.scopes[0].validations[0].result == "exact"
357        );
358    }
359}