1use std::collections::BTreeSet;
2
3use crate::source_http::SourceObservationCollector;
4use crate::{
5 ExtractionLimitExceeded, ExtractionTracker, SourceEpistemicStatus, SourceFramework, SourceLanguage, SourceLineRange, SourceObservation, SourceRole, SourceWarning, normalize_source_http_path
6};
7
8const METHODS: [(&str, &str); 8] = [
9 ("delete", "DELETE"),
10 ("get", "GET"),
11 ("head", "HEAD"),
12 ("options", "OPTIONS"),
13 ("patch", "PATCH"),
14 ("post", "POST"),
15 ("put", "PUT"),
16 ("trace", "TRACE"),
17];
18
19#[must_use]
21pub fn parse_javascript_source(source: &str) -> Vec<SourceObservation> {
22 parse_ecmascript(source, SourceLanguage::JavaScript)
23}
24
25#[must_use]
27pub fn parse_javascript_source_at_path(source_path: &str, source: &str) -> Vec<SourceObservation> {
28 let observations = collect_ecmascript_at_path(
29 source_path,
30 source,
31 SourceLanguage::JavaScript,
32 SourceObservationCollector::unbounded(),
33 );
34 finish(observations.into_unbounded())
35}
36
37pub fn parse_javascript_source_at_path_with_tracker(
44 source_path: &str,
45 source: &str,
46 tracker: &mut ExtractionTracker,
47) -> Result<Vec<SourceObservation>, ExtractionLimitExceeded> {
48 let observations = collect_ecmascript_at_path(
49 source_path,
50 source,
51 SourceLanguage::JavaScript,
52 SourceObservationCollector::bounded(tracker),
53 );
54 Ok(finish(observations.into_result()?))
55}
56
57#[must_use]
59pub fn parse_typescript_source(source: &str) -> Vec<SourceObservation> {
60 parse_ecmascript(source, SourceLanguage::TypeScript)
61}
62
63#[must_use]
65pub fn parse_typescript_source_at_path(source_path: &str, source: &str) -> Vec<SourceObservation> {
66 let observations = collect_ecmascript_at_path(
67 source_path,
68 source,
69 SourceLanguage::TypeScript,
70 SourceObservationCollector::unbounded(),
71 );
72 finish(observations.into_unbounded())
73}
74
75pub fn parse_typescript_source_at_path_with_tracker(
82 source_path: &str,
83 source: &str,
84 tracker: &mut ExtractionTracker,
85) -> Result<Vec<SourceObservation>, ExtractionLimitExceeded> {
86 let observations = collect_ecmascript_at_path(
87 source_path,
88 source,
89 SourceLanguage::TypeScript,
90 SourceObservationCollector::bounded(tracker),
91 );
92 Ok(finish(observations.into_result()?))
93}
94
95#[must_use]
97pub fn parse_go_source(source: &str) -> Vec<SourceObservation> {
98 let observations = collect_go_source(source, SourceObservationCollector::unbounded());
99 finish(observations.into_unbounded())
100}
101
102pub fn parse_go_source_with_tracker(
109 source: &str,
110 tracker: &mut ExtractionTracker,
111) -> Result<Vec<SourceObservation>, ExtractionLimitExceeded> {
112 let observations = collect_go_source(source, SourceObservationCollector::bounded(tracker));
113 Ok(finish(observations.into_result()?))
114}
115
116fn collect_go_source<'a>(
117 source: &str,
118 mut observations: SourceObservationCollector<'a>,
119) -> SourceObservationCollector<'a> {
120 let has_http = source.contains("\"net/http\"");
121 let has_gin = source.contains("github.com/gin-gonic/gin");
122 let has_chi = source.contains("github.com/go-chi/chi");
123 for statement in statements(source) {
124 if has_http {
125 if let Some(method) = method_call(&statement.text, "http.")
126 && matches!(method.as_str(), "GET" | "POST")
127 {
128 observations.push(http_observation(
129 SourceLanguage::Go,
130 SourceFramework::GoNetHttp,
131 SourceRole::Consumer,
132 Some(method),
133 first_literal_after_call(&statement.text),
134 None,
135 statement.lines,
136 ));
137 }
138 if statement.text.contains("http.NewRequest(") {
139 observations.push(http_observation(
140 SourceLanguage::Go,
141 SourceFramework::GoNetHttp,
142 SourceRole::Consumer,
143 literal_method_argument(&statement.text),
144 nth_literal(&statement.text, 2),
145 None,
146 statement.lines,
147 ));
148 }
149 if statement.text.contains("http.HandleFunc(") {
150 observations.push(incomplete_observation(
151 SourceLanguage::Go,
152 SourceFramework::GoNetHttp,
153 SourceRole::Provider,
154 None,
155 first_literal_after_call(&statement.text)
156 .as_deref()
157 .and_then(literal_path),
158 argument_identifier(&statement.text, 2),
159 statement.lines,
160 SourceWarning::DynamicMethod,
161 ));
162 }
163 }
164 if has_gin {
165 append_receiver_route(
166 &mut observations,
167 SourceLanguage::Go,
168 SourceFramework::Gin,
169 &statement,
170 true,
171 );
172 }
173 if has_chi {
174 append_receiver_route(
175 &mut observations,
176 SourceLanguage::Go,
177 SourceFramework::Chi,
178 &statement,
179 false,
180 );
181 }
182 }
183 observations
184}
185
186#[must_use]
188pub fn parse_java_source(source: &str) -> Vec<SourceObservation> {
189 let observations = collect_java_source(source, SourceObservationCollector::unbounded());
190 finish(observations.into_unbounded())
191}
192
193pub fn parse_java_source_with_tracker(
200 source: &str,
201 tracker: &mut ExtractionTracker,
202) -> Result<Vec<SourceObservation>, ExtractionLimitExceeded> {
203 let observations = collect_java_source(source, SourceObservationCollector::bounded(tracker));
204 Ok(finish(observations.into_result()?))
205}
206
207fn collect_java_source<'a>(
208 source: &str,
209 mut observations: SourceObservationCollector<'a>,
210) -> SourceObservationCollector<'a> {
211 let spring = source.contains("org.springframework.web.bind.annotation");
212 let web_client = source.contains("org.springframework.web.reactive.function.client.WebClient");
213 let feign = source.contains("@FeignClient") || source.contains("openfeign.FeignClient");
214 let lines = source.lines().collect::<Vec<_>>();
215 for (index, line) in lines.iter().enumerate() {
216 let trimmed = line.trim();
217 if spring
218 && trimmed.starts_with('@')
219 && let Some((method, path)) = java_mapping(trimmed)
220 {
221 let symbol = lines
222 .iter()
223 .skip(index + 1)
224 .take(5)
225 .find_map(|candidate| java_method_name(candidate));
226 observations.push(http_observation(
227 SourceLanguage::Java,
228 if feign {
229 SourceFramework::Feign
230 } else {
231 SourceFramework::SpringMvc
232 },
233 if feign {
234 SourceRole::Consumer
235 } else {
236 SourceRole::Provider
237 },
238 method,
239 path,
240 symbol,
241 SourceLineRange {
242 start: line_number(index),
243 end: line_number(index),
244 },
245 ));
246 }
247 }
248 if web_client {
249 for statement in statements(source) {
250 if !statement.text.contains(".uri(") {
251 continue;
252 }
253 let method = METHODS.iter().find_map(|(name, method)| {
254 statement
255 .text
256 .contains(&format!(".{name}()"))
257 .then_some((*method).to_owned())
258 });
259 observations.push(http_observation(
260 SourceLanguage::Java,
261 SourceFramework::WebClient,
262 SourceRole::Consumer,
263 method,
264 literal_after(&statement.text, ".uri("),
265 None,
266 statement.lines,
267 ));
268 }
269 }
270 observations
271}
272
273fn parse_ecmascript(source: &str, language: SourceLanguage) -> Vec<SourceObservation> {
274 let observations = collect_ecmascript_at_path(
275 "",
276 source,
277 language,
278 SourceObservationCollector::unbounded(),
279 );
280 finish(observations.into_unbounded())
281}
282
283fn collect_ecmascript_at_path<'a>(
284 source_path: &str,
285 source: &str,
286 language: SourceLanguage,
287 mut observations: SourceObservationCollector<'a>,
288) -> SourceObservationCollector<'a> {
289 let express = source.contains("from \"express\"")
290 || source.contains("from 'express'")
291 || source.contains("require(\"express\")")
292 || source.contains("require('express')");
293 let fastify = source.contains("from \"fastify\"")
294 || source.contains("from 'fastify'")
295 || source.contains("require(\"fastify\")")
296 || source.contains("require('fastify')");
297 let axios = source.contains("from \"axios\"")
298 || source.contains("from 'axios'")
299 || source.contains("require(\"axios\")")
300 || source.contains("require('axios')");
301 let nest = source.contains("@nestjs/common");
302 let express_receivers = assigned_receivers(source, "express");
303 let fastify_receivers = assigned_receivers(source, "fastify");
304 for statement in statements(source) {
305 if statement.text.contains("fetch(") {
306 let method = object_method(&statement.text).or_else(|| Some("GET".to_owned()));
307 observations.push(http_observation(
308 language,
309 SourceFramework::Fetch,
310 SourceRole::Consumer,
311 method,
312 literal_after(&statement.text, "fetch("),
313 None,
314 statement.lines,
315 ));
316 }
317 if axios && statement.text.contains("axios.") {
318 let method = method_call(&statement.text, "axios.");
319 if method.is_some() {
320 observations.push(http_observation(
321 language,
322 SourceFramework::Axios,
323 SourceRole::Consumer,
324 method,
325 first_literal_after_call(&statement.text),
326 None,
327 statement.lines,
328 ));
329 }
330 }
331 if express {
332 append_js_route(
333 &mut observations,
334 language,
335 SourceFramework::Express,
336 &statement,
337 &express_receivers,
338 );
339 }
340 if fastify {
341 append_js_route(
342 &mut observations,
343 language,
344 SourceFramework::Fastify,
345 &statement,
346 &fastify_receivers,
347 );
348 }
349 }
350 if nest {
351 let lines = source.lines().collect::<Vec<_>>();
352 for (index, line) in lines.iter().enumerate() {
353 let Some((method, path)) = nest_mapping(line.trim()) else {
354 continue;
355 };
356 let symbol = lines
357 .iter()
358 .skip(index + 1)
359 .take(5)
360 .find_map(|candidate| ecmascript_method_name(candidate));
361 observations.push(http_observation(
362 language,
363 SourceFramework::NestJs,
364 SourceRole::Provider,
365 Some(method),
366 path,
367 symbol,
368 SourceLineRange {
369 start: line_number(index),
370 end: line_number(index),
371 },
372 ));
373 }
374 }
375 append_next_app_routes(&mut observations, source_path, source, language);
376 observations
377}
378
379fn append_next_app_routes(
380 observations: &mut SourceObservationCollector<'_>,
381 source_path: &str,
382 source: &str,
383 language: SourceLanguage,
384) {
385 let Some(path) = next_app_route_path(source_path) else {
386 return;
387 };
388 for (index, line) in source.lines().enumerate() {
389 let Some(method) = next_route_export_method(line) else {
390 continue;
391 };
392 observations.push(http_observation(
393 language,
394 SourceFramework::NextJs,
395 SourceRole::Provider,
396 Some(method.clone()),
397 Some(path.clone()),
398 Some(method),
399 SourceLineRange {
400 start: line_number(index),
401 end: line_number(index),
402 },
403 ));
404 }
405}
406
407fn next_app_route_path(source_path: &str) -> Option<String> {
408 let normalized = source_path.replace('\\', "/");
409 let components = normalized.split('/').collect::<Vec<_>>();
410 let route_file = components.last()?;
411 let stem = route_file
412 .rsplit_once('.')
413 .map_or(*route_file, |(stem, _)| stem);
414 if stem != "route" {
415 return None;
416 }
417 let app = components
418 .windows(2)
419 .position(|window| window == ["src", "app"])
420 .map(|index| index + 2)
421 .or_else(|| {
422 components
423 .iter()
424 .position(|component| *component == "app")
425 .map(|index| index + 1)
426 })?;
427 let segments = components[app..components.len().saturating_sub(1)]
428 .iter()
429 .filter(|component| !(component.starts_with('(') && component.ends_with(')')))
430 .map(|component| {
431 component
432 .strip_prefix("[[...")
433 .and_then(|value| value.strip_suffix("]]"))
434 .or_else(|| {
435 component
436 .strip_prefix("[...")
437 .and_then(|value| value.strip_suffix(']'))
438 })
439 .or_else(|| {
440 component
441 .strip_prefix('[')
442 .and_then(|value| value.strip_suffix(']'))
443 })
444 .map_or_else(|| (*component).to_owned(), |value| format!("{{{value}}}"))
445 })
446 .collect::<Vec<_>>();
447 Some(normalize_source_http_path(&segments.join("/")))
448}
449
450fn next_route_export_method(line: &str) -> Option<String> {
451 let trimmed = line.trim_start();
452 if !trimmed.starts_with("export ") {
453 return None;
454 }
455 let tokens = trimmed
456 .split(|character: char| character.is_whitespace() || matches!(character, '(' | ':' | '='))
457 .filter(|token| !token.is_empty())
458 .collect::<Vec<_>>();
459 let candidate = tokens
460 .windows(2)
461 .find_map(|window| (window[0] == "function").then_some(window[1]))
462 .or_else(|| {
463 tokens
464 .windows(2)
465 .find_map(|window| matches!(window[0], "const" | "let").then_some(window[1]))
466 })?;
467 METHODS
468 .iter()
469 .find_map(|(_, method)| (*method == candidate).then_some((*method).to_owned()))
470}
471
472#[derive(Debug)]
473struct Statement {
474 text: String,
475 lines: SourceLineRange,
476}
477
478fn statements(source: &str) -> Vec<Statement> {
479 let mut output = Vec::new();
480 let mut text = String::new();
481 let mut start = 1_u32;
482 let mut depth = 0_i32;
483 for (index, line) in source.lines().enumerate() {
484 let trimmed = line.split("//").next().unwrap_or_default().trim();
485 if trimmed.is_empty() {
486 continue;
487 }
488 if text.is_empty() {
489 start = line_number(index);
490 } else {
491 text.push(' ');
492 }
493 text.push_str(trimmed);
494 depth += delimiter_delta(trimmed);
495 if depth <= 0 || trimmed.ends_with(';') {
496 output.push(Statement {
497 text: std::mem::take(&mut text),
498 lines: SourceLineRange {
499 start,
500 end: line_number(index),
501 },
502 });
503 depth = 0;
504 }
505 }
506 if !text.is_empty() {
507 output.push(Statement {
508 text,
509 lines: SourceLineRange {
510 start,
511 end: u32::try_from(source.lines().count()).unwrap_or(u32::MAX),
512 },
513 });
514 }
515 output
516}
517
518fn delimiter_delta(line: &str) -> i32 {
519 line.chars().fold(0, |depth, character| match character {
520 '(' | '[' => depth + 1,
521 ')' | ']' => depth - 1,
522 _ => depth,
523 })
524}
525
526fn append_js_route(
527 output: &mut SourceObservationCollector<'_>,
528 language: SourceLanguage,
529 framework: SourceFramework,
530 statement: &Statement,
531 receivers: &BTreeSet<String>,
532) {
533 if !receivers
534 .iter()
535 .any(|receiver| statement.text.contains(&format!("{receiver}.")))
536 {
537 return;
538 }
539 let method = METHODS.iter().find_map(|(name, method)| {
540 statement
541 .text
542 .contains(&format!(".{name}("))
543 .then_some((*method).to_owned())
544 });
545 if method.is_none() {
546 return;
547 }
548 output.push(http_observation(
549 language,
550 framework,
551 SourceRole::Provider,
552 method,
553 first_literal_after_call(&statement.text),
554 argument_identifier(&statement.text, 2),
555 statement.lines,
556 ));
557}
558
559fn append_receiver_route(
560 output: &mut SourceObservationCollector<'_>,
561 language: SourceLanguage,
562 framework: SourceFramework,
563 statement: &Statement,
564 uppercase: bool,
565) {
566 let method = METHODS.iter().find_map(|(name, method)| {
567 let name = if uppercase {
568 name.to_ascii_uppercase()
569 } else {
570 let mut characters = name.chars();
571 characters.next().map_or_else(String::new, |first| {
572 first.to_ascii_uppercase().to_string() + characters.as_str()
573 })
574 };
575 statement
576 .text
577 .contains(&format!(".{name}("))
578 .then_some((*method).to_owned())
579 });
580 if method.is_none() {
581 return;
582 }
583 output.push(http_observation(
584 language,
585 framework,
586 SourceRole::Provider,
587 method,
588 first_literal_after_call(&statement.text),
589 argument_identifier(&statement.text, 2),
590 statement.lines,
591 ));
592}
593
594fn http_observation(
595 language: SourceLanguage,
596 framework: SourceFramework,
597 role: SourceRole,
598 method: Option<String>,
599 literal: Option<String>,
600 symbol_name: Option<String>,
601 lines: SourceLineRange,
602) -> SourceObservation {
603 let literal_missing = literal.is_none();
604 let path = literal.and_then(|value| literal_path(&value));
605 let mut warnings = Vec::new();
606 if method.is_none() {
607 warnings.push(SourceWarning::DynamicMethod);
608 }
609 if literal_missing {
610 warnings.push(SourceWarning::DynamicPath);
611 } else if path.is_none() {
612 warnings.push(SourceWarning::UnsupportedLiteralPath);
613 }
614 if role == SourceRole::Provider && symbol_name.is_none() {
615 warnings.push(SourceWarning::MissingSymbol);
616 }
617 let confirmed = method.is_some()
618 && path.is_some()
619 && (role != SourceRole::Provider || symbol_name.is_some());
620 SourceObservation {
621 language,
622 framework,
623 role,
624 method,
625 path,
626 symbol_name,
627 related_symbol: None,
628 related_path: None,
629 lines,
630 status: if confirmed {
631 SourceEpistemicStatus::Confirmed
632 } else {
633 SourceEpistemicStatus::Ambiguous
634 },
635 confidence: if confirmed { 1.0 } else { 0.0 },
636 warnings,
637 }
638}
639
640#[expect(
641 clippy::too_many_arguments,
642 reason = "Incomplete observations preserve every available direct coordinate"
643)]
644fn incomplete_observation(
645 language: SourceLanguage,
646 framework: SourceFramework,
647 role: SourceRole,
648 method: Option<String>,
649 path: Option<String>,
650 symbol_name: Option<String>,
651 lines: SourceLineRange,
652 warning: SourceWarning,
653) -> SourceObservation {
654 SourceObservation {
655 language,
656 framework,
657 role,
658 method,
659 path,
660 symbol_name,
661 related_symbol: None,
662 related_path: None,
663 lines,
664 status: SourceEpistemicStatus::Incomplete,
665 confidence: 0.0,
666 warnings: vec![warning],
667 }
668}
669
670fn method_call(text: &str, prefix: &str) -> Option<String> {
671 METHODS.iter().find_map(|(name, method)| {
672 text.contains(&format!("{prefix}{name}("))
673 .then_some((*method).to_owned())
674 .or_else(|| {
675 text.contains(&format!("{prefix}{}(", name.to_ascii_uppercase()))
676 .then_some((*method).to_owned())
677 })
678 })
679}
680
681fn first_literal_after_call(text: &str) -> Option<String> {
682 let open = text.find('(')?;
683 quoted_value(&text[open + 1..])
684}
685
686fn literal_after(text: &str, marker: &str) -> Option<String> {
687 let start = text.find(marker)? + marker.len();
688 quoted_value(&text[start..])
689}
690
691fn nth_literal(text: &str, target: usize) -> Option<String> {
692 quoted_values(text)
693 .into_iter()
694 .nth(target.saturating_sub(1))
695}
696
697fn quoted_value(text: &str) -> Option<String> {
698 quoted_values(text).into_iter().next()
699}
700
701fn quoted_values(text: &str) -> Vec<String> {
702 let mut values = Vec::new();
703 let mut quote = None;
704 let mut start = 0;
705 for (index, character) in text.char_indices() {
706 if let Some(active) = quote {
707 if character == active && !text[..index].ends_with('\\') {
708 values.push(text[start..index].to_owned());
709 quote = None;
710 }
711 } else if character == '\'' || character == '"' || character == '`' {
712 quote = Some(character);
713 start = index + character.len_utf8();
714 }
715 }
716 values
717}
718
719fn literal_method_argument(text: &str) -> Option<String> {
720 nth_literal(text, 1).and_then(|value| canonical_method(&value))
721}
722
723fn object_method(text: &str) -> Option<String> {
724 let start = text.find("method")? + "method".len();
725 quoted_value(&text[start..]).and_then(|method| canonical_method(&method))
726}
727
728fn assigned_receivers(source: &str, factory: &str) -> BTreeSet<String> {
729 source
730 .lines()
731 .filter_map(|line| {
732 let (left, right) = line.split_once('=')?;
733 if !right.contains(&format!("{factory}("))
734 && !right.contains(&format!("{factory}.Router("))
735 {
736 return None;
737 }
738 left.split_whitespace()
739 .next_back()
740 .map(|name| name.trim().to_owned())
741 })
742 .collect()
743}
744
745fn argument_identifier(text: &str, target: usize) -> Option<String> {
746 let open = text.find('(')?;
747 let close = text.rfind(')')?;
748 let argument = text[open + 1..close].split(',').nth(target - 1)?.trim();
749 let identifier = argument
750 .trim_start_matches('&')
751 .split(|character: char| !character.is_ascii_alphanumeric() && character != '_')
752 .next()
753 .unwrap_or_default();
754 (!identifier.is_empty() && !identifier.starts_with(['"', '\'', '`']))
755 .then(|| identifier.to_owned())
756}
757
758fn canonical_method(value: &str) -> Option<String> {
759 METHODS.iter().find_map(|(name, method)| {
760 name.eq_ignore_ascii_case(value)
761 .then_some((*method).to_owned())
762 })
763}
764
765fn literal_path(value: &str) -> Option<String> {
766 if value.contains("${") || value.contains('{') && !value.starts_with('/') {
767 return None;
768 }
769 let path = if value.starts_with('/') {
770 value
771 } else {
772 let after_scheme = value
773 .strip_prefix("http://")
774 .or_else(|| value.strip_prefix("https://"))?;
775 after_scheme
776 .find('/')
777 .map_or("/", |index| &after_scheme[index..])
778 };
779 Some(normalize_source_http_path(
780 path.split(['?', '#']).next().unwrap_or(path),
781 ))
782}
783
784fn java_mapping(line: &str) -> Option<(Option<String>, Option<String>)> {
785 for (annotation, method) in [
786 ("@DeleteMapping", "DELETE"),
787 ("@GetMapping", "GET"),
788 ("@PatchMapping", "PATCH"),
789 ("@PostMapping", "POST"),
790 ("@PutMapping", "PUT"),
791 ] {
792 if line.starts_with(annotation) {
793 return Some((Some(method.to_owned()), literal_after(line, annotation)));
794 }
795 }
796 if line.starts_with("@RequestMapping") {
797 let method = METHODS.iter().find_map(|(_, method)| {
798 line.contains(&format!("RequestMethod.{method}"))
799 .then_some((*method).to_owned())
800 });
801 return Some((method, quoted_value(line)));
802 }
803 None
804}
805
806fn nest_mapping(line: &str) -> Option<(String, Option<String>)> {
807 for (name, method) in [
808 ("Delete", "DELETE"),
809 ("Get", "GET"),
810 ("Head", "HEAD"),
811 ("Options", "OPTIONS"),
812 ("Patch", "PATCH"),
813 ("Post", "POST"),
814 ("Put", "PUT"),
815 ] {
816 let marker = format!("@{name}(");
817 if line.contains(&marker) {
818 return Some((method.to_owned(), literal_after(line, &marker)));
819 }
820 }
821 None
822}
823
824fn java_method_name(line: &str) -> Option<String> {
825 let before = line.split('(').next()?.trim();
826 before
827 .split_whitespace()
828 .next_back()
829 .filter(|name| !name.starts_with('@'))
830 .map(str::to_owned)
831}
832
833fn ecmascript_method_name(line: &str) -> Option<String> {
834 let before = line.split('(').next()?.trim();
835 before
836 .split_whitespace()
837 .next_back()
838 .filter(|name| {
839 !name.starts_with('@')
840 && name
841 .chars()
842 .all(|character| character.is_ascii_alphanumeric() || character == '_')
843 })
844 .map(str::to_owned)
845}
846
847fn finish(mut observations: Vec<SourceObservation>) -> Vec<SourceObservation> {
848 observations.sort_by(|left, right| {
849 (
850 left.lines,
851 left.framework,
852 left.role,
853 &left.method,
854 &left.path,
855 &left.symbol_name,
856 )
857 .cmp(&(
858 right.lines,
859 right.framework,
860 right.role,
861 &right.method,
862 &right.path,
863 &right.symbol_name,
864 ))
865 });
866 observations.dedup();
867 observations
868}
869
870fn line_number(index: usize) -> u32 {
871 u32::try_from(index.saturating_add(1)).unwrap_or(u32::MAX)
872}
873
874#[cfg(test)]
875mod tests {
876 use super::{
877 parse_go_source, parse_java_source, parse_javascript_source_at_path, parse_javascript_source_at_path_with_tracker, parse_typescript_source, parse_typescript_source_at_path
878 };
879 use crate::{
880 ExtractionBudgets, ExtractionResource, ExtractionTracker, SourceEpistemicStatus, SourceFramework, SourceRole
881 };
882
883 #[test]
884 fn polyglot_parser_should_charge_each_attempted_observation_before_retention() {
885 let budgets = ExtractionBudgets {
886 max_observations_per_artifact: 1,
887 ..ExtractionBudgets::default()
888 };
889 let mut tracker = ExtractionTracker::new("client.js", "source.javascript", &budgets);
890 let result = parse_javascript_source_at_path_with_tracker(
891 "client.js",
892 "fetch('/one');\nfetch('/two');\n",
893 &mut tracker,
894 );
895
896 assert!(matches!(
897 result,
898 Err(error)
899 if error.resource == ExtractionResource::Observations
900 && error.observed == 2
901 && error.maximum == 1
902 ));
903 }
904
905 #[test]
906 fn typescript_should_extract_fetch_axios_express_fastify_and_nestjs() {
907 let source = r#"
908import express from "express";
909import fastify from "fastify";
910import axios from "axios";
911import { Controller, Get } from "@nestjs/common";
912const app = express();
913const server = fastify();
914app.post("/orders", createOrder);
915server.get("/health", health);
916fetch("https://api.test/items", { method: "PATCH" });
917axios.delete("/items/1");
918@Get("/users")
919listUsers() {}
920"#;
921 let result = parse_typescript_source(source);
922
923 assert!(
924 [
925 SourceFramework::Fetch,
926 SourceFramework::Axios,
927 SourceFramework::Express,
928 SourceFramework::Fastify,
929 SourceFramework::NestJs,
930 ]
931 .into_iter()
932 .all(|framework| result.iter().any(|item| item.framework == framework))
933 );
934 }
935
936 #[test]
937 fn multiline_fetch_should_preserve_literal_method_and_path() {
938 let result = parse_typescript_source(include_str!(
939 "../../../fixtures/platform-demo/web/src/checkout.ts"
940 ));
941
942 assert!(
943 result.iter().any(|item| {
944 item.framework == SourceFramework::Fetch
945 && item.method.as_deref() == Some("POST")
946 && item.path.as_deref() == Some("/api/orders")
947 }),
948 "{result:?}"
949 );
950 }
951
952 #[test]
953 fn next_app_router_should_derive_static_and_dynamic_file_routes() {
954 let javascript = "export async function POST(request) {}\n";
955 let typescript = "export const GET = async () => {};\n";
956
957 let post =
958 parse_javascript_source_at_path("frontend/src/app/api/cloudflare/route.js", javascript);
959 let get = parse_typescript_source_at_path(
960 "src/app/(public)/users/[user_id]/route.ts",
961 typescript,
962 );
963
964 assert!(post.iter().any(|item| {
965 item.framework == SourceFramework::NextJs
966 && item.method.as_deref() == Some("POST")
967 && item.path.as_deref() == Some("/api/cloudflare")
968 }));
969 assert!(get.iter().any(|item| {
970 item.framework == SourceFramework::NextJs
971 && item.method.as_deref() == Some("GET")
972 && item.path.as_deref() == Some("/users/{user_id}")
973 }));
974 }
975
976 #[test]
977 fn go_should_extract_net_http_gin_and_chi_without_promoting_handle_func_method() {
978 let source = r#"
979import "net/http"
980import "github.com/gin-gonic/gin"
981import "github.com/go-chi/chi/v5"
982http.NewRequest("POST", "https://api.test/orders", nil)
983http.HandleFunc("/health", health)
984router.GET("/users", users)
985r.Delete("/users/{id}", deleteUser)
986"#;
987 let result = parse_go_source(source);
988
989 assert!(
990 result
991 .iter()
992 .any(|item| item.framework == SourceFramework::GoNetHttp
993 && item.role == SourceRole::Consumer
994 && item.method.as_deref() == Some("POST"))
995 );
996 assert!(
997 result
998 .iter()
999 .any(|item| item.framework == SourceFramework::Gin)
1000 );
1001 assert!(
1002 result
1003 .iter()
1004 .any(|item| item.framework == SourceFramework::Chi)
1005 );
1006 assert!(result.iter().any(|item| {
1007 item.framework == SourceFramework::GoNetHttp
1008 && item.status == SourceEpistemicStatus::Incomplete
1009 }));
1010 }
1011
1012 #[test]
1013 fn java_should_extract_spring_webclient_and_feign_roles() {
1014 let spring = r#"
1015import org.springframework.web.bind.annotation.GetMapping;
1016import org.springframework.web.reactive.function.client.WebClient;
1017@GetMapping("/orders")
1018public List<Order> orders() {}
1019client.post().uri("/events").retrieve();
1020"#;
1021 let feign = r#"
1022import org.springframework.web.bind.annotation.PostMapping;
1023import org.springframework.cloud.openfeign.FeignClient;
1024@FeignClient(name = "orders")
1025@PostMapping("/orders")
1026Order create();
1027"#;
1028 let spring_result = parse_java_source(spring);
1029 let feign_result = parse_java_source(feign);
1030
1031 assert!(spring_result.iter().any(|item| {
1032 item.framework == SourceFramework::SpringMvc && item.role == SourceRole::Provider
1033 }));
1034 assert!(
1035 spring_result
1036 .iter()
1037 .any(|item| item.framework == SourceFramework::WebClient)
1038 );
1039 assert!(
1040 feign_result
1041 .iter()
1042 .any(|item| item.framework == SourceFramework::Feign
1043 && item.role == SourceRole::Consumer)
1044 );
1045 }
1046}