openpencil/crates/op-codegen/src/ai/parse.rs

550 lines
20 KiB
Rust

//! Pure, deterministic ports from code-generation-pipeline.ts (lines
//! 28-220) + sanitizeName. No AI calls — fully unit-testable.
use std::collections::HashMap;
use crate::ai::types::{ChunkContract, ChunkResult, PlannedChunk};
/// Port of computeExecutionOrder (pipeline.ts:86-111). Chunks with no deps
/// get order 0; dependents get max(dep orders)+1; cycles resolve to 0.
pub fn compute_execution_order(chunks: &[PlannedChunk]) -> HashMap<String, usize> {
fn resolve(
id: &str,
chunks: &[PlannedChunk],
orders: &mut HashMap<String, usize>,
visiting: &mut Vec<String>,
) -> usize {
if let Some(o) = orders.get(id) {
return *o;
}
if visiting.iter().any(|v| v == id) {
return 0; // cycle guard (pipeline.ts:91)
}
visiting.push(id.to_string());
let chunk = chunks.iter().find(|c| c.id == id);
let order = match chunk {
Some(c) if !c.dependencies.is_empty() => {
c.dependencies
.iter()
.map(|d| resolve(d, chunks, orders, visiting))
.max()
.unwrap_or(0)
+ 1
}
_ => 0,
};
visiting.pop();
orders.insert(id.to_string(), order);
order
}
let mut orders = HashMap::new();
for c in chunks {
let mut visiting = Vec::new();
resolve(&c.id, chunks, &mut orders, &mut visiting);
}
orders
}
/// Port of cleanCode (pipeline.ts:215-220): strip lines that are ```lang or
/// ``` fences, then trim.
pub fn clean_code(raw: &str) -> String {
let mut out = String::new();
for line in raw.lines() {
if line.trim_start().starts_with("```") {
continue;
}
out.push_str(line);
out.push('\n');
}
out.trim().to_string()
}
/// True PascalCase check — port of /^[A-Z][a-zA-Z0-9]*$/.
fn is_pascal_case(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_uppercase() => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric())
}
/// Port of validateContract (pipeline.ts:28-39). Returns (valid, issues).
pub fn validate_contract(result: &ChunkResult) -> (bool, Vec<String>) {
let mut issues = Vec::new();
let name = &result.contract.component_name;
if !name.is_empty() && !is_pascal_case(name) {
issues.push(format!(
"componentName \"{name}\" is not a valid PascalCase identifier"
));
}
let is_sfc = result.code.contains("<script")
|| result.code.contains("<template")
|| result.code.contains("<style");
if !name.is_empty() && !is_sfc && !result.code.contains(name.as_str()) {
issues.push(format!(
"componentName \"{name}\" not found in generated code"
));
}
(issues.is_empty(), issues)
}
/// Port of sanitizeName (pen-core) — PascalCase a free-text label: split on
/// non-alphanumerics, uppercase each word's first letter, join.
pub fn sanitize_name(input: &str) -> String {
input
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|w| !w.is_empty())
.map(|w| {
let mut ch = w.chars();
match ch.next() {
Some(f) => f.to_ascii_uppercase().to_string() + ch.as_str(),
None => String::new(),
}
})
.collect()
}
/// Port of runPlanning's JSON extraction (pipeline.ts:516): first `{` to
/// last `}` slice.
pub fn extract_plan_json(response: &str) -> Option<String> {
let start = response.find('{')?;
let end = response.rfind('}')?;
if end >= start {
Some(response[start..=end].to_string())
} else {
None
}
}
/// Try-parse a contract JSON string (pipeline.ts:160-181). Strips fences,
/// requires a non-empty componentName, stamps chunk_id.
fn try_parse_contract(s: &str, chunk_id: &str) -> Option<ChunkContract> {
let cleaned = clean_code(s);
let mut parsed: ChunkContract = serde_json::from_str(cleaned.trim()).ok()?;
if parsed.component_name.is_empty() {
return None;
}
parsed.chunk_id = chunk_id.to_string();
Some(parsed)
}
/// Infer a contract from code when no JSON was emitted (pipeline.ts:183-213).
fn infer_contract_from_code(code: &str, chunk_id: &str) -> ChunkContract {
let component_name = infer_component_name(code).unwrap_or_default();
// Mirror TS `inferContractFromCode`: also harvest `import ... from '...'`
// sources (specifiers left empty, matching the TS port).
let imports = infer_imports_from_code(code);
ChunkContract {
chunk_id: chunk_id.to_string(),
component_name,
imports,
..ChunkContract::default()
}
}
/// Extract `import ... from '<source>'` sources (TS: `inferContractFromCode`
/// import regex). Specifiers are left empty, mirroring the TS port.
fn infer_imports_from_code(code: &str) -> Vec<crate::ai::types::ImportDef> {
let mut imports = Vec::new();
let mut rest = code;
while let Some(import_idx) = rest.find("import ") {
// Always advance at least past this `import ` so the loop terminates.
let next_scan_start = import_idx + "import ".len();
let after_import = &rest[import_idx..];
// Find the `from` keyword, then the quoted source after it.
if let Some(from_idx) = after_import.find(" from ") {
let after_from = &after_import[from_idx + " from ".len()..];
let trimmed = after_from.trim_start();
if let Some(quote) = trimmed.chars().next().filter(|&c| c == '\'' || c == '"') {
let body = &trimmed[1..];
if let Some(end) = body.find(quote) {
let source = &body[..end];
if !source.is_empty() {
imports.push(crate::ai::types::ImportDef {
source: source.to_string(),
specifiers: Vec::new(),
});
}
// Advance just past the closing quote of `body` within
// `rest`, computed via byte offsets so we never land on a
// multi-byte boundary.
let body_off = byte_offset_in(rest, body);
rest = &rest[body_off + end + quote.len_utf8()..];
continue;
}
}
}
// No well-formed `from '...'` after this `import` — skip it.
rest = &rest[next_scan_start..];
}
imports
}
/// Byte offset of `sub` within `parent` (both must be the same allocation —
/// `sub` is a sub-slice of `parent`). Panic-free; clamps to `parent.len()`.
fn byte_offset_in(parent: &str, sub: &str) -> usize {
let parent_start = parent.as_ptr() as usize;
let sub_start = sub.as_ptr() as usize;
sub_start.saturating_sub(parent_start).min(parent.len())
}
/// Extract a component name from common export forms (pipeline.ts:188-194).
/// Ordering + guards mirror the TS `exportMatch` chain exactly:
/// export default function Name → export function PascalName →
/// (non-SFC) export default class Name → Kotlin `fun PascalName(` →
/// SwiftUI `struct Name: View` → Dart/Flutter `class Name extends`.
fn infer_component_name(code: &str) -> Option<String> {
let is_sfc = code.contains("<script") || code.contains("<template") || code.contains("<style");
let ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
// export default function Name (\w name, any case)
if let Some(n) = capture_after(code, "export default function ", ident) {
return Some(n);
}
// export function PascalName (PascalCase only)
if let Some(n) = capture_after(code, "export function ", ident) {
if n.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
return Some(n);
}
}
// export default class Name (\w name) — only when NOT an SFC.
if !is_sfc {
if let Some(n) = capture_after(code, "export default class ", ident) {
return Some(n);
}
}
// Kotlin: fun PascalName( (PascalCase only)
if let Some(n) = capture_fun_pascal(code) {
return Some(n);
}
// SwiftUI: struct Name: View
if let Some(n) = capture_between(code, "struct ", ": View") {
return Some(n.trim().to_string());
}
// Dart/Flutter: class Name extends
if let Some(n) = capture_after(code, "class ", ident) {
if code.contains(&format!("class {n} extends")) {
return Some(n);
}
}
None
}
/// Kotlin `fun PascalName(` — port of /fun\s+([A-Z]\w*)\s*\(/. Captures a
/// PascalCase identifier after `fun ` that is followed by `(` (allowing
/// whitespace before the paren).
fn capture_fun_pascal(code: &str) -> Option<String> {
let mut rest = code;
while let Some(idx) = rest.find("fun ") {
let after = &rest[idx + "fun ".len()..];
let after = after.trim_start();
let name: String = after
.chars()
.take_while(|&c| c.is_ascii_alphanumeric() || c == '_')
.collect();
if name.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
// Must be followed by `(` (optionally after whitespace).
let tail = after[name.len()..].trim_start();
if tail.starts_with('(') {
return Some(name);
}
}
rest = &rest[idx + "fun ".len()..];
}
None
}
fn capture_after(hay: &str, prefix: &str, pred: impl Fn(char) -> bool) -> Option<String> {
let idx = hay.find(prefix)? + prefix.len();
let name: String = hay[idx..].chars().take_while(|&c| pred(c)).collect();
if name.is_empty() {
None
} else {
Some(name)
}
}
fn capture_between(hay: &str, start: &str, end: &str) -> Option<String> {
let s = hay.find(start)? + start.len();
let e = hay[s..].find(end)? + s;
Some(hay[s..e].to_string())
}
/// Port of parseChunkResponse (pipeline.ts:117-158). Four strategies.
pub fn parse_chunk_response(response: &str, chunk_id: &str) -> ChunkResult {
const SEP: &str = "---CONTRACT---";
if let Some(idx) = response.find(SEP) {
let code = clean_code(&response[..idx]);
let contract_str = response[idx + SEP.len()..].trim();
if let Some(contract) = try_parse_contract(contract_str, chunk_id) {
return ChunkResult {
chunk_id: chunk_id.into(),
code,
contract,
};
}
}
// Strategy 2 + 3 keep their staged structure (separator vs trailing
// object); the nested `if let ... { if ... }` is intentional.
#[allow(clippy::collapsible_if)]
if let Some((block, inner)) = find_json_fence(response) {
if inner.contains("\"componentName\"") {
if let Some(contract) = try_parse_contract(inner, chunk_id) {
let block_start = response.find(block).unwrap_or(0);
let code = clean_code(&response[..block_start]);
return ChunkResult {
chunk_id: chunk_id.into(),
code,
contract,
};
}
}
}
#[allow(clippy::collapsible_if)]
if let Some(obj) = find_trailing_component_object(response) {
if let Some(contract) = try_parse_contract(&obj, chunk_id) {
let json_start = response.rfind(&obj).unwrap_or(0);
let code = clean_code(&response[..json_start]);
return ChunkResult {
chunk_id: chunk_id.into(),
code,
contract,
};
}
}
let code = clean_code(response);
let contract = infer_contract_from_code(&code, chunk_id);
ChunkResult {
chunk_id: chunk_id.into(),
code,
contract,
}
}
/// Find the first ```json ... ``` block; return (full_block, inner_json).
fn find_json_fence(resp: &str) -> Option<(&str, &str)> {
let open = resp.find("```json")?;
let after = open + "```json".len();
let close_rel = resp[after..].find("```")?;
let close = after + close_rel;
let inner = resp[after..close].trim_matches(|c| c == '\n' || c == '\r' || c == ' ');
Some((&resp[open..close + 3], inner))
}
/// Find a trailing `{...}` object containing "componentName" (no nesting).
/// END-ANCHORED to mirror the TS regex `(\{[^{}]*"componentName"[^{}]*\})\s*$`:
/// the closing `}` must be the LAST non-whitespace char of the response, so a
/// `{...} trailing text` shape falls through to strategy 4 (inference).
fn find_trailing_component_object(resp: &str) -> Option<String> {
let trimmed = resp.trim_end();
let end = trimmed.rfind('}')?;
// The `}` must be the last non-whitespace char (regex `\}\s*$`).
if end != trimmed.len() - 1 {
return None;
}
let start = trimmed[..end].rfind('{')?;
let obj = &trimmed[start..=end];
if obj.contains("\"componentName\"") && !obj[1..obj.len() - 1].contains('{') {
Some(obj.to_string())
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn execution_order_zero_for_no_deps_then_increments() {
let chunks = vec![
planned("a", &[]),
planned("b", &["a"]),
planned("c", &["a", "b"]),
];
let orders = compute_execution_order(&chunks);
assert_eq!(orders["a"], 0);
assert_eq!(orders["b"], 1);
assert_eq!(orders["c"], 2);
}
#[test]
fn execution_order_breaks_cycles_at_zero() {
// TS parity (verified against code-generation-pipeline.ts:86-111 run
// in Node): the cycle guard returns 0 for the back-edge, so the
// traversal terminates with finite orders instead of recursing
// forever. Resolving `a` first yields a=2, b=1; both are bounded.
let chunks = vec![planned("a", &["b"]), planned("b", &["a"])];
let orders = compute_execution_order(&chunks);
assert_eq!(orders["a"], 2);
assert_eq!(orders["b"], 1);
}
#[test]
fn parse_chunk_strategy1_contract_separator() {
let resp = "export default function Card(){}\n---CONTRACT---\n{\"componentName\":\"Card\"}";
let r = parse_chunk_response(resp, "c1");
assert_eq!(r.contract.component_name, "Card");
assert_eq!(r.contract.chunk_id, "c1");
assert!(r.code.contains("function Card"));
assert!(!r.code.contains("CONTRACT"));
}
#[test]
fn parse_chunk_strategy2_json_fence() {
let resp = "code here\n```json\n{\"componentName\":\"Hero\"}\n```";
let r = parse_chunk_response(resp, "c2");
assert_eq!(r.contract.component_name, "Hero");
assert!(r.code.contains("code here"));
assert!(!r.code.contains("componentName"));
}
#[test]
fn parse_chunk_strategy4_infer_from_export() {
let resp = "```tsx\nexport default function Footer() { return null }\n```";
let r = parse_chunk_response(resp, "c4");
assert_eq!(r.contract.component_name, "Footer");
assert!(!r.code.contains("```"));
}
#[test]
fn infer_kotlin_fun_pascal_case() {
// Kotlin Composable: `fun Card(` → Card (PascalCase only).
let r = parse_chunk_response("fun Card() { }", "x");
assert_eq!(r.contract.component_name, "Card");
}
#[test]
fn infer_export_default_class_when_not_sfc() {
let r = parse_chunk_response("export default class Widget extends X {}", "x");
assert_eq!(r.contract.component_name, "Widget");
}
#[test]
fn infer_skips_export_default_class_for_sfc() {
// An SFC marker (`<template>`) must suppress the class-name pattern
// (TS guard `!isSFC`); falls through with no inferred name.
let r = parse_chunk_response(
"<template><div/></template>\nexport default class Widget {}",
"x",
);
assert_eq!(r.contract.component_name, "");
}
#[test]
fn infer_harvests_import_sources() {
let r = parse_chunk_response(
"import { useState } from 'react'\nexport default function App(){}",
"x",
);
assert_eq!(r.contract.component_name, "App");
assert_eq!(r.contract.imports.len(), 1);
assert_eq!(r.contract.imports[0].source, "react");
}
#[test]
fn infer_harvests_multiple_import_sources() {
let r = parse_chunk_response(
"import { useState } from \"react\"\nimport clsx from 'clsx'\nexport default function App(){}",
"x",
);
let sources: Vec<&str> = r
.contract
.imports
.iter()
.map(|i| i.source.as_str())
.collect();
assert_eq!(sources, vec!["react", "clsx"]);
}
#[test]
fn trailing_object_must_end_at_brace() {
// strategy 3 requires the `}` be the last non-whitespace char.
// A trailing-text response must NOT use strategy 3; it falls through
// to strategy 4 inference (which finds the exported function name).
let resp = "export default function Card(){}\n{\"componentName\":\"Wrong\"} trailing text";
let r = parse_chunk_response(resp, "x");
assert_eq!(r.contract.component_name, "Card");
// The JSON object remains part of code (not stripped as a contract).
assert!(r.code.contains("componentName"));
}
#[test]
fn trailing_object_used_when_ending_exactly_at_brace() {
let resp = "export default function Card(){}\n{\"componentName\":\"Card\"}";
let r = parse_chunk_response(resp, "x");
assert_eq!(r.contract.component_name, "Card");
assert!(!r.code.contains("componentName"));
}
#[test]
fn clean_code_strips_fences() {
assert_eq!(clean_code("```ts\nlet x = 1;\n```"), "let x = 1;");
}
#[test]
fn validate_contract_rejects_non_pascal_and_missing_name() {
let bad = ChunkResult {
chunk_id: "x".into(),
code: "export default function card(){}".into(),
contract: ChunkContract {
component_name: "card".into(),
..Default::default()
},
};
assert!(!validate_contract(&bad).0);
let ok = ChunkResult {
chunk_id: "x".into(),
code: "export default function Card(){ return null }".into(),
contract: ChunkContract {
component_name: "Card".into(),
..Default::default()
},
};
assert!(validate_contract(&ok).0);
}
#[test]
fn validate_contract_allows_sfc_without_name_in_code() {
let sfc = ChunkResult {
chunk_id: "x".into(),
code: "<template><div/></template><script>export default {}</script>".into(),
contract: ChunkContract {
component_name: "Widget".into(),
..Default::default()
},
};
assert!(validate_contract(&sfc).0);
}
#[test]
fn sanitize_name_pascal_cases() {
assert_eq!(sanitize_name("nav bar"), "NavBar");
assert_eq!(sanitize_name("hero-section"), "HeroSection");
}
#[test]
fn extract_plan_json_finds_object() {
let resp = "Here is the plan:\n{\"chunks\":[],\"rootLayout\":{}}\nDone.";
let json = extract_plan_json(resp).unwrap();
assert!(json.starts_with('{') && json.ends_with('}'));
}
fn planned(id: &str, deps: &[&str]) -> crate::ai::types::PlannedChunk {
crate::ai::types::PlannedChunk {
id: id.into(),
name: id.into(),
node_ids: vec![],
role: String::new(),
suggested_component_name: id.into(),
dependencies: deps.iter().map(|s| s.to_string()).collect(),
}
}
}