//! Parse extension modules and reject free runtime identifiers outside the world runtime globals. use deno_ast::swc::{ ast::{Ident, TsInterfaceDecl, TsType}, common::SyntaxContext, ecma_visit::{Visit, VisitWith}, }; use deno_ast::{MediaType, ParseParams, ProgramRef, parse_module}; use std::path::Path; // Standard JavaScript globals. World extensions also see Deno. const GLOBALS: &[&str] = &[ "undefined", "globalThis", "Deno", "console", "crypto", "fetch", "performance", "setTimeout", "clearTimeout", "setInterval", "clearInterval", "queueMicrotask", "structuredClone", "atob", "btoa", "parseInt", "parseFloat", "isNaN", "Infinity", "NaN", "Object", "Function", "Boolean", "Number", "BigInt", "String", "Symbol", "Array", "Map", "Set", "WeakMap", "WeakSet", "WeakRef", "FinalizationRegistry", "Promise", "Date", "RegExp", "Error", "TypeError", "RangeError", "SyntaxError", "AggregateError", "JSON", "Math", "Reflect", "Proxy", "Intl", "Iterator", "Uint8Array", "ArrayBuffer", "TextEncoder", "TextDecoder", "URL", "URLSearchParams", "AbortController", "AbortSignal", "Request", "Response", "Headers", "ReadableStream", "WritableStream", "TransformStream", "Blob", "File", "FormData", "DOMException", "encodeURIComponent", "decodeURIComponent", ]; fn undefined(source: &str) -> Vec { let parsed = parse_module(ParseParams { specifier: "file:///extension.ts".parse().unwrap(), text: source.into(), media_type: MediaType::TypeScript, capture_tokens: false, scope_analysis: true, maybe_syntax: None, }) .unwrap(); struct FreeNames { context: SyntaxContext, names: Vec, } impl Visit for FreeNames { // Type signatures contain parameter names but do not execute. fn visit_ts_type(&mut self, _node: &TsType) {} fn visit_ts_interface_decl(&mut self, _node: &TsInterfaceDecl) {} fn visit_ident(&mut self, ident: &Ident) { if ident.ctxt == self.context && !GLOBALS.contains(&ident.sym.as_ref()) { self.names.push(ident.sym.to_string()); } } } let mut free = FreeNames { context: parsed.unresolved_context(), names: Vec::new(), }; match parsed.program_ref() { ProgramRef::Module(module) => module.visit_with(&mut free), ProgramRef::Script(script) => script.visit_with(&mut free), } free.names.sort(); free.names.dedup(); free.names } fn scan(directory: &Path, failures: &mut Vec) { for entry in std::fs::read_dir(directory).unwrap() { let path = entry.unwrap().path(); if path.is_dir() { scan(&path, failures); } else if path.extension().is_some_and(|ext| ext == "ts") && !path.to_string_lossy().ends_with(".test.ts") { let names = undefined(&std::fs::read_to_string(&path).unwrap()); if !names.is_empty() { failures.push(format!("{}: {}", path.display(), names.join(", "))); } } } } #[test] fn extension_identifiers_are_defined() { let mut failures = Vec::new(); scan( &Path::new(env!("CARGO_MANIFEST_DIR")).join("../extensions"), &mut failures, ); assert!( failures.is_empty(), "Undefined identifiers:\n{}", failures.join("\n") ); } #[test] fn catches_the_missing_route_import() { let source = std::fs::read_to_string( Path::new(env!("CARGO_MANIFEST_DIR")).join("../extensions/messages/route.ts"), ) .unwrap(); let old = source.replace( "import { ConversationsApi } from '../conversations/api.ts'\n", "", ); assert!( undefined(&old) .iter() .any(|name| name == "ConversationsApi") ); assert!(undefined("import { Api } from 'service'; type Callback = (arg: UnknownType) => void; interface Service { use(parameter: UnknownType): void }; function use(host: any) { const local = Api; return host.need(local) }").is_empty()); assert_eq!( undefined("function use(host: any) { return host.need(MissingApi) }"), ["MissingApi"] ); }