Skip to main content

code_system_graph_core/
source_polyglot.rs

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/// Extracts Fetch, Axios, Express, and Fastify facts from JavaScript.
20#[must_use]
21pub fn parse_javascript_source(source: &str) -> Vec<SourceObservation> {
22    parse_ecmascript(source, SourceLanguage::JavaScript)
23}
24
25/// Extracts JavaScript framework facts with repository-relative file-route context.
26#[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
37/// Extracts JavaScript facts while charging each attempted observation before retention.
38///
39/// # Errors
40///
41/// Returns [`ExtractionLimitExceeded`] before an observation or one of its values exceeds the
42/// effective per-artifact budget.
43pub 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/// Extracts Fetch, Axios, Express, Fastify, and `NestJS` facts from TypeScript.
58#[must_use]
59pub fn parse_typescript_source(source: &str) -> Vec<SourceObservation> {
60    parse_ecmascript(source, SourceLanguage::TypeScript)
61}
62
63/// Extracts TypeScript framework facts with repository-relative file-route context.
64#[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
75/// Extracts TypeScript facts while charging each attempted observation before retention.
76///
77/// # Errors
78///
79/// Returns [`ExtractionLimitExceeded`] before an observation or one of its values exceeds the
80/// effective per-artifact budget.
81pub 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/// Extracts `net/http`, Gin, and Chi facts from Go source.
96#[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
102/// Extracts Go facts while charging each attempted observation before retention.
103///
104/// # Errors
105///
106/// Returns [`ExtractionLimitExceeded`] before an observation or one of its values exceeds the
107/// effective per-artifact budget.
108pub 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/// Extracts Spring MVC, Spring `WebClient`, and Feign facts from Java source.
187#[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
193/// Extracts Java facts while charging each attempted observation before retention.
194///
195/// # Errors
196///
197/// Returns [`ExtractionLimitExceeded`] before an observation or one of its values exceeds the
198/// effective per-artifact budget.
199pub 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}