From 0c34d74658653d062ae1d0b0c68bdc8527436773 Mon Sep 17 00:00:00 2001 From: Isaac Elbaz Date: Sat, 5 Sep 2026 10:14:56 -0400 Subject: [PATCH] Unify source discovery and select the TSX grammar --- docs/source-languages.md | 15 +++++++++ src/main.rs | 41 ++--------------------- src/patterns.rs | 17 ++++++++-- src/scan.rs | 36 +++++++++++++------- tests/integration.rs | 2 +- tests/language_discovery.rs | 66 +++++++++++++++++++++++++++++++++++++ 6 files changed, 123 insertions(+), 54 deletions(-) create mode 100644 docs/source-languages.md create mode 100644 tests/language_discovery.rs diff --git a/docs/source-languages.md b/docs/source-languages.md new file mode 100644 index 0000000..2ac4ba6 --- /dev/null +++ b/docs/source-languages.md @@ -0,0 +1,15 @@ +# Source language selection + +Directory discovery and explicit source paths use the same extension resolver. +Extensions are case-insensitive, with one conventional exception: `.c` selects C +and `.C` selects C++. A file is scanned only if its parser feature is enabled and +the pattern set contains definitions for its language. + +`.ts`, `.mts`, and `.cts` use the TypeScript grammar. `.tsx` uses the dedicated +TSX grammar, represented by `Language::Tsx` in the library API. Both use +`lang-typescript` and the `TypeScript` pattern definitions. Ordinary TypeScript +continues to accept angle-bracket type assertions, which conflict with JSX syntax. + +Ambiguous `.h` files prefer C++ when available and fall back to C in C-only builds. +The OpenSSL C fixture family runs whenever `lang-c` is enabled; it does not require +the C++ parser. diff --git a/src/main.rs b/src/main.rs index 2e15ead..b305a41 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,6 @@ use clap::Parser; use crossbeam_channel as channel; use ignore::WalkBuilder; use ignore::overrides::OverrideBuilder; -use ignore::types::TypesBuilder; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use memmap2::Mmap; use rayon::prelude::*; @@ -181,44 +180,8 @@ fn main() -> Result<()> { } } - let mut types_builder = TypesBuilder::new(); - types_builder.add("c", "*.c").unwrap(); - types_builder.add("c", "*.h").unwrap(); - types_builder.add("cpp", "*.cc").unwrap(); - types_builder.add("cpp", "*.cpp").unwrap(); - types_builder.add("cpp", "*.cxx").unwrap(); - types_builder.add("cpp", "*.hpp").unwrap(); - types_builder.add("cpp", "*.hh").unwrap(); - types_builder.add("cpp", "*.hxx").unwrap(); - types_builder.add("java", "*.java").unwrap(); - types_builder.add("python", "*.py").unwrap(); - types_builder.add("go", "*.go").unwrap(); - types_builder.add("swift", "*.swift").unwrap(); - types_builder.add("php", "*.php").unwrap(); - types_builder.add("php", "*.hack").unwrap(); - types_builder.add("objc", "*.m").unwrap(); - types_builder.add("objc", "*.mm").unwrap(); - types_builder.add("rust", "*.rs").unwrap(); - types_builder.add("javascript", "*.js").unwrap(); - types_builder.add("javascript", "*.mjs").unwrap(); - types_builder.add("javascript", "*.cjs").unwrap(); - types_builder.add("javascript", "*.jsx").unwrap(); - types_builder.add("typescript", "*.ts").unwrap(); - types_builder.add("typescript", "*.mts").unwrap(); - types_builder.add("typescript", "*.cts").unwrap(); - types_builder.add("typescript", "*.tsx").unwrap(); - types_builder.select("c"); - types_builder.select("cpp"); - types_builder.select("java"); - types_builder.select("python"); - types_builder.select("go"); - types_builder.select("swift"); - types_builder.select("php"); - types_builder.select("objc"); - types_builder.select("rust"); - types_builder.select("javascript"); - types_builder.select("typescript"); - walk_builder.types(types_builder.build()?); + // language_from_path is the single source of truth for supported extensions. + // A second, case-sensitive glob allowlist used to silently drop uppercase files. if !cli.exclude.is_empty() { let mut override_builder = OverrideBuilder::new(Path::new(".")); diff --git a/src/patterns.rs b/src/patterns.rs index 9601017..cba1c26 100644 --- a/src/patterns.rs +++ b/src/patterns.rs @@ -16,6 +16,8 @@ pub enum Language { Rust, JavaScript, TypeScript, + /// TypeScript with JSX syntax; uses the TypeScript pattern catalog. + Tsx, } impl Language { @@ -31,7 +33,14 @@ impl Language { Self::Objc => cfg!(feature = "lang-objc"), Self::Rust => cfg!(feature = "lang-rust"), Self::JavaScript => cfg!(feature = "lang-javascript"), - Self::TypeScript => cfg!(feature = "lang-typescript"), + Self::TypeScript | Self::Tsx => cfg!(feature = "lang-typescript"), + } + } + + pub(crate) const fn pattern_language(self) -> Self { + match self { + Self::Tsx => Self::TypeScript, + language => language, } } @@ -356,7 +365,11 @@ impl PatternSet { } pub fn supports_language(&self, lang: Language) -> bool { - lang.is_enabled() && self.libraries.iter().any(|l| l.languages.contains(&lang)) + lang.is_enabled() + && self + .libraries + .iter() + .any(|l| l.languages.contains(&lang.pattern_language())) } } diff --git a/src/scan.rs b/src/scan.rs index cfe2b41..fd13b0b 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -38,9 +38,11 @@ define_ts_lang!( "lang-typescript", tree_sitter_typescript::LANGUAGE_TYPESCRIPT ); -// Note: For TSX files, we use the TypeScript grammar which handles most crypto detection needs. -// The TSX grammar (tree_sitter_typescript::LANGUAGE_TSX) could be used for JSX-specific parsing -// if needed in the future. +define_ts_lang!( + ts_lang_tsx, + "lang-typescript", + tree_sitter_typescript::LANGUAGE_TSX +); #[derive(Clone, Copy, Debug)] pub struct LibraryHit<'a> { @@ -58,7 +60,12 @@ pub struct AlgorithmHit<'a> { } pub fn language_from_path(path: &std::path::Path) -> Option { - let ext = path.extension()?.to_str()?.to_ascii_lowercase(); + let extension = path.extension()?.to_str()?; + // Uppercase .C conventionally denotes C++, unlike ordinary case variants. + if extension == "C" { + return Language::Cpp.is_enabled().then_some(Language::Cpp); + } + let ext = extension.to_ascii_lowercase(); let language = match ext.as_str() { "c" => Language::C, // Prefer the C++ grammar for ambiguous headers, but keep C-only builds useful. @@ -73,7 +80,8 @@ pub fn language_from_path(path: &std::path::Path) -> Option { "m" | "mm" => Language::Objc, "rs" => Language::Rust, "js" | "mjs" | "cjs" | "jsx" => Language::JavaScript, - "ts" | "mts" | "cts" | "tsx" => Language::TypeScript, + "ts" | "mts" | "cts" => Language::TypeScript, + "tsx" => Language::Tsx, _ => return None, }; language.is_enabled().then_some(language) @@ -98,6 +106,7 @@ pub fn parse(lang: Language, content: &str) -> Result { Language::Rust => ts_lang_rust()?, Language::JavaScript => ts_lang_javascript()?, Language::TypeScript => ts_lang_typescript()?, + Language::Tsx => ts_lang_tsx()?, }; parser.set_language(&ts_lang).context("set language")?; @@ -140,7 +149,7 @@ pub fn find_library_anchors<'a>( // Handle libraries without include patterns (fallback to api_regexes) for lib in &patterns.libraries { - if !lib.languages.contains(&lang) { + if !lib.languages.contains(&lang.pattern_language()) { continue; } if lib.include_regexes.is_empty() { @@ -156,7 +165,10 @@ pub fn find_library_anchors<'a>( } // Use pre-compiled include set with ownership tracking - let Some(include_set_with_owners) = patterns.include_sets_with_owners.get(&lang) else { + let Some(include_set_with_owners) = patterns + .include_sets_with_owners + .get(&lang.pattern_language()) + else { return hits; }; @@ -179,13 +191,13 @@ pub fn find_library_anchors<'a>( } pub fn has_anchor_hint(lang: Language, content: &str, patterns: &PatternSet) -> bool { - if let Some(include_set) = patterns.include_sets.get(&lang) + if let Some(include_set) = patterns.include_sets.get(&lang.pattern_language()) && include_set.is_match(content) { return true; } - if let Some(api_set) = patterns.api_sets.get(&lang) + if let Some(api_set) = patterns.api_sets.get(&lang.pattern_language()) && api_set.is_match(content) { return true; @@ -474,7 +486,7 @@ fn collect_constants( ) -> HashMap { let mut constants = HashMap::new(); - let Some(const_patterns) = patterns.constant_patterns.get(&lang) else { + let Some(const_patterns) = patterns.constant_patterns.get(&lang.pattern_language()) else { return constants; }; @@ -632,7 +644,7 @@ fn import_like_nodes<'a>(lang: Language, root: Node<'a>, content: &[u8]) -> Vec< } } // TypeScript: same as JavaScript (import statements, require calls) - Language::TypeScript => { + Language::TypeScript | Language::Tsx => { kind == "import_statement" || kind == "call_expression" && { node.child(0) @@ -708,7 +720,7 @@ fn code_symbol_nodes<'a>(lang: Language, root: Node<'a>) -> Vec> { "call_expression" | "member_expression" | "string" | "template_string" ), // TypeScript: same as JavaScript - Language::TypeScript => matches!( + Language::TypeScript | Language::Tsx => matches!( kind, "call_expression" | "member_expression" | "string" | "template_string" ), diff --git a/tests/integration.rs b/tests/integration.rs index 0e2f901..1aecaf4 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -51,7 +51,7 @@ fn should_skip_family(family: &str) -> bool { "swift" => !cfg!(feature = "lang-swift"), "php" => !cfg!(feature = "lang-php"), "objc" | "objective-c" => !cfg!(feature = "lang-objc"), - "openssl_c" => !cfg!(feature = "lang-c") || !cfg!(feature = "lang-cpp"), + "openssl_c" => !cfg!(feature = "lang-c"), "cpp" => !cfg!(feature = "lang-cpp"), "rust" => !cfg!(feature = "lang-rust"), "javascript" | "js" => !cfg!(feature = "lang-javascript"), diff --git a/tests/language_discovery.rs b/tests/language_discovery.rs new file mode 100644 index 0000000..c644dc0 --- /dev/null +++ b/tests/language_discovery.rs @@ -0,0 +1,66 @@ +use cipherscope::{patterns::Language, scan::language_from_path}; +use std::path::Path; + +#[test] +fn extensions_select_enabled_grammars() { + for (extension, language) in [ + ("PY", Language::Python), + ("JsX", Language::JavaScript), + ("TS", Language::TypeScript), + ("TSX", Language::Tsx), + ("c", Language::C), + ("C", Language::Cpp), + ] { + assert_eq!( + language_from_path(Path::new(&format!("source.{extension}"))), + language.is_enabled().then_some(language), + ); + } +} + +#[cfg(feature = "lang-python")] +#[test] +fn directory_discovery_accepts_uppercase_extensions() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write(dir.path().join("source.PY"), "import cryptography\n").unwrap(); + let output = std::process::Command::new(env!("CARGO_BIN_EXE_cipherscope")) + .arg("--roots") + .arg(dir.path()) + .output() + .unwrap(); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("PyCA cryptography")); +} + +#[cfg(feature = "lang-typescript")] +#[test] +fn tsx_uses_jsx_grammar_and_typescript_patterns() { + let source = "import { createHash } from 'node:crypto';\nexport const View = () =>
{createHash('sha256').digest('hex')}
;\n"; + let tree = cipherscope::scan::parse(Language::Tsx, source).unwrap(); + assert!( + !tree.root_node().has_error(), + "{}", + tree.root_node().to_sexp() + ); + let findings = cipherscope::scan_snippet(source, Language::Tsx, "source.tsx").unwrap(); + assert!(findings.iter().any(|hit| hit.identifier == "SHA-256")); + assert!(findings.iter().any(|hit| hit.asset_type == "library")); + + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write(dir.path().join("source.tsx"), source).unwrap(); + let output = std::process::Command::new(env!("CARGO_BIN_EXE_cipherscope")) + .arg("--roots") + .arg(dir.path()) + .output() + .unwrap(); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("SHA-256")); +} + +#[cfg(feature = "lang-typescript")] +#[test] +fn ordinary_typescript_retains_angle_bracket_type_assertions() { + let tree = + cipherscope::scan::parse(Language::TypeScript, "const value = input;\n").unwrap(); + assert!(!tree.root_node().has_error()); +}