1use 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#[derive(Debug, Clone)]
21pub struct FileAnalysis {
22 pub url_matches: Vec<UrlMatch>,
24 pub scopes: Vec<ScopeAnalysis>,
26}
27
28#[derive(Debug, Clone)]
30pub struct ScopeAnalysis {
31 pub url_match: UrlMatch,
32 pub validations: Vec<StepValidation>,
33 pub coverage: Option<CoverageResult>,
34}
35
36#[derive(Debug, Serialize)]
40pub struct FileAnalysisView {
41 pub scopes: Vec<ScopeAnalysisView>,
42}
43
44#[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#[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#[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
124pub trait SpecResolver {
131 fn resolve(&self, spec: &str, anchor: &str) -> Option<String>;
133}
134
135pub 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 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}