Skip to main content

submilli_engine/
capability_derivation.rs

1use std::collections::BTreeMap;
2
3use crate::stdlib::capabilities::{self, FieldNormalization};
4use crate::{
5    BinOp, Diagnostic, DocCapability, DocCapabilityBindingKind, DocCapabilityLiteral, ExprId,
6    GlobalKind, MangledName, Param, Severity, Span, TypedAst, TypedExprKind, TypedStmtKind,
7};
8
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub struct DerivedCapability {
11    pub capability: String,
12    pub filter: Option<String>,
13    /// Statically known filter operands, keyed by capability field.
14    pub known_bindings: BTreeMap<String, String>,
15    pub warnings: Vec<Diagnostic>,
16}
17
18pub fn derive_call_site_capability(
19    tag: &DocCapability,
20    callee_params: &[Param],
21    caller_ast: &TypedAst,
22    actual_args: &[ExprId],
23) -> Result<DerivedCapability, crate::compiler_error::CompileError> {
24    let mut warnings = Vec::new();
25    let mut unresolved_http_host_span = None;
26    let filters = derive_filters(
27        tag,
28        callee_params,
29        caller_ast,
30        actual_args,
31        &mut warnings,
32        &mut unresolved_http_host_span,
33    );
34    if let Some(span) = unresolved_http_host_span {
35        warnings.push(unresolved_http_url_warning(span, &tag.capability));
36    }
37    let filters = filters.map_err(|fatal| {
38        crate::compiler_error::CompileError::from(fatal).with_prior_diagnostics(&warnings)
39    })?;
40    let known_bindings = filters.iter().cloned().collect();
41    Ok(DerivedCapability {
42        capability: tag.capability.clone(),
43        filter: (!filters.is_empty()).then(|| {
44            filters
45                .iter()
46                .map(|(field, operand)| format!("{field} == {operand}"))
47                .collect::<Vec<_>>()
48                .join(" and ")
49        }),
50        known_bindings,
51        warnings,
52    })
53}
54
55fn derive_filters(
56    tag: &DocCapability,
57    callee_params: &[Param],
58    caller_ast: &TypedAst,
59    actual_args: &[ExprId],
60    warnings: &mut Vec<Diagnostic>,
61    unresolved_http_host_span: &mut Option<Span>,
62) -> Result<Vec<(String, String)>, crate::compiler_error::CompilerFailure> {
63    let mut filters = Vec::new();
64    for binding in &tag.bindings {
65        match &binding.kind {
66            DocCapabilityBindingKind::Literal { value, .. } => {
67                // The tag's span is in the callee's source, not the caller's, so a
68                // literal the runtime refuses keeps its spelling without a warning.
69                filters.extend(binding_filter(
70                    tag,
71                    &binding.field,
72                    StaticValue::from(value),
73                    None,
74                    warnings,
75                ));
76            }
77            DocCapabilityBindingKind::Type { .. } => {}
78            DocCapabilityBindingKind::Parameter { param, path, span } => {
79                let Some(param_index) = callee_params.iter().position(|p| p.name == *param) else {
80                    warnings.push(warning(
81                        *span,
82                        format!("cannot derive filter for unknown parameter `${param}`"),
83                    ));
84                    continue;
85                };
86                let Some(actual) = actual_args.get(param_index) else {
87                    warnings.push(warning(
88                        *span,
89                        format!("cannot derive filter for missing argument `${param}`"),
90                    ));
91                    continue;
92                };
93                let actual_span = caller_ast
94                    .try_expr(*actual)
95                    .map_err(crate::typechecker::arena_failure)?
96                    .span;
97                match literal_from_expr_path(caller_ast, *actual, path)? {
98                    Some(value) => filters.extend(binding_filter(
99                        tag,
100                        &binding.field,
101                        value,
102                        Some(actual_span),
103                        warnings,
104                    )),
105                    None if is_http_url_binding(tag, param, path) => {
106                        if matches!(path.as_slice(), [component] if component == "host") {
107                            *unresolved_http_host_span = Some(actual_span);
108                        }
109                    }
110                    // Dynamic arguments leave the requirement broad; they do not
111                    // demonstrate an error in the caller.
112                    None => {}
113                }
114            }
115        }
116    }
117    Ok(filters)
118}
119
120/// A value known at the call site, before it becomes a filter operand.
121enum StaticValue {
122    String(String),
123    /// The number as the filter spells it.
124    Number(String),
125    Boolean(bool),
126    Null,
127}
128
129impl From<&DocCapabilityLiteral> for StaticValue {
130    fn from(value: &DocCapabilityLiteral) -> Self {
131        match value {
132            DocCapabilityLiteral::String(value) => Self::String(value.clone()),
133            DocCapabilityLiteral::Number(value) => Self::Number(value.clone()),
134            DocCapabilityLiteral::Boolean(value) => Self::Boolean(*value),
135            DocCapabilityLiteral::Null => Self::Null,
136        }
137    }
138}
139
140/// `field == value`, with a string in the form the runtime checks.
141fn binding_filter(
142    tag: &DocCapability,
143    field: &str,
144    value: StaticValue,
145    warn_at: Option<Span>,
146    warnings: &mut Vec<Diagnostic>,
147) -> Option<(String, String)> {
148    let operand = match value {
149        StaticValue::String(value) => {
150            if field_normalization(&tag.capability, field) == FieldNormalization::VfsPath
151                && !value.starts_with('/')
152            {
153                if let Some(span) = warn_at {
154                    warnings.push(warning(span, format!("relative `{field}` depends on the session cwd; no static path filter for `{}`", tag.capability)));
155                }
156                return None;
157            }
158            let normalized = normalized_string(tag, field, value, warn_at, warnings);
159            let Some(literal) = filter_string_literal(&normalized) else {
160                if let Some(span) = warn_at {
161                    warnings.push(warning(
162                        span,
163                        format!(
164                            "`{field}` value contains `{VAR_PLACEHOLDER}`, which a filter reads as a variable reference; no static filter for `{}`",
165                            tag.capability
166                        ),
167                    ));
168                }
169                return None;
170            };
171            literal
172        }
173        StaticValue::Number(spelling) => spelling,
174        StaticValue::Boolean(value) => value.to_string(),
175        StaticValue::Null => "null".to_string(),
176    };
177    Some((field.to_string(), operand))
178}
179
180/// The string the runtime checks for `value`. A value the runtime refuses keeps
181/// its spelling, so the filter fails closed; the call cannot succeed, which a
182/// warning at `warn_at` reports when that location is in the caller's source.
183fn normalized_string(
184    tag: &DocCapability,
185    field: &str,
186    value: String,
187    warn_at: Option<Span>,
188    warnings: &mut Vec<Diagnostic>,
189) -> String {
190    field_normalization(&tag.capability, field)
191        .apply(&value)
192        .unwrap_or_else(|reason| {
193            if let Some(span) = warn_at {
194                warnings.push(warning(
195                    span,
196                    format!(
197                        "`{}` refuses `{field}` value \"{}\" at runtime: {reason}",
198                        tag.capability,
199                        escape(&value)
200                    ),
201                ));
202            }
203            value
204        })
205}
206
207fn field_normalization(capability: &str, field: &str) -> FieldNormalization {
208    capabilities::find_any(capability)
209        .and_then(|entry| entry.filter_fields.iter().find(|f| f.name == field))
210        .map_or(FieldNormalization::Verbatim, |f| f.normalization)
211}
212
213fn is_http_url_binding(tag: &DocCapability, param: &str, path: &[String]) -> bool {
214    tag.capability.starts_with("http.")
215        && param == "url"
216        && matches!(path, [component] if matches!(component.as_str(), "host" | "path"))
217}
218
219fn unresolved_http_url_warning(span: Span, capability: &str) -> Diagnostic {
220    Diagnostic {
221        severity: Severity::Warning,
222        span,
223        message: format!(
224            "cannot statically resolve the host in the URL passed to `{capability}`; \
225             no host capability filter was derived"
226        ),
227        help: vec![
228            "call the HTTP function directly with a URL literal or a concatenation of \
229             string literals and top-level string constants"
230                .to_string(),
231            "when appending a dynamic path, include `/` after the host in the constant prefix, \
232             e.g. `\"https://api.example.com/\" + path` where `path` has no leading slash; \
233             this makes the host boundary statically known"
234                .to_string(),
235        ],
236        notes: vec![],
237    }
238}
239
240fn literal_from_expr_path(
241    ast: &TypedAst,
242    expr_id: ExprId,
243    path: &[String],
244) -> Result<Option<StaticValue>, crate::compiler_error::CompilerFailure> {
245    if path.is_empty() {
246        return literal_from_expr(ast, expr_id);
247    }
248    // `$url.host` / `$url.path`: a single `host`/`path` segment on a value that
249    // resolves to a string URL is filled by parsing the URL, not by walking an
250    // object field — this is how the http capabilities bind `host`/`path` from
251    // their `url` argument. Guarded on the base resolving to a string literal,
252    // so an object field literally named `host`/`path` still resolves below.
253    if let [segment] = path
254        && matches!(segment.as_str(), "host" | "path")
255    {
256        if let Some(url) = resolve_string_literal(ast, expr_id)? {
257            return Ok(url_component(&url, segment).map(StaticValue::String));
258        }
259        if segment == "host" {
260            return url_host_from_constant_prefix(ast, expr_id);
261        }
262    }
263    let mut current = expr_id;
264    for segment in path {
265        let TypedExprKind::ObjectLiteral { fields, .. } = &ast
266            .try_expr(current)
267            .map_err(crate::typechecker::arena_failure)?
268            .kind
269        else {
270            return Ok(None);
271        };
272        let Some(field) = fields.iter().find(|field| field.name.name == *segment) else {
273            return Ok(None);
274        };
275        current = match field.source.literal_expr_id() {
276            Some(value) => value,
277            None => return Ok(None),
278        };
279    }
280    literal_from_expr(ast, current)
281}
282
283fn url_host_from_constant_prefix(
284    ast: &TypedAst,
285    expr_id: ExprId,
286) -> Result<Option<StaticValue>, crate::compiler_error::CompilerFailure> {
287    let prefix = constant_string_prefix(ast, expr_id)?;
288    let authority_start = match prefix.find("://") {
289        Some(value) => value,
290        None => return Ok(None),
291    } + 3;
292    let path_start = match prefix[authority_start..].find('/') {
293        Some(value) => value,
294        None => return Ok(None),
295    } + authority_start;
296    Ok(url_component(&prefix[..=path_start], "host").map(StaticValue::String))
297}
298
299fn constant_string_prefix(
300    ast: &TypedAst,
301    expr_id: ExprId,
302) -> Result<String, crate::compiler_error::CompilerFailure> {
303    Ok(
304        match &ast
305            .try_expr(expr_id)
306            .map_err(crate::typechecker::arena_failure)?
307            .kind
308        {
309            TypedExprKind::String(value) => value.clone(),
310            TypedExprKind::GlobalRef { mangled, .. } => match const_initializer(ast, mangled)? {
311                Some(value) => constant_string_prefix(ast, value)?,
312                None => String::new(),
313            },
314            TypedExprKind::Binary {
315                op: BinOp::Add,
316                lhs,
317                rhs,
318            } => {
319                let Some(mut value) = resolve_string_literal(ast, *lhs)? else {
320                    return constant_string_prefix(ast, *lhs);
321                };
322                value.push_str(&constant_string_prefix(ast, *rhs)?);
323                value
324            }
325            _ => String::new(),
326        },
327    )
328}
329
330fn literal_from_expr(
331    ast: &TypedAst,
332    expr_id: ExprId,
333) -> Result<Option<StaticValue>, crate::compiler_error::CompilerFailure> {
334    Ok(
335        match &ast
336            .try_expr(expr_id)
337            .map_err(crate::typechecker::arena_failure)?
338            .kind
339        {
340            TypedExprKind::String(value) => Some(StaticValue::String(value.clone())),
341            TypedExprKind::Binary { op: BinOp::Add, .. } => {
342                resolve_string_literal(ast, expr_id)?.map(StaticValue::String)
343            }
344            TypedExprKind::Number(value) => Some(StaticValue::Number(number_literal(*value))),
345            TypedExprKind::Boolean(value) => Some(StaticValue::Boolean(*value)),
346            TypedExprKind::Null => Some(StaticValue::Null),
347            TypedExprKind::GlobalRef { mangled, .. } => literal_from_expr(
348                ast,
349                match const_initializer(ast, mangled)? {
350                    Some(value) => value,
351                    None => return Ok(None),
352                },
353            )?,
354            _ => None,
355        },
356    )
357}
358
359/// Resolve a value to its raw string, folding immutable top-level constants and
360/// concatenations whose operands both resolve to strings.
361fn resolve_string_literal(
362    ast: &TypedAst,
363    expr_id: ExprId,
364) -> Result<Option<String>, crate::compiler_error::CompilerFailure> {
365    Ok(
366        match &ast
367            .try_expr(expr_id)
368            .map_err(crate::typechecker::arena_failure)?
369            .kind
370        {
371            TypedExprKind::String(value) => Some(value.clone()),
372            TypedExprKind::Binary {
373                op: BinOp::Add,
374                lhs,
375                rhs,
376            } => {
377                let Some(mut value) = resolve_string_literal(ast, *lhs)? else {
378                    return Ok(None);
379                };
380                value.push_str(&match resolve_string_literal(ast, *rhs)? {
381                    Some(value) => value,
382                    None => return Ok(None),
383                });
384                Some(value)
385            }
386            TypedExprKind::GlobalRef { mangled, .. } => resolve_string_literal(
387                ast,
388                match const_initializer(ast, mangled)? {
389                    Some(value) => value,
390                    None => return Ok(None),
391                },
392            )?,
393            _ => None,
394        },
395    )
396}
397
398/// The initializer expression of a top-level `const` (not `let` — a `let` can be
399/// reassigned, so folding it would be unsound). `None` for anything else.
400fn const_initializer(
401    ast: &TypedAst,
402    mangled: &MangledName,
403) -> Result<Option<ExprId>, crate::compiler_error::CompilerFailure> {
404    let is_const = ast
405        .globals
406        .iter()
407        .any(|g| &g.mangled_name == mangled && matches!(g.kind, GlobalKind::Const));
408    if !is_const {
409        return Ok(None);
410    }
411    ast.top_level_statements
412        .iter()
413        .map(|stmt| {
414            Ok::<_, crate::compiler_error::CompilerFailure>(
415                match &ast
416                    .try_stmt(*stmt)
417                    .map_err(crate::typechecker::arena_failure)?
418                    .kind
419                {
420                    TypedStmtKind::AssignGlobal {
421                        mangled: target,
422                        value,
423                        ..
424                    } if target == mangled => Some(*value),
425                    _ => None,
426                },
427            )
428        })
429        .find_map(Result::transpose)
430        .transpose()
431}
432
433/// Parse `url` and return the requested component.
434fn url_component(url: &str, component: &str) -> Option<String> {
435    let parsed = url::Url::parse(url).ok()?;
436    let value = match component {
437        // The spelling the runtime capability contexts use.
438        "host" => {
439            parsed.host_str()?;
440            crate::stdlib::url::host_without_trailing_dots(&parsed).to_string()
441        }
442        "path" => parsed.path().to_string(),
443        _ => return None,
444    };
445    Some(value)
446}
447
448/// What a filter reads inside a quoted string as the start of a variable reference.
449const VAR_PLACEHOLDER: &str = "${vars.";
450
451/// `value` as a quoted filter string, in the canonical form the policy parser
452/// reads back as exactly `value`. `None` when no quoted string can spell it: the
453/// parser reads `${vars.` inside quotes as a variable reference.
454///
455/// Must agree with `submilli-blueprint`'s `quote_literal`, which the engine
456/// cannot depend on.
457#[doc(hidden)]
458pub fn filter_string_literal(value: &str) -> Option<String> {
459    (!value.contains(VAR_PLACEHOLDER)).then(|| format!("\"{}\"", escape(value)))
460}
461
462/// The escapes the filter tokenizer reads inside a quoted string.
463fn escape(value: &str) -> String {
464    let mut out = String::with_capacity(value.len());
465    for ch in value.chars() {
466        match ch {
467            '"' => out.push_str("\\\""),
468            '\\' => out.push_str("\\\\"),
469            '\n' => out.push_str("\\n"),
470            '\t' => out.push_str("\\t"),
471            '\r' => out.push_str("\\r"),
472            c => out.push(c),
473        }
474    }
475    out
476}
477
478fn number_literal(value: f64) -> String {
479    if value.fract() == 0.0 {
480        format!("{value:.0}")
481    } else {
482        value.to_string()
483    }
484}
485
486fn warning(span: Span, message: String) -> Diagnostic {
487    Diagnostic {
488        severity: Severity::Warning,
489        span,
490        message,
491        help: vec![],
492        notes: vec![],
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499    use crate::{Asi, TokenKind, infer, parse};
500
501    fn typed(source: &str) -> TypedAst {
502        let mut asi = Asi::new(source, crate::FileId(0));
503        let mut tokens = Vec::new();
504        loop {
505            let tok = asi.next_token();
506            let is_eof = matches!(tok.kind, TokenKind::Eof);
507            tokens.push(tok);
508            if is_eof {
509                break;
510            }
511        }
512        let lex_diags = asi.into_diagnostics();
513        assert!(lex_diags.is_empty(), "{lex_diags:?}");
514        let (ast, parse_diags) = parse(source, tokens, crate::FileId(0));
515        assert!(parse_diags.is_empty(), "{parse_diags:?}");
516        let (prelude_defs, host_defs, _) =
517            crate::runtime::prelude::cached_runtime_package_declarations();
518        let mut packages = Vec::with_capacity(prelude_defs.len() + host_defs.len());
519        packages.extend(prelude_defs.iter());
520        packages.extend(host_defs.iter());
521        let (ta, infer_diags) = infer(source, "main", &ast, &packages);
522        assert!(infer_diags.is_empty(), "{infer_diags:?}");
523        ta
524    }
525
526    fn first_doc_capability(source: &str) -> (TypedAst, DocCapability, Vec<Param>, Vec<ExprId>) {
527        let ta = typed(source);
528        let callee = ta
529            .functions
530            .iter()
531            .find(|f| f.name.name == "callee")
532            .expect("callee");
533        let tag = callee
534            .doc
535            .as_ref()
536            .and_then(|doc| doc.capabilities.first())
537            .cloned()
538            .expect("capability tag");
539        let params = callee
540            .params
541            .iter()
542            .map(|p| Param::new(p.name.name.clone(), p.ty.clone()))
543            .collect::<Vec<_>>();
544        let caller = ta
545            .functions
546            .iter()
547            .find(|f| f.name.name == "main")
548            .expect("main");
549        let crate::TypedStmtKind::Block(stmts) = &ta.try_stmt(caller.body).unwrap().kind else {
550            panic!("main body block");
551        };
552        let call_expr = stmts
553            .iter()
554            .find_map(|stmt| match &ta.try_stmt(*stmt).unwrap().kind {
555                crate::TypedStmtKind::Expr(call_expr) => Some(*call_expr),
556                _ => None,
557            })
558            .expect("call expr stmt");
559        let TypedExprKind::Call { args, .. } = &ta.try_expr(call_expr).unwrap().kind else {
560            panic!("direct call");
561        };
562        let args = args.clone();
563        (ta, tag, params, args)
564    }
565
566    #[test]
567    fn literal_arg_contributes_filter() {
568        let (ta, tag, params, args) = first_doc_capability(
569            "/** @capability secrets.get { name } */\n\
570             function callee(name: string): void { }\n\
571             function main(): void { callee(\"TOKEN\"); }\n",
572        );
573        let derived = derive_call_site_capability(&tag, &params, &ta, &args).unwrap();
574        assert_eq!(derived.filter.as_deref(), Some("name == \"TOKEN\""));
575        assert!(derived.warnings.is_empty(), "{:?}", derived.warnings);
576    }
577
578    /// A filter reads `${vars.` inside quotes as a variable, so no literal can spell
579    /// such a value: the binding is dropped, with a warning, rather than written as
580    /// a variable reference the program never meant.
581    #[test]
582    fn literal_holding_a_variable_reference_contributes_no_filter() {
583        let (ta, tag, params, args) = first_doc_capability(
584            "/** @capability secrets.get { name } */\n\
585             function callee(name: string): void { }\n\
586             function main(): void { callee(\"${vars.who}\"); }\n",
587        );
588        let derived = derive_call_site_capability(&tag, &params, &ta, &args).unwrap();
589        assert_eq!(derived.filter, None);
590        assert!(derived.known_bindings.is_empty());
591        assert_eq!(derived.warnings.len(), 1, "{:?}", derived.warnings);
592        assert!(derived.warnings[0].message.contains("${vars."));
593    }
594
595    #[test]
596    fn string_literal_escapes_what_the_filter_tokenizer_reads() {
597        assert_eq!(
598            filter_string_literal("a\"b\\c\nd\te\rf").as_deref(),
599            Some("\"a\\\"b\\\\c\\nd\\te\\rf\""),
600        );
601        assert_eq!(filter_string_literal("${vars.x}"), None);
602        assert_eq!(
603            filter_string_literal("${other}").as_deref(),
604            Some("\"${other}\"")
605        );
606    }
607
608    #[test]
609    fn non_literal_arg_retains_requirement_without_warning() {
610        let (ta, tag, params, args) = first_doc_capability(
611            "/** @capability secrets.get { name } */\n\
612             function callee(name: string): void { }\n\
613             function main(): void { let token = \"TOKEN\"; callee(token); }\n",
614        );
615        let derived = derive_call_site_capability(&tag, &params, &ta, &args).unwrap();
616        assert_eq!(derived.capability, "secrets.get");
617        assert_eq!(derived.filter, None);
618        assert!(derived.known_bindings.is_empty());
619        assert!(derived.warnings.is_empty(), "{:?}", derived.warnings);
620    }
621
622    #[test]
623    fn type_only_field_does_not_contribute_filter() {
624        let (ta, tag, params, args) = first_doc_capability(
625            "/** @capability x/op { amount: number } */\n\
626             function callee(amount: number): void { }\n\
627             function main(): void { callee(1); }\n",
628        );
629        let derived = derive_call_site_capability(&tag, &params, &ta, &args).unwrap();
630        assert_eq!(derived.filter, None);
631        assert!(derived.warnings.is_empty(), "{:?}", derived.warnings);
632    }
633
634    #[test]
635    fn fixed_literal_contributes_filter() {
636        let (ta, tag, params, args) = first_doc_capability(
637            "/** @capability db.query { readonly: true, query: string } */\n\
638             function callee(query: string): void { }\n\
639             function main(): void { callee(\"select 1\"); }\n",
640        );
641        let derived = derive_call_site_capability(&tag, &params, &ta, &args).unwrap();
642        assert_eq!(derived.filter.as_deref(), Some("readonly == true"));
643        assert!(derived.warnings.is_empty(), "{:?}", derived.warnings);
644    }
645
646    #[test]
647    fn url_host_and_path_extracted_from_literal() {
648        let (ta, tag, params, args) = first_doc_capability(
649            "/** @capability http.post { host: $url.host, path: $url.path } */\n\
650             function callee(url: string): void { }\n\
651             function main(): void { callee(\"https://r.jina.ai/read\"); }\n",
652        );
653        let derived = derive_call_site_capability(&tag, &params, &ta, &args).unwrap();
654        assert_eq!(
655            derived.filter.as_deref(),
656            Some("host == \"r.jina.ai\" and path == \"/read\"")
657        );
658        assert!(derived.warnings.is_empty(), "{:?}", derived.warnings);
659    }
660
661    #[test]
662    fn fully_qualified_host_derives_the_filter_the_runtime_checks() {
663        let (ta, tag, params, args) = first_doc_capability(
664            "/** @capability http.post { host: $url.host } */\n\
665             function callee(url: string): void { }\n\
666             function main(): void { callee(\"https://r.jina.ai./read\"); }\n",
667        );
668        let derived = derive_call_site_capability(&tag, &params, &ta, &args).unwrap();
669        assert_eq!(derived.filter.as_deref(), Some("host == \"r.jina.ai\""));
670    }
671
672    #[test]
673    fn const_url_folds_to_host_filter() {
674        let (ta, tag, params, args) = first_doc_capability(
675            "/** @capability http.post { host: $url.host } */\n\
676             function callee(url: string): void { }\n\
677             const ENDPOINT: string = \"https://r.jina.ai/\";\n\
678             function main(): void { callee(ENDPOINT); }\n",
679        );
680        let derived = derive_call_site_capability(&tag, &params, &ta, &args).unwrap();
681        assert_eq!(derived.filter.as_deref(), Some("host == \"r.jina.ai\""));
682        assert!(derived.warnings.is_empty(), "{:?}", derived.warnings);
683    }
684
685    #[test]
686    fn constant_string_concatenation_folds_to_url_filters() {
687        let (ta, tag, params, args) = first_doc_capability(
688            "/** @capability http.post { host: $url.host, path: $url.path } */\n\
689             function callee(url: string): void { }\n\
690             const ORIGIN: string = \"https://\" + \"api.example.com\";\n\
691             const VERSION: string = \"/v1\";\n\
692             function main(): void { callee(ORIGIN + VERSION + \"/items\"); }\n",
693        );
694        let derived = derive_call_site_capability(&tag, &params, &ta, &args).unwrap();
695        assert_eq!(
696            derived.filter.as_deref(),
697            Some("host == \"api.example.com\" and path == \"/v1/items\"")
698        );
699        assert!(derived.warnings.is_empty(), "{:?}", derived.warnings);
700    }
701
702    #[test]
703    fn constant_url_prefix_folds_only_the_host_filter() {
704        let (ta, tag, params, args) = first_doc_capability(
705            "/** @capability http.get { host: $url.host, path: $url.path } */\n\
706             function callee(url: string): void { }\n\
707             const API: string = \"https://api.example.com/v1/\";\n\
708             function main(path: string): void { callee(API + path); }\n",
709        );
710        let derived = derive_call_site_capability(&tag, &params, &ta, &args).unwrap();
711        assert_eq!(
712            derived.filter.as_deref(),
713            Some("host == \"api.example.com\"")
714        );
715        assert!(derived.warnings.is_empty(), "{:?}", derived.warnings);
716    }
717
718    #[test]
719    fn constant_url_prefix_without_path_does_not_claim_a_host() {
720        let (ta, tag, params, args) = first_doc_capability(
721            "/** @capability http.get { host: $url.host } */\n\
722             function callee(url: string): void { }\n\
723             const ORIGIN: string = \"https://api.example.com\";\n\
724             function main(suffix: string): void { callee(ORIGIN + suffix); }\n",
725        );
726        let derived = derive_call_site_capability(&tag, &params, &ta, &args).unwrap();
727        assert_eq!(derived.filter, None);
728        assert_eq!(derived.warnings.len(), 1);
729    }
730
731    #[test]
732    fn http_helper_result_is_not_folded_and_gets_one_targeted_warning() {
733        let (ta, tag, params, args) = first_doc_capability(
734            "/** @capability http.get { host: $url.host, path: $url.path } */\n\
735             function callee(url: string): void { }\n\
736             const ORIGIN: string = \"https://api.example.com\";\n\
737             function endpoint(): string { return ORIGIN + \"/v1/items\"; }\n\
738             function main(): void { callee(endpoint()); }\n",
739        );
740        let derived = derive_call_site_capability(&tag, &params, &ta, &args).unwrap();
741        assert_eq!(derived.filter.as_deref(), None);
742        assert_eq!(derived.warnings.len(), 1);
743        assert!(
744            derived.warnings[0]
745                .message
746                .contains("cannot statically resolve the host in the URL passed to `http.get`")
747        );
748        assert!(
749            derived.warnings[0].help[0].contains("call the HTTP function directly"),
750            "{:?}",
751            derived.warnings[0].help
752        );
753    }
754
755    #[test]
756    fn repository_url_and_path_derive_the_values_the_runtime_checks() {
757        let (ta, tag, params, args) = first_doc_capability(
758            "/** @capability git.clone { path, remote: $url, remoteName: \"origin\" } */\n\
759             function callee(url: string, path: string): void { }\n\
760             function main(): void { callee(\"https://GitHub.com:443\", \"repo/./x/..\"); }\n",
761        );
762        let derived = derive_call_site_capability(&tag, &params, &ta, &args).unwrap();
763        assert_eq!(
764            derived.filter.as_deref(),
765            Some("remote == \"https://github.com/\" and remoteName == \"origin\"")
766        );
767        assert_eq!(derived.warnings.len(), 1, "{:?}", derived.warnings);
768    }
769
770    #[test]
771    fn vfs_path_fields_derive_normalized_paths() {
772        let (ta, tag, params, args) = first_doc_capability(
773            "/** @capability fs.move { from, to } */\n\
774             function callee(from: string, to: string): void { }\n\
775             function main(): void { callee(\"/data/../in.csv\", \"out/\"); }\n",
776        );
777        let derived = derive_call_site_capability(&tag, &params, &ta, &args).unwrap();
778        assert_eq!(derived.filter.as_deref(), Some("from == \"/in.csv\""));
779        assert_eq!(derived.warnings.len(), 1, "{:?}", derived.warnings);
780    }
781
782    #[test]
783    fn fixed_vfs_path_literal_is_normalized() {
784        let (ta, tag, params, args) = first_doc_capability(
785            "/** @capability fs.read { path: \"data\" } */\n\
786             function callee(): void { }\n\
787             function main(): void { callee(); }\n",
788        );
789        let derived = derive_call_site_capability(&tag, &params, &ta, &args).unwrap();
790        assert_eq!(derived.filter, None);
791        assert!(derived.warnings.is_empty(), "{:?}", derived.warnings);
792    }
793
794    #[test]
795    fn fixed_literal_the_runtime_refuses_keeps_its_spelling_without_a_warning() {
796        let (ta, tag, params, args) = first_doc_capability(
797            "/** @capability fs.read { path: \"/..\" } */\n\
798             function callee(): void { }\n\
799             function main(): void { callee(); }\n",
800        );
801        let derived = derive_call_site_capability(&tag, &params, &ta, &args).unwrap();
802        assert_eq!(derived.filter.as_deref(), Some("path == \"/..\""));
803        assert!(derived.warnings.is_empty(), "{:?}", derived.warnings);
804    }
805
806    #[test]
807    fn download_destination_and_const_folded_paths_are_normalized() {
808        let (ta, tag, params, args) = first_doc_capability(
809            "/** @capability http.download { host: $url.host, vfs_path: $path } */\n\
810             function callee(url: string, path: string): void { }\n\
811             const DIR: string = \"out/\";\n\
812             function main(): void { callee(\"https://Example.com/f\", DIR + \"../dl.csv\"); }\n",
813        );
814        let derived = derive_call_site_capability(&tag, &params, &ta, &args).unwrap();
815        assert_eq!(derived.filter.as_deref(), Some("host == \"example.com\""));
816        assert_eq!(derived.warnings.len(), 1, "{:?}", derived.warnings);
817    }
818
819    #[test]
820    fn same_field_name_outside_the_catalog_stays_verbatim() {
821        let (ta, tag, params, args) = first_doc_capability(
822            "/** @capability acme.com/open { path } */\n\
823             function callee(path: string): void { }\n\
824             function main(): void { callee(\"repo\"); }\n",
825        );
826        let derived = derive_call_site_capability(&tag, &params, &ta, &args).unwrap();
827        assert_eq!(derived.filter.as_deref(), Some("path == \"repo\""));
828    }
829
830    #[test]
831    fn value_the_runtime_refuses_keeps_its_spelling_and_warns() {
832        let (ta, tag, params, args) = first_doc_capability(
833            "/** @capability git.clone { path, remote: $url } */\n\
834             function callee(url: string, path: string): void { }\n\
835             function main(): void { callee(\"http://github.com/a.git\", \"/..\"); }\n",
836        );
837        let derived = derive_call_site_capability(&tag, &params, &ta, &args).unwrap();
838        assert_eq!(
839            derived.filter.as_deref(),
840            Some("path == \"/..\" and remote == \"http://github.com/a.git\"")
841        );
842        let messages = derived
843            .warnings
844            .iter()
845            .map(|warning| warning.message.as_str())
846            .collect::<Vec<_>>();
847        assert_eq!(
848            messages,
849            [
850                "`git.clone` refuses `path` value \"/..\" at runtime: path escapes the VFS root",
851                "`git.clone` refuses `remote` value \"http://github.com/a.git\" at runtime: \
852                 git: remote must be an HTTPS repository URL without credentials, query, or fragment",
853            ]
854        );
855    }
856
857    #[test]
858    fn mutable_global_let_is_not_folded() {
859        let (ta, tag, params, args) = first_doc_capability(
860            "/** @capability http.post { host: $url.host } */\n\
861             function callee(url: string): void { }\n\
862             let endpoint: string = \"https://r.jina.ai/\";\n\
863             function main(): void { callee(endpoint); }\n",
864        );
865        let derived = derive_call_site_capability(&tag, &params, &ta, &args).unwrap();
866        assert_eq!(derived.filter, None);
867        assert_eq!(derived.warnings.len(), 1);
868    }
869}