Skip to main content

aptu_coder_core/languages/
regex_fallback.rs

1// SPDX-FileCopyrightText: 2026 aptu-coder contributors
2// SPDX-License-Identifier: Apache-2.0
3//! Regex-based semantic extraction for formats without tree-sitter grammars.
4//!
5//! Covers CSS, YAML, JSON, TOML, and Astro. Each function returns a
6//! [`SemanticAnalysis`] with `functions` populated as a best-effort symbol
7//! list (selectors, keys, section headers, or frontmatter exports). All
8//! errors are handled internally; callers always receive a valid (possibly
9//! empty) result.
10
11use crate::parser::SemanticExtractor;
12use crate::types::{FunctionInfo, SemanticAnalysis};
13use regex::Regex;
14use std::sync::LazyLock;
15use tracing;
16
17// --- compiled patterns (compiled once at startup) ---
18
19static CSS_SELECTOR: LazyLock<Regex> = LazyLock::new(|| {
20    // SAFETY: static regex patterns are hardcoded literals; compilation never fails.
21    #[allow(clippy::expect_used)]
22    Regex::new(r"^[.#][\w-]+[\s,:{]").expect("valid CSS selector pattern")
23});
24
25static YAML_TOP_KEY: LazyLock<Regex> = LazyLock::new(|| {
26    // SAFETY: static regex patterns are hardcoded literals; compilation never fails.
27    #[allow(clippy::expect_used)]
28    Regex::new(r"^(\w[\w-]*): ").expect("valid YAML top-level key pattern")
29});
30
31static JSON_FIRST_KEY: LazyLock<Regex> = LazyLock::new(|| {
32    // SAFETY: static regex patterns are hardcoded literals; compilation never fails.
33    #[allow(clippy::expect_used)]
34    Regex::new(r#"^\s{0,2}"(\w+)":"#).expect("valid JSON first-level key pattern")
35});
36
37static TOML_SECTION: LazyLock<Regex> = LazyLock::new(|| {
38    // SAFETY: static regex patterns are hardcoded literals; compilation never fails.
39    #[allow(clippy::expect_used)]
40    Regex::new(r"^\[([^\]]+)\]").expect("valid TOML section header pattern")
41});
42
43// --- extraction functions ---
44
45/// Extract CSS class/ID selectors as function entries.
46pub fn extract_css(source: &str) -> SemanticAnalysis {
47    let mut functions = Vec::new();
48    for (idx, line) in source.lines().enumerate() {
49        let trimmed = line.trim_start();
50        if CSS_SELECTOR.is_match(trimmed) {
51            let name = trimmed
52                .trim_end_matches(|c: char| c == '{' || c == ',' || c == ':' || c.is_whitespace())
53                .to_string();
54            if !name.is_empty() {
55                let line_no = idx + 1;
56                functions.push(FunctionInfo {
57                    name,
58                    line: line_no,
59                    end_line: line_no,
60                    parameters: Vec::new(),
61                    return_type: None,
62                });
63            }
64        }
65    }
66    SemanticAnalysis {
67        functions,
68        ..Default::default()
69    }
70}
71
72/// Extract YAML top-level keys as function entries.
73pub fn extract_yaml(source: &str) -> SemanticAnalysis {
74    let mut functions = Vec::new();
75    for (idx, line) in source.lines().enumerate() {
76        if let Some(caps) = YAML_TOP_KEY.captures(line) {
77            let name = caps[1].to_string();
78            let line_no = idx + 1;
79            functions.push(FunctionInfo {
80                name,
81                line: line_no,
82                end_line: line_no,
83                parameters: Vec::new(),
84                return_type: None,
85            });
86        }
87    }
88    SemanticAnalysis {
89        functions,
90        ..Default::default()
91    }
92}
93
94/// Extract JSON first-level string keys as function entries.
95pub fn extract_json(source: &str) -> SemanticAnalysis {
96    let mut functions = Vec::new();
97    for (idx, line) in source.lines().enumerate() {
98        if let Some(caps) = JSON_FIRST_KEY.captures(line) {
99            let name = caps[1].to_string();
100            let line_no = idx + 1;
101            functions.push(FunctionInfo {
102                name,
103                line: line_no,
104                end_line: line_no,
105                parameters: Vec::new(),
106                return_type: None,
107            });
108        }
109    }
110    SemanticAnalysis {
111        functions,
112        ..Default::default()
113    }
114}
115
116/// Extract TOML section headers as function entries.
117pub fn extract_toml(source: &str) -> SemanticAnalysis {
118    let mut functions = Vec::new();
119    for (idx, line) in source.lines().enumerate() {
120        if let Some(caps) = TOML_SECTION.captures(line) {
121            let name = caps[1].to_string();
122            let line_no = idx + 1;
123            functions.push(FunctionInfo {
124                name,
125                line: line_no,
126                end_line: line_no,
127                parameters: Vec::new(),
128                return_type: None,
129            });
130        }
131    }
132    SemanticAnalysis {
133        functions,
134        ..Default::default()
135    }
136}
137
138/// Extract Astro frontmatter imports/exports via the TypeScript extractor.
139///
140/// Splits on lines starting with `---`, extracts the block between the first
141/// and second delimiter, then delegates to [`SemanticExtractor::extract`] with
142/// `language = "typescript"`. Returns [`Default::default`] when no frontmatter
143/// is found or extraction fails.
144#[must_use]
145pub fn extract_astro(source: &str) -> SemanticAnalysis {
146    let block = extract_frontmatter(source);
147    let Some(frontmatter) = block else {
148        return SemanticAnalysis::default();
149    };
150    SemanticExtractor::extract(&frontmatter, "typescript", None, None).unwrap_or_else(|err| {
151        tracing::warn!(error = %err, "astro TypeScript extractor failed; returning empty analysis");
152        SemanticAnalysis::default()
153    })
154}
155
156fn extract_frontmatter(source: &str) -> Option<String> {
157    let mut delimiters = source
158        .lines()
159        .enumerate()
160        .filter(|(_, line)| line.starts_with("---"));
161    let (first, _) = delimiters.next()?;
162    let (second, _) = delimiters.next()?;
163    let block: Vec<&str> = source
164        .lines()
165        .skip(first + 1)
166        .take(second - first - 1)
167        .collect();
168    Some(block.join("\n"))
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn test_regex_fallback_css_basic() {
177        // Arrange
178        let source = ".container {\n  color: red;\n}\n#header {\n  font-size: 16px;\n}\n";
179        // Act
180        let result = extract_css(source);
181        // Assert
182        let names: Vec<&str> = result.functions.iter().map(|f| f.name.as_str()).collect();
183        assert!(
184            names.contains(&".container"),
185            "expected .container in {names:?}"
186        );
187        assert!(names.contains(&"#header"), "expected #header in {names:?}");
188    }
189
190    #[test]
191    fn test_regex_fallback_yaml_basic() {
192        // Arrange
193        let source = "name: my-project\nversion: 1.0\n  nested: value\n";
194        // Act
195        let result = extract_yaml(source);
196        // Assert
197        let names: Vec<&str> = result.functions.iter().map(|f| f.name.as_str()).collect();
198        assert!(names.contains(&"name"), "expected name in {names:?}");
199        assert!(names.contains(&"version"), "expected version in {names:?}");
200        // nested key has leading spaces so must NOT appear
201        assert!(
202            !names.contains(&"nested"),
203            "nested must not appear in {names:?}"
204        );
205    }
206
207    #[test]
208    fn test_regex_fallback_json_basic() {
209        // Arrange
210        let source = "{\n  \"name\": \"project\",\n  \"version\": \"1.0\"\n}\n";
211        // Act
212        let result = extract_json(source);
213        // Assert
214        let names: Vec<&str> = result.functions.iter().map(|f| f.name.as_str()).collect();
215        assert!(names.contains(&"name"), "expected name in {names:?}");
216        assert!(names.contains(&"version"), "expected version in {names:?}");
217    }
218
219    #[test]
220    fn test_regex_fallback_toml_basic() {
221        // Arrange
222        let source = "[package]\nname = \"my-crate\"\n\n[dependencies]\nregex = \"1\"\n";
223        // Act
224        let result = extract_toml(source);
225        // Assert
226        let names: Vec<&str> = result.functions.iter().map(|f| f.name.as_str()).collect();
227        assert!(names.contains(&"package"), "expected package in {names:?}");
228        assert!(
229            names.contains(&"dependencies"),
230            "expected dependencies in {names:?}"
231        );
232    }
233
234    #[test]
235    fn test_regex_fallback_astro_basic() {
236        // Arrange: Astro file with TypeScript frontmatter
237        let source =
238            "---\nimport Foo from './Foo.astro';\nconst title = 'Hello';\n---\n<h1>{title}</h1>\n";
239        // Act
240        let result = extract_astro(source);
241        // Assert: TypeScript extractor should find the import
242        assert!(
243            !result.imports.is_empty() || !result.functions.is_empty(),
244            "expected imports or functions from frontmatter; got empty result"
245        );
246    }
247
248    #[test]
249    fn test_regex_fallback_astro_no_frontmatter() {
250        // Arrange: Astro file without --- delimiters
251        let source = "<h1>Hello World</h1>\n<p>No frontmatter here.</p>\n";
252        // Act
253        let result = extract_astro(source);
254        // Assert: returns empty without panic
255        assert!(result.functions.is_empty());
256        assert!(result.imports.is_empty());
257    }
258
259    #[test]
260    fn test_regex_fallback_empty_file() {
261        // Arrange: empty source for each format
262        assert!(extract_css("").functions.is_empty());
263        assert!(extract_yaml("").functions.is_empty());
264        assert!(extract_json("").functions.is_empty());
265        assert!(extract_toml("").functions.is_empty());
266        assert!(extract_astro("").functions.is_empty());
267    }
268}