diff --git a/.omg/state/learn-watch.json b/.omg/state/learn-watch.json index 1c84e8781..0819904fc 100644 --- a/.omg/state/learn-watch.json +++ b/.omg/state/learn-watch.json @@ -1,6 +1,6 @@ { "last_session_id": "67e970c5-7790-4d38-ba0b-527b9f349c49", - "last_event_key": "67e970c5-7790-4d38-ba0b-527b9f349c49:transcript:70f7463d95fcfa9de1ead358c8fab10cd302abfc43cc274eb68fa952a0c97675", + "last_event_key": "67e970c5-7790-4d38-ba0b-527b9f349c49:transcript:01389baa63d7cd14460c1725484e72f23651a4b02cc12b87f3b6f1bf6043a8d0", "last_prompted_session_id": "", "last_reason": "short-session", "last_prompted_at": "", @@ -8,5 +8,5 @@ "last_actionable_message_count": 0, "deep_interview_lock_active": false, "deep_interview_lock_source": "/home/mhugo/code/singularity-forge/.omg/state/deep-interview.json", - "updated_at": "2026-05-04T17:09:50.283Z" + "updated_at": "2026-05-04T19:57:31.227Z" } diff --git a/.siftignore b/.siftignore index ba14dba55..0725ee98e 100644 --- a/.siftignore +++ b/.siftignore @@ -9,11 +9,17 @@ node_modules/** **/__pycache__/** *.pyc *.egg-info/** -build/** -dist/** -target/** -vendor/** -coverage/** +**/build/** +**/dist/** +**/target/** +**/vendor/** +**/coverage/** .cache/** tmp/** *.log +dist-test/** +packages/*/dist/** +packages/*/target/** +rust-engine/target/** +rust-engine/addon/*.node +**/tsconfig.tsbuildinfo diff --git a/rust-engine/crates/ast/src/ast.rs b/rust-engine/crates/ast/src/ast.rs index a648294cc..da0f93294 100644 --- a/rust-engine/crates/ast/src/ast.rs +++ b/rust-engine/crates/ast/src/ast.rs @@ -1,8 +1,13 @@ //! AST-aware structural search and rewrite powered by ast-grep. -use std::{collections::{BTreeMap, BTreeSet, HashMap}, path::{Path, PathBuf}}; +use std::{ + collections::{BTreeMap, BTreeSet, HashMap}, + path::{Path, PathBuf}, +}; -use ast_grep_core::{Language, MatchStrictness, matcher::Pattern, source::Edit, tree_sitter::LanguageExt}; +use ast_grep_core::{ + matcher::Pattern, source::Edit, tree_sitter::LanguageExt, Language, MatchStrictness, +}; use ignore::WalkBuilder; use napi::bindgen_prelude::*; use napi_derive::napi; @@ -13,420 +18,871 @@ const DEFAULT_FIND_LIMIT: u32 = 50; #[napi(object)] pub struct AstFindOptions { - pub patterns: Option>, - pub lang: Option, - pub path: Option, - pub glob: Option, - pub selector: Option, - pub strictness: Option, - pub limit: Option, - pub offset: Option, - #[napi(js_name = "includeMeta")] - pub include_meta: Option, - pub context: Option, + pub patterns: Option>, + pub lang: Option, + pub path: Option, + pub glob: Option, + pub selector: Option, + pub strictness: Option, + pub limit: Option, + pub offset: Option, + #[napi(js_name = "includeMeta")] + pub include_meta: Option, + pub context: Option, } #[napi(object)] pub struct AstFindMatch { - pub path: String, - pub text: String, - #[napi(js_name = "byteStart")] - pub byte_start: u32, - #[napi(js_name = "byteEnd")] - pub byte_end: u32, - #[napi(js_name = "startLine")] - pub start_line: u32, - #[napi(js_name = "startColumn")] - pub start_column: u32, - #[napi(js_name = "endLine")] - pub end_line: u32, - #[napi(js_name = "endColumn")] - pub end_column: u32, - #[napi(js_name = "metaVariables")] - pub meta_variables: Option>, + pub path: String, + pub text: String, + #[napi(js_name = "byteStart")] + pub byte_start: u32, + #[napi(js_name = "byteEnd")] + pub byte_end: u32, + #[napi(js_name = "startLine")] + pub start_line: u32, + #[napi(js_name = "startColumn")] + pub start_column: u32, + #[napi(js_name = "endLine")] + pub end_line: u32, + #[napi(js_name = "endColumn")] + pub end_column: u32, + #[napi(js_name = "metaVariables")] + pub meta_variables: Option>, } #[napi(object)] pub struct AstFindResult { - pub matches: Vec, - #[napi(js_name = "totalMatches")] - pub total_matches: u32, - #[napi(js_name = "filesWithMatches")] - pub files_with_matches: u32, - #[napi(js_name = "filesSearched")] - pub files_searched: u32, - #[napi(js_name = "limitReached")] - pub limit_reached: bool, - #[napi(js_name = "parseErrors")] - pub parse_errors: Option>, + pub matches: Vec, + #[napi(js_name = "totalMatches")] + pub total_matches: u32, + #[napi(js_name = "filesWithMatches")] + pub files_with_matches: u32, + #[napi(js_name = "filesSearched")] + pub files_searched: u32, + #[napi(js_name = "limitReached")] + pub limit_reached: bool, + #[napi(js_name = "parseErrors")] + pub parse_errors: Option>, } #[napi(object)] pub struct AstReplaceOptions { - pub rewrites: Option>, - pub lang: Option, - pub path: Option, - pub glob: Option, - pub selector: Option, - pub strictness: Option, - #[napi(js_name = "dryRun")] - pub dry_run: Option, - #[napi(js_name = "maxReplacements")] - pub max_replacements: Option, - #[napi(js_name = "maxFiles")] - pub max_files: Option, - #[napi(js_name = "failOnParseError")] - pub fail_on_parse_error: Option, + pub rewrites: Option>, + pub lang: Option, + pub path: Option, + pub glob: Option, + pub selector: Option, + pub strictness: Option, + #[napi(js_name = "dryRun")] + pub dry_run: Option, + #[napi(js_name = "maxReplacements")] + pub max_replacements: Option, + #[napi(js_name = "maxFiles")] + pub max_files: Option, + #[napi(js_name = "failOnParseError")] + pub fail_on_parse_error: Option, } #[napi(object)] pub struct AstReplaceChange { - pub path: String, pub before: String, pub after: String, - #[napi(js_name = "byteStart")] - pub byte_start: u32, - #[napi(js_name = "byteEnd")] - pub byte_end: u32, - #[napi(js_name = "deletedLength")] - pub deleted_length: u32, - #[napi(js_name = "startLine")] - pub start_line: u32, - #[napi(js_name = "startColumn")] - pub start_column: u32, - #[napi(js_name = "endLine")] - pub end_line: u32, - #[napi(js_name = "endColumn")] - pub end_column: u32, + pub path: String, + pub before: String, + pub after: String, + #[napi(js_name = "byteStart")] + pub byte_start: u32, + #[napi(js_name = "byteEnd")] + pub byte_end: u32, + #[napi(js_name = "deletedLength")] + pub deleted_length: u32, + #[napi(js_name = "startLine")] + pub start_line: u32, + #[napi(js_name = "startColumn")] + pub start_column: u32, + #[napi(js_name = "endLine")] + pub end_line: u32, + #[napi(js_name = "endColumn")] + pub end_column: u32, } #[napi(object)] -pub struct AstReplaceFileChange { pub path: String, pub count: u32 } +pub struct AstReplaceFileChange { + pub path: String, + pub count: u32, +} #[napi(object)] pub struct AstReplaceResult { - pub changes: Vec, - #[napi(js_name = "fileChanges")] - pub file_changes: Vec, - #[napi(js_name = "totalReplacements")] - pub total_replacements: u32, - #[napi(js_name = "filesTouched")] - pub files_touched: u32, - #[napi(js_name = "filesSearched")] - pub files_searched: u32, - pub applied: bool, - #[napi(js_name = "limitReached")] - pub limit_reached: bool, - #[napi(js_name = "parseErrors")] - pub parse_errors: Option>, + pub changes: Vec, + #[napi(js_name = "fileChanges")] + pub file_changes: Vec, + #[napi(js_name = "totalReplacements")] + pub total_replacements: u32, + #[napi(js_name = "filesTouched")] + pub files_touched: u32, + #[napi(js_name = "filesSearched")] + pub files_searched: u32, + pub applied: bool, + #[napi(js_name = "limitReached")] + pub limit_reached: bool, + #[napi(js_name = "parseErrors")] + pub parse_errors: Option>, } -struct FileCandidate { absolute_path: PathBuf, display_path: String } -struct PendingFileChange { change: AstReplaceChange, edit: Edit } -fn to_u32(value: usize) -> u32 { value.min(u32::MAX as usize) as u32 } +struct FileCandidate { + absolute_path: PathBuf, + display_path: String, +} +struct PendingFileChange { + change: AstReplaceChange, + edit: Edit, +} +fn to_u32(value: usize) -> u32 { + value.min(u32::MAX as usize) as u32 +} static LANG_ALIASES: phf::Map<&'static str, SupportLang> = phf::phf_map! { - "bash" => SupportLang::Bash, "sh" => SupportLang::Bash, - "c" => SupportLang::C, "cpp" => SupportLang::Cpp, "c++" => SupportLang::Cpp, - "cc" => SupportLang::Cpp, "cxx" => SupportLang::Cpp, - "csharp" => SupportLang::CSharp, "c#" => SupportLang::CSharp, "cs" => SupportLang::CSharp, - "css" => SupportLang::Css, "diff" => SupportLang::Diff, "patch" => SupportLang::Diff, - "elixir" => SupportLang::Elixir, "ex" => SupportLang::Elixir, - "go" => SupportLang::Go, "golang" => SupportLang::Go, - "haskell" => SupportLang::Haskell, "hs" => SupportLang::Haskell, - "hcl" => SupportLang::Hcl, "tf" => SupportLang::Hcl, "tfvars" => SupportLang::Hcl, "terraform" => SupportLang::Hcl, - "html" => SupportLang::Html, "htm" => SupportLang::Html, - "java" => SupportLang::Java, - "javascript" => SupportLang::JavaScript, "js" => SupportLang::JavaScript, - "jsx" => SupportLang::JavaScript, "mjs" => SupportLang::JavaScript, "cjs" => SupportLang::JavaScript, - "json" => SupportLang::Json, "julia" => SupportLang::Julia, "jl" => SupportLang::Julia, - "kotlin" => SupportLang::Kotlin, "kt" => SupportLang::Kotlin, - "lua" => SupportLang::Lua, "make" => SupportLang::Make, "makefile" => SupportLang::Make, - "markdown" => SupportLang::Markdown, "md" => SupportLang::Markdown, "mdx" => SupportLang::Markdown, - "nix" => SupportLang::Nix, "objc" => SupportLang::ObjC, "objective-c" => SupportLang::ObjC, - "odin" => SupportLang::Odin, "php" => SupportLang::Php, - "python" => SupportLang::Python, "py" => SupportLang::Python, - "regex" => SupportLang::Regex, "ruby" => SupportLang::Ruby, "rb" => SupportLang::Ruby, - "rust" => SupportLang::Rust, "rs" => SupportLang::Rust, - "scala" => SupportLang::Scala, "solidity" => SupportLang::Solidity, "sol" => SupportLang::Solidity, - "starlark" => SupportLang::Starlark, "star" => SupportLang::Starlark, - "swift" => SupportLang::Swift, "toml" => SupportLang::Toml, "tsx" => SupportLang::Tsx, - "typescript" => SupportLang::TypeScript, "ts" => SupportLang::TypeScript, - "mts" => SupportLang::TypeScript, "cts" => SupportLang::TypeScript, - "verilog" => SupportLang::Verilog, "systemverilog" => SupportLang::Verilog, "sv" => SupportLang::Verilog, - "xml" => SupportLang::Xml, "xsl" => SupportLang::Xml, "svg" => SupportLang::Xml, - "yaml" => SupportLang::Yaml, "yml" => SupportLang::Yaml, "zig" => SupportLang::Zig, + "bash" => SupportLang::Bash, "sh" => SupportLang::Bash, + "c" => SupportLang::C, "cpp" => SupportLang::Cpp, "c++" => SupportLang::Cpp, + "cc" => SupportLang::Cpp, "cxx" => SupportLang::Cpp, + "csharp" => SupportLang::CSharp, "c#" => SupportLang::CSharp, "cs" => SupportLang::CSharp, + "css" => SupportLang::Css, "diff" => SupportLang::Diff, "patch" => SupportLang::Diff, + "elixir" => SupportLang::Elixir, "ex" => SupportLang::Elixir, + "go" => SupportLang::Go, "golang" => SupportLang::Go, + "haskell" => SupportLang::Haskell, "hs" => SupportLang::Haskell, + "hcl" => SupportLang::Hcl, "tf" => SupportLang::Hcl, "tfvars" => SupportLang::Hcl, "terraform" => SupportLang::Hcl, + "html" => SupportLang::Html, "htm" => SupportLang::Html, + "java" => SupportLang::Java, + "javascript" => SupportLang::JavaScript, "js" => SupportLang::JavaScript, + "jsx" => SupportLang::JavaScript, "mjs" => SupportLang::JavaScript, "cjs" => SupportLang::JavaScript, + "json" => SupportLang::Json, "julia" => SupportLang::Julia, "jl" => SupportLang::Julia, + "kotlin" => SupportLang::Kotlin, "kt" => SupportLang::Kotlin, + "lua" => SupportLang::Lua, "make" => SupportLang::Make, "makefile" => SupportLang::Make, + "markdown" => SupportLang::Markdown, "md" => SupportLang::Markdown, "mdx" => SupportLang::Markdown, + "nix" => SupportLang::Nix, "objc" => SupportLang::ObjC, "objective-c" => SupportLang::ObjC, + "odin" => SupportLang::Odin, "php" => SupportLang::Php, + "python" => SupportLang::Python, "py" => SupportLang::Python, + "regex" => SupportLang::Regex, "ruby" => SupportLang::Ruby, "rb" => SupportLang::Ruby, + "rust" => SupportLang::Rust, "rs" => SupportLang::Rust, + "scala" => SupportLang::Scala, "solidity" => SupportLang::Solidity, "sol" => SupportLang::Solidity, + "starlark" => SupportLang::Starlark, "star" => SupportLang::Starlark, + "swift" => SupportLang::Swift, "toml" => SupportLang::Toml, "tsx" => SupportLang::Tsx, + "typescript" => SupportLang::TypeScript, "ts" => SupportLang::TypeScript, + "mts" => SupportLang::TypeScript, "cts" => SupportLang::TypeScript, + "verilog" => SupportLang::Verilog, "systemverilog" => SupportLang::Verilog, "sv" => SupportLang::Verilog, + "xml" => SupportLang::Xml, "xsl" => SupportLang::Xml, "svg" => SupportLang::Xml, + "yaml" => SupportLang::Yaml, "yml" => SupportLang::Yaml, "zig" => SupportLang::Zig, }; -fn supported_lang_list() -> String { let mut keys: Vec<&str> = LANG_ALIASES.keys().copied().collect(); keys.sort_unstable(); keys.join(", ") } +fn supported_lang_list() -> String { + let mut keys: Vec<&str> = LANG_ALIASES.keys().copied().collect(); + keys.sort_unstable(); + keys.join(", ") +} fn resolve_supported_lang(value: &str) -> Result { - let lower = value.to_ascii_lowercase(); - LANG_ALIASES.get(lower.as_str()).copied().ok_or_else(|| Error::from_reason(format!("Unsupported language '{value}'. Supported: {}", supported_lang_list()))) + let lower = value.to_ascii_lowercase(); + LANG_ALIASES.get(lower.as_str()).copied().ok_or_else(|| { + Error::from_reason(format!( + "Unsupported language '{value}'. Supported: {}", + supported_lang_list() + )) + }) } fn resolve_language(lang: Option<&str>, file_path: &Path) -> Result { - if let Some(lang) = lang.map(str::trim).filter(|l| !l.is_empty()) { return resolve_supported_lang(lang); } - SupportLang::from_path(file_path).ok_or_else(|| Error::from_reason(format!("Unable to infer language from file extension: {}. Specify `lang` explicitly.", file_path.display()))) + if let Some(lang) = lang.map(str::trim).filter(|l| !l.is_empty()) { + return resolve_supported_lang(lang); + } + SupportLang::from_path(file_path).ok_or_else(|| { + Error::from_reason(format!( + "Unable to infer language from file extension: {}. Specify `lang` explicitly.", + file_path.display() + )) + }) } fn is_supported_file(file_path: &Path, explicit_lang: Option<&str>) -> bool { - if explicit_lang.is_some() { return true; } - resolve_language(None, file_path).is_ok() + if explicit_lang.is_some() { + return true; + } + resolve_language(None, file_path).is_ok() } fn infer_single_replace_lang(candidates: &[FileCandidate]) -> Result { - let mut inferred = BTreeSet::new(); - let mut unresolved = Vec::new(); - for c in candidates { - match resolve_language(None, &c.absolute_path) { - Ok(l) => { inferred.insert(l.canonical_name().to_string()); }, - Err(e) => unresolved.push(format!("{}: {}", c.display_path, e)), - } - } - if !unresolved.is_empty() { return Err(Error::from_reason(format!("`lang` is required for ast_edit when language cannot be inferred from all files:\n{}", unresolved.into_iter().map(|e| format!("- {e}")).collect::>().join("\n")))); } - if inferred.is_empty() { return Err(Error::from_reason("`lang` is required for ast_edit when no files match path/glob".to_string())); } - if inferred.len() > 1 { return Err(Error::from_reason(format!("`lang` is required for ast_edit when path/glob resolves to multiple languages: {}", inferred.into_iter().collect::>().join(", ")))); } - Ok(inferred.into_iter().next().unwrap()) + let mut inferred = BTreeSet::new(); + let mut unresolved = Vec::new(); + for c in candidates { + match resolve_language(None, &c.absolute_path) { + Ok(l) => { + inferred.insert(l.canonical_name().to_string()); + } + Err(e) => unresolved.push(format!("{}: {}", c.display_path, e)), + } + } + if !unresolved.is_empty() { + return Err(Error::from_reason(format!( + "`lang` is required for ast_edit when language cannot be inferred from all files:\n{}", + unresolved + .into_iter() + .map(|e| format!("- {e}")) + .collect::>() + .join("\n") + ))); + } + if inferred.is_empty() { + return Err(Error::from_reason( + "`lang` is required for ast_edit when no files match path/glob".to_string(), + )); + } + if inferred.len() > 1 { + return Err(Error::from_reason(format!( + "`lang` is required for ast_edit when path/glob resolves to multiple languages: {}", + inferred.into_iter().collect::>().join(", ") + ))); + } + Ok(inferred.into_iter().next().unwrap()) } fn parse_strictness(value: Option<&str>) -> Result { - let Some(raw) = value.map(str::trim).filter(|v| !v.is_empty()) else { return Ok(MatchStrictness::Smart) }; - raw.parse::().map_err(|e| Error::from_reason(format!("Invalid strictness '{raw}': {e}"))) + let Some(raw) = value.map(str::trim).filter(|v| !v.is_empty()) else { + return Ok(MatchStrictness::Smart); + }; + raw.parse::() + .map_err(|e| Error::from_reason(format!("Invalid strictness '{raw}': {e}"))) } fn normalize_search_path(path: Option) -> Result { - let raw = path.unwrap_or_else(|| ".".into()); - let candidate = PathBuf::from(raw.trim()); - let absolute = if candidate.is_absolute() { candidate } else { std::env::current_dir().map_err(|e| Error::from_reason(format!("Failed to resolve cwd: {e}")))?.join(candidate) }; - Ok(std::fs::canonicalize(&absolute).unwrap_or(absolute)) + let raw = path.unwrap_or_else(|| ".".into()); + let candidate = PathBuf::from(raw.trim()); + let absolute = if candidate.is_absolute() { + candidate + } else { + std::env::current_dir() + .map_err(|e| Error::from_reason(format!("Failed to resolve cwd: {e}")))? + .join(candidate) + }; + Ok(std::fs::canonicalize(&absolute).unwrap_or(absolute)) } fn collect_candidates(path: Option, glob: Option<&str>) -> Result> { - let search_path = normalize_search_path(path)?; - let metadata = std::fs::metadata(&search_path).map_err(|e| Error::from_reason(format!("Path not found: {e}")))?; - if metadata.is_file() { - let display_path = search_path.file_name().and_then(|n| n.to_str()).map_or_else(|| search_path.to_string_lossy().into_owned(), |s| s.to_string()); - return Ok(vec![FileCandidate { absolute_path: search_path, display_path }]); - } - if !metadata.is_dir() { return Err(Error::from_reason(format!("Search path must be a file or directory: {}", search_path.display()))); } - let glob_set = glob_util::try_compile_glob(glob, false)?; - let mentions_node_modules = glob.is_some_and(|v| v.contains("node_modules")); - let walker = WalkBuilder::new(&search_path).hidden(true).git_ignore(true).git_global(true).git_exclude(true).build(); - let mut files = Vec::new(); - for entry in walker { - let entry = match entry { Ok(e) => e, Err(_) => continue }; - if !entry.file_type().is_some_and(|ft| ft.is_file()) { continue; } - let abs = entry.path().to_path_buf(); - let relative = abs.strip_prefix(&search_path).map(|p| p.to_string_lossy().replace('\\', "/")).unwrap_or_else(|_| abs.to_string_lossy().into_owned()); - if !mentions_node_modules && relative.contains("node_modules") { continue; } - if let Some(ref gs) = glob_set { if !gs.is_match(&relative) { continue; } } - files.push(FileCandidate { absolute_path: abs, display_path: relative }); - } - files.sort_by(|a, b| a.display_path.cmp(&b.display_path)); - Ok(files) + let search_path = normalize_search_path(path)?; + let metadata = std::fs::metadata(&search_path) + .map_err(|e| Error::from_reason(format!("Path not found: {e}")))?; + if metadata.is_file() { + let display_path = search_path + .file_name() + .and_then(|n| n.to_str()) + .map_or_else( + || search_path.to_string_lossy().into_owned(), + |s| s.to_string(), + ); + return Ok(vec![FileCandidate { + absolute_path: search_path, + display_path, + }]); + } + if !metadata.is_dir() { + return Err(Error::from_reason(format!( + "Search path must be a file or directory: {}", + search_path.display() + ))); + } + let glob_set = glob_util::try_compile_glob(glob, false)?; + let mentions_node_modules = glob.is_some_and(|v| v.contains("node_modules")); + let walker = WalkBuilder::new(&search_path) + .hidden(true) + .git_ignore(true) + .git_global(true) + .git_exclude(true) + .build(); + let mut files = Vec::new(); + for entry in walker { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + if !entry.file_type().is_some_and(|ft| ft.is_file()) { + continue; + } + let abs = entry.path().to_path_buf(); + let relative = abs + .strip_prefix(&search_path) + .map(|p| p.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|_| abs.to_string_lossy().into_owned()); + if !mentions_node_modules && relative.contains("node_modules") { + continue; + } + if let Some(ref gs) = glob_set { + if !gs.is_match(&relative) { + continue; + } + } + files.push(FileCandidate { + absolute_path: abs, + display_path: relative, + }); + } + files.sort_by(|a, b| a.display_path.cmp(&b.display_path)); + Ok(files) } -fn compile_pattern(pattern: &str, selector: Option<&str>, strictness: &MatchStrictness, lang: SupportLang) -> Result { - let mut compiled = if let Some(sel) = selector.map(str::trim).filter(|s| !s.is_empty()) { Pattern::contextual(pattern, sel, lang) } else { Pattern::try_new(pattern, lang) } - .map_err(|e| Error::from_reason(format!("Invalid pattern: {e}")))?; - compiled.strictness = strictness.clone(); - Ok(compiled) +fn compile_pattern( + pattern: &str, + selector: Option<&str>, + strictness: &MatchStrictness, + lang: SupportLang, +) -> Result { + let mut compiled = if let Some(sel) = selector.map(str::trim).filter(|s| !s.is_empty()) { + Pattern::contextual(pattern, sel, lang) + } else { + Pattern::try_new(pattern, lang) + } + .map_err(|e| Error::from_reason(format!("Invalid pattern: {e}")))?; + compiled.strictness = strictness.clone(); + Ok(compiled) } fn apply_edits(content: &str, edits: &[Edit]) -> Result { - let mut sorted: Vec<&Edit> = edits.iter().collect(); - sorted.sort_by_key(|e| e.position); - let mut prev_end = 0usize; - for edit in &sorted { if edit.position < prev_end { return Err(Error::from_reason("Overlapping replacements detected".to_string())); } prev_end = edit.position.saturating_add(edit.deleted_length); } - let mut output = content.to_string(); - for edit in sorted.into_iter().rev() { - let start = edit.position; let end = edit.position.saturating_add(edit.deleted_length); - if end > output.len() || start > end { return Err(Error::from_reason("Computed edit range is out of bounds".to_string())); } - let replacement = String::from_utf8(edit.inserted_text.clone()).map_err(|e| Error::from_reason(format!("Replacement text is not valid UTF-8: {e}")))?; - output.replace_range(start..end, &replacement); - } - Ok(output) + let mut sorted: Vec<&Edit> = edits.iter().collect(); + sorted.sort_by_key(|e| e.position); + let mut prev_end = 0usize; + for edit in &sorted { + if edit.position < prev_end { + return Err(Error::from_reason( + "Overlapping replacements detected".to_string(), + )); + } + prev_end = edit.position.saturating_add(edit.deleted_length); + } + let mut output = content.to_string(); + for edit in sorted.into_iter().rev() { + let start = edit.position; + let end = edit.position.saturating_add(edit.deleted_length); + if end > output.len() || start > end { + return Err(Error::from_reason( + "Computed edit range is out of bounds".to_string(), + )); + } + let replacement = String::from_utf8(edit.inserted_text.clone()) + .map_err(|e| Error::from_reason(format!("Replacement text is not valid UTF-8: {e}")))?; + output.replace_range(start..end, &replacement); + } + Ok(output) } fn normalize_pattern_list(patterns: Option>) -> Result> { - let mut normalized = Vec::new(); let mut seen = BTreeSet::new(); - for raw in patterns.unwrap_or_default() { let p = raw.trim(); if !p.is_empty() && seen.insert(p.to_string()) { normalized.push(p.to_string()); } } - if normalized.is_empty() { return Err(Error::from_reason("`patterns` is required and must include at least one non-empty pattern".to_string())); } - Ok(normalized) + let mut normalized = Vec::new(); + let mut seen = BTreeSet::new(); + for raw in patterns.unwrap_or_default() { + let p = raw.trim(); + if !p.is_empty() && seen.insert(p.to_string()) { + normalized.push(p.to_string()); + } + } + if normalized.is_empty() { + return Err(Error::from_reason( + "`patterns` is required and must include at least one non-empty pattern".to_string(), + )); + } + Ok(normalized) } -fn normalize_rewrite_map(rewrites: Option>) -> Result> { - let mut normalized = Vec::new(); - for (p, r) in rewrites.unwrap_or_default() { if p.is_empty() { return Err(Error::from_reason("`rewrites` keys must be non-empty".to_string())); } normalized.push((p, r)); } - if normalized.is_empty() { return Err(Error::from_reason("`rewrites` is required".to_string())); } - normalized.sort_by(|l, r| l.0.cmp(&r.0)); Ok(normalized) +fn normalize_rewrite_map( + rewrites: Option>, +) -> Result> { + let mut normalized = Vec::new(); + for (p, r) in rewrites.unwrap_or_default() { + if p.is_empty() { + return Err(Error::from_reason( + "`rewrites` keys must be non-empty".to_string(), + )); + } + normalized.push((p, r)); + } + if normalized.is_empty() { + return Err(Error::from_reason("`rewrites` is required".to_string())); + } + normalized.sort_by(|l, r| l.0.cmp(&r.0)); + Ok(normalized) } -struct CompiledFindPattern { pattern: String, compiled_by_lang: HashMap, compile_errors_by_lang: HashMap } -struct ResolvedCandidate { candidate: FileCandidate, language: Option, language_error: Option } - -fn resolve_candidates_for_find(candidates: Vec, lang: Option<&str>) -> Result<(Vec, HashMap)> { - let mut resolved = Vec::with_capacity(candidates.len()); let mut languages = HashMap::new(); - for candidate in candidates { - match resolve_language(lang, &candidate.absolute_path) { - Ok(language) => { languages.entry(language.canonical_name().to_string()).or_insert(language); resolved.push(ResolvedCandidate { candidate, language: Some(language), language_error: None }); }, - Err(err) => resolved.push(ResolvedCandidate { candidate, language: None, language_error: Some(err.to_string()) }), - } - } - Ok((resolved, languages)) +struct CompiledFindPattern { + pattern: String, + compiled_by_lang: HashMap, + compile_errors_by_lang: HashMap, +} +struct ResolvedCandidate { + candidate: FileCandidate, + language: Option, + language_error: Option, } -fn compile_find_patterns(patterns: &[String], languages: &HashMap, selector: Option<&str>, strictness: &MatchStrictness) -> Result> { - let mut compiled = Vec::with_capacity(patterns.len()); - for pattern in patterns { - let mut by_lang = HashMap::with_capacity(languages.len()); let mut errors = HashMap::new(); - for (key, &lang) in languages { match compile_pattern(pattern, selector, strictness, lang) { Ok(p) => { by_lang.insert(key.clone(), p); }, Err(e) => { errors.insert(key.clone(), e.to_string()); } } } - compiled.push(CompiledFindPattern { pattern: pattern.clone(), compiled_by_lang: by_lang, compile_errors_by_lang: errors }); - } - Ok(compiled) +fn resolve_candidates_for_find( + candidates: Vec, + lang: Option<&str>, +) -> Result<(Vec, HashMap)> { + let mut resolved = Vec::with_capacity(candidates.len()); + let mut languages = HashMap::new(); + for candidate in candidates { + match resolve_language(lang, &candidate.absolute_path) { + Ok(language) => { + languages + .entry(language.canonical_name().to_string()) + .or_insert(language); + resolved.push(ResolvedCandidate { + candidate, + language: Some(language), + language_error: None, + }); + } + Err(err) => resolved.push(ResolvedCandidate { + candidate, + language: None, + language_error: Some(err.to_string()), + }), + } + } + Ok((resolved, languages)) +} + +fn compile_find_patterns( + patterns: &[String], + languages: &HashMap, + selector: Option<&str>, + strictness: &MatchStrictness, +) -> Result> { + let mut compiled = Vec::with_capacity(patterns.len()); + for pattern in patterns { + let mut by_lang = HashMap::with_capacity(languages.len()); + let mut errors = HashMap::new(); + for (key, &lang) in languages { + match compile_pattern(pattern, selector, strictness, lang) { + Ok(p) => { + by_lang.insert(key.clone(), p); + } + Err(e) => { + errors.insert(key.clone(), e.to_string()); + } + } + } + compiled.push(CompiledFindPattern { + pattern: pattern.clone(), + compiled_by_lang: by_lang, + compile_errors_by_lang: errors, + }); + } + Ok(compiled) } #[napi(js_name = "astGrep")] pub fn ast_grep(options: AstFindOptions) -> Result { - let AstFindOptions { patterns, lang, path, glob, selector, strictness, limit, offset, include_meta, context: _ } = options; - let normalized_limit = limit.unwrap_or(DEFAULT_FIND_LIMIT).max(1); - let normalized_offset = offset.unwrap_or(0); - let patterns = normalize_pattern_list(patterns)?; - let strictness = parse_strictness(strictness.as_deref())?; - let include_meta = include_meta.unwrap_or(false); - let lang_str = lang.as_deref().map(str::trim).filter(|v| !v.is_empty()); - let candidates: Vec<_> = collect_candidates(path, glob.as_deref())?.into_iter().filter(|c| is_supported_file(&c.absolute_path, lang_str)).collect(); - let (resolved_candidates, languages) = resolve_candidates_for_find(candidates, lang_str)?; - let compiled_patterns = compile_find_patterns(&patterns, &languages, selector.as_deref(), &strictness)?; - let files_searched = to_u32(resolved_candidates.len()); - let mut all_matches = Vec::new(); let mut parse_errors = Vec::new(); let mut total_matches = 0u32; let mut files_with_matches = BTreeSet::new(); - for resolved in resolved_candidates { - let ResolvedCandidate { candidate, language, language_error } = resolved; - if let Some(error) = language_error.as_deref() { for c in &compiled_patterns { parse_errors.push(format!("{}: {}: {error}", c.pattern, candidate.display_path)); } continue; } - let Some(language) = language else { continue }; - let lang_key = language.canonical_name(); - let source = match std::fs::read_to_string(&candidate.absolute_path) { Ok(s) => s, Err(e) => { for c in &compiled_patterns { parse_errors.push(format!("{}: {}: {e}", c.pattern, candidate.display_path)); } continue; } }; - let mut runnable: Vec<(&str, &Pattern)> = Vec::new(); - for c in &compiled_patterns { - if let Some(e) = c.compile_errors_by_lang.get(lang_key) { parse_errors.push(format!("{}: {}: {e}", c.pattern, candidate.display_path)); continue; } - if let Some(p) = c.compiled_by_lang.get(lang_key) { runnable.push((c.pattern.as_str(), p)); } - } - if runnable.is_empty() { continue; } - let ast = language.ast_grep(source); - if ast.root().dfs().any(|node| node.is_error()) { parse_errors.push(format!("{}: parse error (syntax tree contains error nodes)", candidate.display_path)); } - for (_, pattern) in runnable { - for matched in ast.root().find_all(pattern.clone()) { - total_matches = total_matches.saturating_add(1); - let range = matched.range(); let start = matched.start_pos(); let end = matched.end_pos(); - let meta_variables = if include_meta { Some(HashMap::::from(matched.get_env().clone())) } else { None }; - all_matches.push(AstFindMatch { path: candidate.display_path.clone(), text: matched.text().into_owned(), byte_start: to_u32(range.start), byte_end: to_u32(range.end), start_line: to_u32(start.line().saturating_add(1)), start_column: to_u32(start.column(matched.get_node()).saturating_add(1)), end_line: to_u32(end.line().saturating_add(1)), end_column: to_u32(end.column(matched.get_node()).saturating_add(1)), meta_variables }); - files_with_matches.insert(candidate.display_path.clone()); - } - } - } - all_matches.sort_by(|l, r| l.path.cmp(&r.path).then(l.start_line.cmp(&r.start_line)).then(l.start_column.cmp(&r.start_column))); - let visible: Vec<_> = all_matches.into_iter().skip(normalized_offset as usize).collect(); - let limit_reached = visible.len() > normalized_limit as usize; - let matches: Vec<_> = visible.into_iter().take(normalized_limit as usize).collect(); - Ok(AstFindResult { matches, total_matches, files_with_matches: to_u32(files_with_matches.len()), files_searched, limit_reached, parse_errors: (!parse_errors.is_empty()).then_some(parse_errors) }) + let AstFindOptions { + patterns, + lang, + path, + glob, + selector, + strictness, + limit, + offset, + include_meta, + context: _, + } = options; + let normalized_limit = limit.unwrap_or(DEFAULT_FIND_LIMIT).max(1); + let normalized_offset = offset.unwrap_or(0); + let patterns = normalize_pattern_list(patterns)?; + let strictness = parse_strictness(strictness.as_deref())?; + let include_meta = include_meta.unwrap_or(false); + let lang_str = lang.as_deref().map(str::trim).filter(|v| !v.is_empty()); + let candidates: Vec<_> = collect_candidates(path, glob.as_deref())? + .into_iter() + .filter(|c| is_supported_file(&c.absolute_path, lang_str)) + .collect(); + let (resolved_candidates, languages) = resolve_candidates_for_find(candidates, lang_str)?; + let compiled_patterns = + compile_find_patterns(&patterns, &languages, selector.as_deref(), &strictness)?; + let files_searched = to_u32(resolved_candidates.len()); + let mut all_matches = Vec::new(); + let mut parse_errors = Vec::new(); + let mut total_matches = 0u32; + let mut files_with_matches = BTreeSet::new(); + for resolved in resolved_candidates { + let ResolvedCandidate { + candidate, + language, + language_error, + } = resolved; + if let Some(error) = language_error.as_deref() { + for c in &compiled_patterns { + parse_errors.push(format!( + "{}: {}: {error}", + c.pattern, candidate.display_path + )); + } + continue; + } + let Some(language) = language else { continue }; + let lang_key = language.canonical_name(); + let source = match std::fs::read_to_string(&candidate.absolute_path) { + Ok(s) => s, + Err(e) => { + for c in &compiled_patterns { + parse_errors.push(format!("{}: {}: {e}", c.pattern, candidate.display_path)); + } + continue; + } + }; + let mut runnable: Vec<(&str, &Pattern)> = Vec::new(); + for c in &compiled_patterns { + if let Some(e) = c.compile_errors_by_lang.get(lang_key) { + parse_errors.push(format!("{}: {}: {e}", c.pattern, candidate.display_path)); + continue; + } + if let Some(p) = c.compiled_by_lang.get(lang_key) { + runnable.push((c.pattern.as_str(), p)); + } + } + if runnable.is_empty() { + continue; + } + let ast = language.ast_grep(source); + if ast.root().dfs().any(|node| node.is_error()) { + parse_errors.push(format!( + "{}: parse error (syntax tree contains error nodes)", + candidate.display_path + )); + } + for (_, pattern) in runnable { + for matched in ast.root().find_all(pattern.clone()) { + total_matches = total_matches.saturating_add(1); + let range = matched.range(); + let start = matched.start_pos(); + let end = matched.end_pos(); + let meta_variables = if include_meta { + Some(HashMap::::from(matched.get_env().clone())) + } else { + None + }; + all_matches.push(AstFindMatch { + path: candidate.display_path.clone(), + text: matched.text().into_owned(), + byte_start: to_u32(range.start), + byte_end: to_u32(range.end), + start_line: to_u32(start.line().saturating_add(1)), + start_column: to_u32(start.column(matched.get_node()).saturating_add(1)), + end_line: to_u32(end.line().saturating_add(1)), + end_column: to_u32(end.column(matched.get_node()).saturating_add(1)), + meta_variables, + }); + files_with_matches.insert(candidate.display_path.clone()); + } + } + } + all_matches.sort_by(|l, r| { + l.path + .cmp(&r.path) + .then(l.start_line.cmp(&r.start_line)) + .then(l.start_column.cmp(&r.start_column)) + }); + let visible: Vec<_> = all_matches + .into_iter() + .skip(normalized_offset as usize) + .collect(); + let limit_reached = visible.len() > normalized_limit as usize; + let matches: Vec<_> = visible + .into_iter() + .take(normalized_limit as usize) + .collect(); + Ok(AstFindResult { + matches, + total_matches, + files_with_matches: to_u32(files_with_matches.len()), + files_searched, + limit_reached, + parse_errors: (!parse_errors.is_empty()).then_some(parse_errors), + }) } #[napi(js_name = "astEdit")] pub fn ast_edit(options: AstReplaceOptions) -> Result { - let AstReplaceOptions { rewrites, lang, path, glob, selector, strictness, dry_run, max_replacements, max_files, fail_on_parse_error } = options; - let rewrite_rules = normalize_rewrite_map(rewrites)?; - let strictness = parse_strictness(strictness.as_deref())?; - let dry_run = dry_run.unwrap_or(true); let max_replacements = max_replacements.unwrap_or(u32::MAX).max(1); let max_files = max_files.unwrap_or(u32::MAX).max(1); let fail_on_parse_error = fail_on_parse_error.unwrap_or(false); - let lang_str = lang.as_deref().map(str::trim).filter(|v| !v.is_empty()); - let candidates: Vec<_> = collect_candidates(path, glob.as_deref())?.into_iter().filter(|c| is_supported_file(&c.absolute_path, lang_str)).collect(); - let effective_lang = if let Some(l) = lang_str { l.to_string() } else { infer_single_replace_lang(&candidates)? }; - let language = resolve_supported_lang(&effective_lang)?; - let mut parse_errors = Vec::new(); let mut compiled_rules = Vec::new(); - for (pattern, rewrite) in rewrite_rules { - match compile_pattern(&pattern, selector.as_deref(), &strictness, language) { Ok(c) => compiled_rules.push((pattern, rewrite, c)), Err(e) => { if fail_on_parse_error { return Err(e); } parse_errors.push(format!("{pattern}: {e}")); } } - } - if compiled_rules.is_empty() { return Ok(AstReplaceResult { file_changes: vec![], total_replacements: 0, files_touched: 0, files_searched: to_u32(candidates.len()), applied: !dry_run, limit_reached: false, parse_errors: (!parse_errors.is_empty()).then_some(parse_errors), changes: vec![] }); } - let mut changes = Vec::new(); let mut file_counts: BTreeMap = BTreeMap::new(); let mut files_touched = 0u32; let mut limit_reached = false; - for candidate in &candidates { - let source = match std::fs::read_to_string(&candidate.absolute_path) { Ok(s) => s, Err(e) => { if fail_on_parse_error { return Err(Error::from_reason(format!("{}: {e}", candidate.display_path))); } parse_errors.push(format!("{}: {e}", candidate.display_path)); continue; } }; - let ast = language.ast_grep(&source); - if ast.root().dfs().any(|n| n.is_error()) { let msg = format!("{}: parse error (syntax tree contains error nodes)", candidate.display_path); if fail_on_parse_error { return Err(Error::from_reason(msg)); } parse_errors.push(msg); continue; } - let mut file_changes = Vec::new(); let mut reached_max = false; - 'patterns: for (_pat, rewrite, compiled) in &compiled_rules { - for matched in ast.root().find_all(compiled.clone()) { - if changes.len() + file_changes.len() >= max_replacements as usize { limit_reached = true; reached_max = true; break 'patterns; } - let edit = matched.replace_by(rewrite.as_str()); let range = matched.range(); let start = matched.start_pos(); let end = matched.end_pos(); - let after = String::from_utf8(edit.inserted_text.clone()).map_err(|e| Error::from_reason(format!("{}: replacement not valid UTF-8: {e}", candidate.display_path)))?; - file_changes.push(PendingFileChange { change: AstReplaceChange { path: candidate.display_path.clone(), before: matched.text().into_owned(), after, byte_start: to_u32(range.start), byte_end: to_u32(range.end), deleted_length: to_u32(edit.deleted_length), start_line: to_u32(start.line().saturating_add(1)), start_column: to_u32(start.column(matched.get_node()).saturating_add(1)), end_line: to_u32(end.line().saturating_add(1)), end_column: to_u32(end.column(matched.get_node()).saturating_add(1)) }, edit }); - } - } - if file_changes.is_empty() { if reached_max { break; } continue; } - if files_touched >= max_files { limit_reached = true; break; } - files_touched = files_touched.saturating_add(1); - file_counts.insert(candidate.display_path.clone(), to_u32(file_changes.len())); - if !dry_run { - let edits: Vec> = file_changes.iter().map(|e| Edit { position: e.edit.position, deleted_length: e.edit.deleted_length, inserted_text: e.edit.inserted_text.clone() }).collect(); - let output = apply_edits(&source, &edits)?; - if output != source { std::fs::write(&candidate.absolute_path, output).map_err(|e| Error::from_reason(format!("Failed to write {}: {e}", candidate.display_path)))?; } - } - changes.extend(file_changes.into_iter().map(|e| e.change)); - if reached_max { break; } - } - let file_changes: Vec<_> = file_counts.into_iter().map(|(p, c)| AstReplaceFileChange { path: p, count: c }).collect(); - Ok(AstReplaceResult { file_changes, total_replacements: to_u32(changes.len()), files_touched, files_searched: to_u32(candidates.len()), applied: !dry_run, limit_reached, parse_errors: (!parse_errors.is_empty()).then_some(parse_errors), changes }) + let AstReplaceOptions { + rewrites, + lang, + path, + glob, + selector, + strictness, + dry_run, + max_replacements, + max_files, + fail_on_parse_error, + } = options; + let rewrite_rules = normalize_rewrite_map(rewrites)?; + let strictness = parse_strictness(strictness.as_deref())?; + let dry_run = dry_run.unwrap_or(true); + let max_replacements = max_replacements.unwrap_or(u32::MAX).max(1); + let max_files = max_files.unwrap_or(u32::MAX).max(1); + let fail_on_parse_error = fail_on_parse_error.unwrap_or(false); + let lang_str = lang.as_deref().map(str::trim).filter(|v| !v.is_empty()); + let candidates: Vec<_> = collect_candidates(path, glob.as_deref())? + .into_iter() + .filter(|c| is_supported_file(&c.absolute_path, lang_str)) + .collect(); + let effective_lang = if let Some(l) = lang_str { + l.to_string() + } else { + infer_single_replace_lang(&candidates)? + }; + let language = resolve_supported_lang(&effective_lang)?; + let mut parse_errors = Vec::new(); + let mut compiled_rules = Vec::new(); + for (pattern, rewrite) in rewrite_rules { + match compile_pattern(&pattern, selector.as_deref(), &strictness, language) { + Ok(c) => compiled_rules.push((pattern, rewrite, c)), + Err(e) => { + if fail_on_parse_error { + return Err(e); + } + parse_errors.push(format!("{pattern}: {e}")); + } + } + } + if compiled_rules.is_empty() { + return Ok(AstReplaceResult { + file_changes: vec![], + total_replacements: 0, + files_touched: 0, + files_searched: to_u32(candidates.len()), + applied: !dry_run, + limit_reached: false, + parse_errors: (!parse_errors.is_empty()).then_some(parse_errors), + changes: vec![], + }); + } + let mut changes = Vec::new(); + let mut file_counts: BTreeMap = BTreeMap::new(); + let mut files_touched = 0u32; + let mut limit_reached = false; + for candidate in &candidates { + let source = match std::fs::read_to_string(&candidate.absolute_path) { + Ok(s) => s, + Err(e) => { + if fail_on_parse_error { + return Err(Error::from_reason(format!( + "{}: {e}", + candidate.display_path + ))); + } + parse_errors.push(format!("{}: {e}", candidate.display_path)); + continue; + } + }; + let ast = language.ast_grep(&source); + if ast.root().dfs().any(|n| n.is_error()) { + let msg = format!( + "{}: parse error (syntax tree contains error nodes)", + candidate.display_path + ); + if fail_on_parse_error { + return Err(Error::from_reason(msg)); + } + parse_errors.push(msg); + continue; + } + let mut file_changes = Vec::new(); + let mut reached_max = false; + 'patterns: for (_pat, rewrite, compiled) in &compiled_rules { + for matched in ast.root().find_all(compiled.clone()) { + if changes.len() + file_changes.len() >= max_replacements as usize { + limit_reached = true; + reached_max = true; + break 'patterns; + } + let edit = matched.replace_by(rewrite.as_str()); + let range = matched.range(); + let start = matched.start_pos(); + let end = matched.end_pos(); + let after = String::from_utf8(edit.inserted_text.clone()).map_err(|e| { + Error::from_reason(format!( + "{}: replacement not valid UTF-8: {e}", + candidate.display_path + )) + })?; + file_changes.push(PendingFileChange { + change: AstReplaceChange { + path: candidate.display_path.clone(), + before: matched.text().into_owned(), + after, + byte_start: to_u32(range.start), + byte_end: to_u32(range.end), + deleted_length: to_u32(edit.deleted_length), + start_line: to_u32(start.line().saturating_add(1)), + start_column: to_u32(start.column(matched.get_node()).saturating_add(1)), + end_line: to_u32(end.line().saturating_add(1)), + end_column: to_u32(end.column(matched.get_node()).saturating_add(1)), + }, + edit, + }); + } + } + if file_changes.is_empty() { + if reached_max { + break; + } + continue; + } + if files_touched >= max_files { + limit_reached = true; + break; + } + files_touched = files_touched.saturating_add(1); + file_counts.insert(candidate.display_path.clone(), to_u32(file_changes.len())); + if !dry_run { + let edits: Vec> = file_changes + .iter() + .map(|e| Edit { + position: e.edit.position, + deleted_length: e.edit.deleted_length, + inserted_text: e.edit.inserted_text.clone(), + }) + .collect(); + let output = apply_edits(&source, &edits)?; + if output != source { + std::fs::write(&candidate.absolute_path, output).map_err(|e| { + Error::from_reason(format!("Failed to write {}: {e}", candidate.display_path)) + })?; + } + } + changes.extend(file_changes.into_iter().map(|e| e.change)); + if reached_max { + break; + } + } + let file_changes: Vec<_> = file_counts + .into_iter() + .map(|(p, c)| AstReplaceFileChange { path: p, count: c }) + .collect(); + Ok(AstReplaceResult { + file_changes, + total_replacements: to_u32(changes.len()), + files_touched, + files_searched: to_u32(candidates.len()), + applied: !dry_run, + limit_reached, + parse_errors: (!parse_errors.is_empty()).then_some(parse_errors), + changes, + }) } #[cfg(test)] mod tests { - use std::{fs, path::PathBuf, time::{SystemTime, UNIX_EPOCH}}; - use super::*; - struct TempTree { root: PathBuf } - impl Drop for TempTree { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.root); } } - fn make_temp_tree() -> TempTree { - let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); - let root = std::env::temp_dir().join(format!("forge-ast-test-{unique}")); - fs::create_dir_all(root.join("nested")).unwrap(); - fs::write(root.join("a.ts"), "const a = 1;\n").unwrap(); - fs::write(root.join("nested").join("b.ts"), "const b = 2;\n").unwrap(); - TempTree { root } - } - #[test] - fn resolves_supported_language_aliases() { - assert_eq!(resolve_supported_lang("ts").ok(), Some(SupportLang::TypeScript)); - assert_eq!(resolve_supported_lang("rs").ok(), Some(SupportLang::Rust)); - assert!(resolve_supported_lang("brainfuck").is_err()); - } - #[test] - fn applies_non_overlapping_edits() { - let edits = vec![Edit:: { position: 6, deleted_length: 6, inserted_text: b"value".to_vec() }, Edit:: { position: 15, deleted_length: 2, inserted_text: b"42".to_vec() }]; - assert_eq!(apply_edits("const answer = 41;", &edits).unwrap(), "const value = 42;"); - } - #[test] - fn rejects_overlapping_edits() { - let edits = vec![Edit:: { position: 1, deleted_length: 3, inserted_text: b"x".to_vec() }, Edit:: { position: 2, deleted_length: 1, inserted_text: b"y".to_vec() }]; - assert!(apply_edits("abcdef", &edits).is_err()); - } - #[test] - fn collect_candidates_finds_files() { - let tree = make_temp_tree(); - let candidates = collect_candidates(Some(tree.root.to_string_lossy().into_owned()), None).unwrap(); - let paths: Vec<_> = candidates.iter().map(|f| f.display_path.as_str()).collect(); - assert!(paths.contains(&"a.ts") && paths.contains(&"nested/b.ts")); - } - #[test] - fn infers_single_replace_lang() { - let tree = make_temp_tree(); - let candidates = collect_candidates(Some(tree.root.to_string_lossy().into_owned()), Some("**/*.ts")).unwrap(); - assert_eq!(infer_single_replace_lang(&candidates).unwrap(), "typescript"); - } - #[test] - fn rejects_mixed_replace_lang() { - let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); - let root = std::env::temp_dir().join(format!("forge-ast-mixed-{unique}")); - fs::create_dir_all(&root).unwrap(); - fs::write(root.join("a.ts"), "const a = 1;\n").unwrap(); - fs::write(root.join("b.rs"), "fn main() {}\n").unwrap(); - let candidates = collect_candidates(Some(root.to_string_lossy().into_owned()), None).unwrap(); - assert!(infer_single_replace_lang(&candidates).unwrap_err().to_string().contains("multiple languages")); - let _ = fs::remove_dir_all(&root); - } + use super::*; + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + struct TempTree { + root: PathBuf, + } + impl Drop for TempTree { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } + } + fn make_temp_tree() -> TempTree { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("forge-ast-test-{unique}")); + fs::create_dir_all(root.join("nested")).unwrap(); + fs::write(root.join("a.ts"), "const a = 1;\n").unwrap(); + fs::write(root.join("nested").join("b.ts"), "const b = 2;\n").unwrap(); + TempTree { root } + } + #[test] + fn resolves_supported_language_aliases() { + assert_eq!( + resolve_supported_lang("ts").ok(), + Some(SupportLang::TypeScript) + ); + assert_eq!(resolve_supported_lang("rs").ok(), Some(SupportLang::Rust)); + assert!(resolve_supported_lang("brainfuck").is_err()); + } + #[test] + fn applies_non_overlapping_edits() { + let edits = vec![ + Edit:: { + position: 6, + deleted_length: 6, + inserted_text: b"value".to_vec(), + }, + Edit:: { + position: 15, + deleted_length: 2, + inserted_text: b"42".to_vec(), + }, + ]; + assert_eq!( + apply_edits("const answer = 41;", &edits).unwrap(), + "const value = 42;" + ); + } + #[test] + fn rejects_overlapping_edits() { + let edits = vec![ + Edit:: { + position: 1, + deleted_length: 3, + inserted_text: b"x".to_vec(), + }, + Edit:: { + position: 2, + deleted_length: 1, + inserted_text: b"y".to_vec(), + }, + ]; + assert!(apply_edits("abcdef", &edits).is_err()); + } + #[test] + fn collect_candidates_finds_files() { + let tree = make_temp_tree(); + let candidates = + collect_candidates(Some(tree.root.to_string_lossy().into_owned()), None).unwrap(); + let paths: Vec<_> = candidates.iter().map(|f| f.display_path.as_str()).collect(); + assert!(paths.contains(&"a.ts") && paths.contains(&"nested/b.ts")); + } + #[test] + fn infers_single_replace_lang() { + let tree = make_temp_tree(); + let candidates = collect_candidates( + Some(tree.root.to_string_lossy().into_owned()), + Some("**/*.ts"), + ) + .unwrap(); + assert_eq!( + infer_single_replace_lang(&candidates).unwrap(), + "typescript" + ); + } + #[test] + fn rejects_mixed_replace_lang() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("forge-ast-mixed-{unique}")); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("a.ts"), "const a = 1;\n").unwrap(); + fs::write(root.join("b.rs"), "fn main() {}\n").unwrap(); + let candidates = + collect_candidates(Some(root.to_string_lossy().into_owned()), None).unwrap(); + assert!(infer_single_replace_lang(&candidates) + .unwrap_err() + .to_string() + .contains("multiple languages")); + let _ = fs::remove_dir_all(&root); + } } diff --git a/rust-engine/crates/ast/src/glob_util.rs b/rust-engine/crates/ast/src/glob_util.rs index 4ce59b548..f1c5d7d9e 100644 --- a/rust-engine/crates/ast/src/glob_util.rs +++ b/rust-engine/crates/ast/src/glob_util.rs @@ -7,13 +7,13 @@ use napi::bindgen_prelude::*; /// Normalize a raw glob string: fix path separators, optionally prepend `**/` /// for recursive matching, and close any unclosed `{` alternation groups. pub fn build_glob_pattern(glob: &str, recursive: bool) -> String { - let normalized = glob.replace('\\', "/"); - let pattern = if !recursive || normalized.contains('/') || normalized.starts_with("**") { - normalized - } else { - format!("**/{normalized}") - }; - fix_unclosed_braces(pattern) + let normalized = glob.replace('\\', "/"); + let pattern = if !recursive || normalized.contains('/') || normalized.starts_with("**") { + normalized + } else { + format!("**/{normalized}") + }; + fix_unclosed_braces(pattern) } /// Compile a glob pattern string into a [`GlobSet`]. @@ -21,25 +21,25 @@ pub fn build_glob_pattern(glob: &str, recursive: bool) -> String { /// When `recursive` is true, simple patterns (no path separators, no leading /// `**`) are automatically prefixed with `**/`. pub fn compile_glob(glob: &str, recursive: bool) -> Result { - let mut builder = GlobSetBuilder::new(); - let pattern = build_glob_pattern(glob, recursive); - let glob = GlobBuilder::new(&pattern) - .literal_separator(true) - .build() - .map_err(|err| Error::from_reason(format!("Invalid glob pattern: {err}")))?; - builder.add(glob); - builder - .build() - .map_err(|err| Error::from_reason(format!("Failed to build glob matcher: {err}"))) + let mut builder = GlobSetBuilder::new(); + let pattern = build_glob_pattern(glob, recursive); + let glob = GlobBuilder::new(&pattern) + .literal_separator(true) + .build() + .map_err(|err| Error::from_reason(format!("Invalid glob pattern: {err}")))?; + builder.add(glob); + builder + .build() + .map_err(|err| Error::from_reason(format!("Failed to build glob matcher: {err}"))) } /// Like [`compile_glob`], but accepts an `Option<&str>` — returns `Ok(None)` /// when the input is `None`, empty, or whitespace-only. pub fn try_compile_glob(glob: Option<&str>, recursive: bool) -> Result> { - let Some(glob) = glob.map(str::trim).filter(|v| !v.is_empty()) else { - return Ok(None); - }; - compile_glob(glob, recursive).map(Some) + let Some(glob) = glob.map(str::trim).filter(|v| !v.is_empty()) else { + return Ok(None); + }; + compile_glob(glob, recursive).map(Some) } /// Close unclosed `{` alternation groups in a glob pattern. @@ -47,70 +47,70 @@ pub fn try_compile_glob(glob: Option<&str>, recursive: bool) -> Result String { - let opens = pattern.chars().filter(|&c| c == '{').count(); - let closes = pattern.chars().filter(|&c| c == '}').count(); - if opens > closes { - let mut fixed = pattern; - for _ in 0..(opens - closes) { - fixed.push('}'); - } - fixed - } else { - pattern - } + let opens = pattern.chars().filter(|&c| c == '{').count(); + let closes = pattern.chars().filter(|&c| c == '}').count(); + if opens > closes { + let mut fixed = pattern; + for _ in 0..(opens - closes) { + fixed.push('}'); + } + fixed + } else { + pattern + } } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn simple_pattern_gets_recursive_prefix() { - assert_eq!(build_glob_pattern("*.ts", true), "**/*.ts"); - } + #[test] + fn simple_pattern_gets_recursive_prefix() { + assert_eq!(build_glob_pattern("*.ts", true), "**/*.ts"); + } - #[test] - fn pattern_with_path_stays_as_is() { - assert_eq!(build_glob_pattern("src/*.ts", true), "src/*.ts"); - } + #[test] + fn pattern_with_path_stays_as_is() { + assert_eq!(build_glob_pattern("src/*.ts", true), "src/*.ts"); + } - #[test] - fn already_recursive_pattern_unchanged() { - assert_eq!(build_glob_pattern("**/*.rs", true), "**/*.rs"); - } + #[test] + fn already_recursive_pattern_unchanged() { + assert_eq!(build_glob_pattern("**/*.rs", true), "**/*.rs"); + } - #[test] - fn non_recursive_keeps_simple_pattern() { - assert_eq!(build_glob_pattern("*.ts", false), "*.ts"); - } + #[test] + fn non_recursive_keeps_simple_pattern() { + assert_eq!(build_glob_pattern("*.ts", false), "*.ts"); + } - #[test] - fn backslashes_normalized() { - assert_eq!(build_glob_pattern("src\\**\\*.ts", true), "src/**/*.ts"); - } + #[test] + fn backslashes_normalized() { + assert_eq!(build_glob_pattern("src\\**\\*.ts", true), "src/**/*.ts"); + } - #[test] - fn unclosed_brace_gets_closed() { - assert_eq!(build_glob_pattern("*.{ts,tsx,js", true), "**/*.{ts,tsx,js}"); - } + #[test] + fn unclosed_brace_gets_closed() { + assert_eq!(build_glob_pattern("*.{ts,tsx,js", true), "**/*.{ts,tsx,js}"); + } - #[test] - fn deeply_unclosed_braces_all_closed() { - assert_eq!(build_glob_pattern("{a,{b,c}", true), "**/{a,{b,c}}"); - } + #[test] + fn deeply_unclosed_braces_all_closed() { + assert_eq!(build_glob_pattern("{a,{b,c}", true), "**/{a,{b,c}}"); + } - #[test] - fn balanced_braces_unchanged() { - assert_eq!(build_glob_pattern("*.{ts,js}", true), "**/*.{ts,js}"); - } + #[test] + fn balanced_braces_unchanged() { + assert_eq!(build_glob_pattern("*.{ts,js}", true), "**/*.{ts,js}"); + } - #[test] - fn compile_glob_accepts_valid_pattern() { - assert!(compile_glob("*.ts", true).is_ok()); - } + #[test] + fn compile_glob_accepts_valid_pattern() { + assert!(compile_glob("*.ts", true).is_ok()); + } - #[test] - fn compile_glob_fixes_unclosed_brace() { - assert!(compile_glob("*.{ts,tsx,js", true).is_ok()); - } + #[test] + fn compile_glob_fixes_unclosed_brace() { + assert!(compile_glob("*.{ts,tsx,js", true).is_ok()); + } } diff --git a/rust-engine/crates/ast/src/language/mod.rs b/rust-engine/crates/ast/src/language/mod.rs index 51f180665..56a822cc6 100644 --- a/rust-engine/crates/ast/src/language/mod.rs +++ b/rust-engine/crates/ast/src/language/mod.rs @@ -8,97 +8,95 @@ mod parsers; use std::{borrow::Cow, collections::HashMap, fmt, path::Path}; use ast_grep_core::{ - Doc, Language, Node, - matcher::{KindMatcher, Pattern, PatternBuilder, PatternError}, - meta_var::MetaVariable, - tree_sitter::{LanguageExt, StrDoc, TSLanguage, TSRange}, + matcher::{KindMatcher, Pattern, PatternBuilder, PatternError}, + meta_var::MetaVariable, + tree_sitter::{LanguageExt, StrDoc, TSLanguage, TSRange}, + Doc, Language, Node, }; /// Implements a stub language (no expando / `pre_process_pattern` needed). /// Use when the language grammar accepts `$VAR` as valid identifiers. macro_rules! impl_lang { - ($lang:ident, $func:ident) => { - #[derive(Clone, Copy, Debug)] - pub struct $lang; - impl Language for $lang { - fn kind_to_id(&self, kind: &str) -> u16 { - self.get_ts_language().id_for_node_kind(kind, true) - } + ($lang:ident, $func:ident) => { + #[derive(Clone, Copy, Debug)] + pub struct $lang; + impl Language for $lang { + fn kind_to_id(&self, kind: &str) -> u16 { + self.get_ts_language().id_for_node_kind(kind, true) + } - fn field_to_id(&self, field: &str) -> Option { - self - .get_ts_language() - .field_id_for_name(field) - .map(|f| f.get()) - } + fn field_to_id(&self, field: &str) -> Option { + self.get_ts_language() + .field_id_for_name(field) + .map(|f| f.get()) + } - fn build_pattern(&self, builder: &PatternBuilder) -> Result { - builder.build(|src| StrDoc::try_new(src, *self)) - } - } - impl LanguageExt for $lang { - fn get_ts_language(&self) -> TSLanguage { - parsers::$func().into() - } - } - }; + fn build_pattern(&self, builder: &PatternBuilder) -> Result { + builder.build(|src| StrDoc::try_new(src, *self)) + } + } + impl LanguageExt for $lang { + fn get_ts_language(&self) -> TSLanguage { + parsers::$func().into() + } + } + }; } fn pre_process_pattern(expando: char, query: &str) -> Cow<'_, str> { - let mut ret = Vec::with_capacity(query.len()); - let mut dollar_count = 0; - for c in query.chars() { - if c == '$' { - dollar_count += 1; - continue; - } - let need_replace = matches!(c, 'A'..='Z' | '_') || dollar_count == 3; - let sigil = if need_replace { expando } else { '$' }; - ret.extend(std::iter::repeat_n(sigil, dollar_count)); - dollar_count = 0; - ret.push(c); - } - let sigil = if dollar_count == 3 { expando } else { '$' }; - ret.extend(std::iter::repeat_n(sigil, dollar_count)); - Cow::Owned(ret.into_iter().collect()) + let mut ret = Vec::with_capacity(query.len()); + let mut dollar_count = 0; + for c in query.chars() { + if c == '$' { + dollar_count += 1; + continue; + } + let need_replace = matches!(c, 'A'..='Z' | '_') || dollar_count == 3; + let sigil = if need_replace { expando } else { '$' }; + ret.extend(std::iter::repeat_n(sigil, dollar_count)); + dollar_count = 0; + ret.push(c); + } + let sigil = if dollar_count == 3 { expando } else { '$' }; + ret.extend(std::iter::repeat_n(sigil, dollar_count)); + Cow::Owned(ret.into_iter().collect()) } /// Implements a language with `expando_char` / `pre_process_pattern`. /// Use when the language does NOT accept `$` as a valid identifier character. macro_rules! impl_lang_expando { - ($lang:ident, $func:ident, $char:expr) => { - #[derive(Clone, Copy, Debug)] - pub struct $lang; - impl Language for $lang { - fn kind_to_id(&self, kind: &str) -> u16 { - self.get_ts_language().id_for_node_kind(kind, true) - } + ($lang:ident, $func:ident, $char:expr) => { + #[derive(Clone, Copy, Debug)] + pub struct $lang; + impl Language for $lang { + fn kind_to_id(&self, kind: &str) -> u16 { + self.get_ts_language().id_for_node_kind(kind, true) + } - fn field_to_id(&self, field: &str) -> Option { - self - .get_ts_language() - .field_id_for_name(field) - .map(|f| f.get()) - } + fn field_to_id(&self, field: &str) -> Option { + self.get_ts_language() + .field_id_for_name(field) + .map(|f| f.get()) + } - fn expando_char(&self) -> char { - $char - } + fn expando_char(&self) -> char { + $char + } - fn pre_process_pattern<'q>(&self, query: &'q str) -> Cow<'q, str> { - pre_process_pattern(self.expando_char(), query) - } + fn pre_process_pattern<'q>(&self, query: &'q str) -> Cow<'q, str> { + pre_process_pattern(self.expando_char(), query) + } - fn build_pattern(&self, builder: &PatternBuilder) -> Result { - builder.build(|src| StrDoc::try_new(src, *self)) - } - } - impl LanguageExt for $lang { - fn get_ts_language(&self) -> TSLanguage { - parsers::$func().into() - } - } - }; + fn build_pattern(&self, builder: &PatternBuilder) -> Result { + builder.build(|src| StrDoc::try_new(src, *self)) + } + } + impl LanguageExt for $lang { + fn get_ts_language(&self) -> TSLanguage { + parsers::$func().into() + } + } + }; } // ── Customized languages with expando_char ────────────────────────────── @@ -154,93 +152,97 @@ impl_lang!(Regex, language_regex); pub struct Html; impl Language for Html { - fn expando_char(&self) -> char { - 'z' - } + fn expando_char(&self) -> char { + 'z' + } - fn pre_process_pattern<'q>(&self, query: &'q str) -> Cow<'q, str> { - pre_process_pattern(self.expando_char(), query) - } + fn pre_process_pattern<'q>(&self, query: &'q str) -> Cow<'q, str> { + pre_process_pattern(self.expando_char(), query) + } - fn kind_to_id(&self, kind: &str) -> u16 { - self.get_ts_language().id_for_node_kind(kind, true) - } + fn kind_to_id(&self, kind: &str) -> u16 { + self.get_ts_language().id_for_node_kind(kind, true) + } - fn field_to_id(&self, field: &str) -> Option { - self - .get_ts_language() - .field_id_for_name(field) - .map(|f| f.get()) - } + fn field_to_id(&self, field: &str) -> Option { + self.get_ts_language() + .field_id_for_name(field) + .map(|f| f.get()) + } - fn build_pattern(&self, builder: &PatternBuilder) -> Result { - builder.build(|src| StrDoc::try_new(src, *self)) - } + fn build_pattern(&self, builder: &PatternBuilder) -> Result { + builder.build(|src| StrDoc::try_new(src, *self)) + } } impl LanguageExt for Html { - fn get_ts_language(&self) -> TSLanguage { - parsers::language_html() - } + fn get_ts_language(&self) -> TSLanguage { + parsers::language_html() + } - fn injectable_languages(&self) -> Option<&'static [&'static str]> { - Some(&["css", "js", "ts", "tsx", "scss", "less", "stylus", "coffee"]) - } + fn injectable_languages(&self) -> Option<&'static [&'static str]> { + Some(&["css", "js", "ts", "tsx", "scss", "less", "stylus", "coffee"]) + } - fn extract_injections( - &self, - root: Node>, - ) -> HashMap> { - let lang = root.lang(); - let mut map = HashMap::new(); - let matcher = KindMatcher::new("script_element", lang.clone()); - for script in root.find_all(matcher) { - let injected = find_html_lang(&script).unwrap_or_else(|| "js".into()); - let content = script.children().find(|c| c.kind() == "raw_text"); - if let Some(content) = content { - map.entry(injected) - .or_insert_with(Vec::new) - .push(node_to_range(&content)); - } - } - let matcher = KindMatcher::new("style_element", lang.clone()); - for style in root.find_all(matcher) { - let injected = find_html_lang(&style).unwrap_or_else(|| "css".into()); - let content = style.children().find(|c| c.kind() == "raw_text"); - if let Some(content) = content { - map.entry(injected) - .or_insert_with(Vec::new) - .push(node_to_range(&content)); - } - } - map - } + fn extract_injections( + &self, + root: Node>, + ) -> HashMap> { + let lang = root.lang(); + let mut map = HashMap::new(); + let matcher = KindMatcher::new("script_element", lang.clone()); + for script in root.find_all(matcher) { + let injected = find_html_lang(&script).unwrap_or_else(|| "js".into()); + let content = script.children().find(|c| c.kind() == "raw_text"); + if let Some(content) = content { + map.entry(injected) + .or_insert_with(Vec::new) + .push(node_to_range(&content)); + } + } + let matcher = KindMatcher::new("style_element", lang.clone()); + for style in root.find_all(matcher) { + let injected = find_html_lang(&style).unwrap_or_else(|| "css".into()); + let content = style.children().find(|c| c.kind() == "raw_text"); + if let Some(content) = content { + map.entry(injected) + .or_insert_with(Vec::new) + .push(node_to_range(&content)); + } + } + map + } } fn find_html_lang(node: &Node) -> Option { - let html = node.lang(); - let attr_matcher = KindMatcher::new("attribute", html.clone()); - let name_matcher = KindMatcher::new("attribute_name", html.clone()); - let val_matcher = KindMatcher::new("attribute_value", html.clone()); - node.find_all(attr_matcher).find_map(|attr| { - let name = attr.find(&name_matcher)?; - if name.text() != "lang" { - return None; - } - let val = attr.find(&val_matcher)?; - Some(val.text().to_string()) - }) + let html = node.lang(); + let attr_matcher = KindMatcher::new("attribute", html.clone()); + let name_matcher = KindMatcher::new("attribute_name", html.clone()); + let val_matcher = KindMatcher::new("attribute_value", html.clone()); + node.find_all(attr_matcher).find_map(|attr| { + let name = attr.find(&name_matcher)?; + if name.text() != "lang" { + return None; + } + let val = attr.find(&val_matcher)?; + Some(val.text().to_string()) + }) } fn node_to_range(node: &Node) -> TSRange { - let r = node.range(); - let start = node.start_pos(); - let sp = start.byte_point(); - let sp = tree_sitter::Point::new(sp.0, sp.1); - let end = node.end_pos(); - let ep = end.byte_point(); - let ep = tree_sitter::Point::new(ep.0, ep.1); - TSRange { start_byte: r.start, end_byte: r.end, start_point: sp, end_point: ep } + let r = node.range(); + let start = node.start_pos(); + let sp = start.byte_point(); + let sp = tree_sitter::Point::new(sp.0, sp.1); + let end = node.end_pos(); + let ep = end.byte_point(); + let ep = tree_sitter::Point::new(ep.0, ep.1); + TSRange { + start_byte: r.start, + end_byte: r.end, + start_point: sp, + end_point: ep, + } } // ── SupportLang enum ──────────────────────────────────────────────────── @@ -248,106 +250,106 @@ fn node_to_range(node: &Node) -> TSRange { /// All supported languages for ast-grep structural search/replace. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum SupportLang { - Bash, - C, - Cpp, - CSharp, - Css, - Diff, - Elixir, - Go, - Haskell, - Hcl, - Html, - Java, - JavaScript, - Json, - Julia, - Kotlin, - Lua, - Make, - Markdown, - Nix, - ObjC, - Odin, - Php, - Python, - Regex, - Ruby, - Rust, - Scala, - Solidity, - Starlark, - Swift, - Toml, - Tsx, - TypeScript, - Verilog, - Xml, - Yaml, - Zig, + Bash, + C, + Cpp, + CSharp, + Css, + Diff, + Elixir, + Go, + Haskell, + Hcl, + Html, + Java, + JavaScript, + Json, + Julia, + Kotlin, + Lua, + Make, + Markdown, + Nix, + ObjC, + Odin, + Php, + Python, + Regex, + Ruby, + Rust, + Scala, + Solidity, + Starlark, + Swift, + Toml, + Tsx, + TypeScript, + Verilog, + Xml, + Yaml, + Zig, } impl SupportLang { - pub const fn all_langs() -> &'static [Self] { - use SupportLang::*; - &[ - Bash, C, Cpp, CSharp, Css, Diff, Elixir, Go, Haskell, Hcl, Html, Java, JavaScript, Json, - Julia, Kotlin, Lua, Make, Markdown, Nix, ObjC, Odin, Php, Python, Regex, Ruby, Rust, - Scala, Solidity, Starlark, Swift, Toml, Tsx, TypeScript, Verilog, Xml, Yaml, Zig, - ] - } + pub const fn all_langs() -> &'static [Self] { + use SupportLang::*; + &[ + Bash, C, Cpp, CSharp, Css, Diff, Elixir, Go, Haskell, Hcl, Html, Java, JavaScript, + Json, Julia, Kotlin, Lua, Make, Markdown, Nix, ObjC, Odin, Php, Python, Regex, Ruby, + Rust, Scala, Solidity, Starlark, Swift, Toml, Tsx, TypeScript, Verilog, Xml, Yaml, Zig, + ] + } - /// The canonical lowercase name used as a stable key in alias maps, - /// file-type inference results, and error messages. - pub const fn canonical_name(self) -> &'static str { - match self { - Self::Bash => "bash", - Self::C => "c", - Self::Cpp => "cpp", - Self::CSharp => "csharp", - Self::Css => "css", - Self::Diff => "diff", - Self::Elixir => "elixir", - Self::Go => "go", - Self::Haskell => "haskell", - Self::Hcl => "hcl", - Self::Html => "html", - Self::Java => "java", - Self::JavaScript => "javascript", - Self::Json => "json", - Self::Julia => "julia", - Self::Kotlin => "kotlin", - Self::Lua => "lua", - Self::Make => "make", - Self::Markdown => "markdown", - Self::Nix => "nix", - Self::ObjC => "objc", - Self::Odin => "odin", - Self::Php => "php", - Self::Python => "python", - Self::Regex => "regex", - Self::Ruby => "ruby", - Self::Rust => "rust", - Self::Scala => "scala", - Self::Solidity => "solidity", - Self::Starlark => "starlark", - Self::Swift => "swift", - Self::Toml => "toml", - Self::Tsx => "tsx", - Self::TypeScript => "typescript", - Self::Verilog => "verilog", - Self::Xml => "xml", - Self::Yaml => "yaml", - Self::Zig => "zig", - } - } + /// The canonical lowercase name used as a stable key in alias maps, + /// file-type inference results, and error messages. + pub const fn canonical_name(self) -> &'static str { + match self { + Self::Bash => "bash", + Self::C => "c", + Self::Cpp => "cpp", + Self::CSharp => "csharp", + Self::Css => "css", + Self::Diff => "diff", + Self::Elixir => "elixir", + Self::Go => "go", + Self::Haskell => "haskell", + Self::Hcl => "hcl", + Self::Html => "html", + Self::Java => "java", + Self::JavaScript => "javascript", + Self::Json => "json", + Self::Julia => "julia", + Self::Kotlin => "kotlin", + Self::Lua => "lua", + Self::Make => "make", + Self::Markdown => "markdown", + Self::Nix => "nix", + Self::ObjC => "objc", + Self::Odin => "odin", + Self::Php => "php", + Self::Python => "python", + Self::Regex => "regex", + Self::Ruby => "ruby", + Self::Rust => "rust", + Self::Scala => "scala", + Self::Solidity => "solidity", + Self::Starlark => "starlark", + Self::Swift => "swift", + Self::Toml => "toml", + Self::Tsx => "tsx", + Self::TypeScript => "typescript", + Self::Verilog => "verilog", + Self::Xml => "xml", + Self::Yaml => "yaml", + Self::Zig => "zig", + } + } } impl fmt::Display for SupportLang { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{self:?}") - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{self:?}") + } } // ── Dispatch macro ────────────────────────────────────────────────────── @@ -408,104 +410,104 @@ macro_rules! impl_lang_method { } impl Language for SupportLang { - impl_lang_method!(kind_to_id, (kind: &str) => u16); + impl_lang_method!(kind_to_id, (kind: &str) => u16); - impl_lang_method!(field_to_id, (field: &str) => Option); + impl_lang_method!(field_to_id, (field: &str) => Option); - impl_lang_method!(meta_var_char, () => char); + impl_lang_method!(meta_var_char, () => char); - impl_lang_method!(expando_char, () => char); + impl_lang_method!(expando_char, () => char); - impl_lang_method!(extract_meta_var, (source: &str) => Option); + impl_lang_method!(extract_meta_var, (source: &str) => Option); - impl_lang_method!(build_pattern, (builder: &PatternBuilder) => Result); + impl_lang_method!(build_pattern, (builder: &PatternBuilder) => Result); - fn pre_process_pattern<'q>(&self, query: &'q str) -> Cow<'q, str> { - execute_lang_method! { self, pre_process_pattern, query } - } + fn pre_process_pattern<'q>(&self, query: &'q str) -> Cow<'q, str> { + execute_lang_method! { self, pre_process_pattern, query } + } - fn from_path>(path: P) -> Option { - from_extension(path.as_ref()) - } + fn from_path>(path: P) -> Option { + from_extension(path.as_ref()) + } } impl LanguageExt for SupportLang { - impl_lang_method!(get_ts_language, () => TSLanguage); + impl_lang_method!(get_ts_language, () => TSLanguage); - impl_lang_method!(injectable_languages, () => Option<&'static [&'static str]>); + impl_lang_method!(injectable_languages, () => Option<&'static [&'static str]>); - fn extract_injections( - &self, - root: Node>, - ) -> HashMap> { - match self { - Self::Html => Html.extract_injections(root), - _ => HashMap::new(), - } - } + fn extract_injections( + &self, + root: Node>, + ) -> HashMap> { + match self { + Self::Html => Html.extract_injections(root), + _ => HashMap::new(), + } + } } // ── File extension mapping ────────────────────────────────────────────── const fn extensions(lang: SupportLang) -> &'static [&'static str] { - use SupportLang::*; - match lang { - Bash => { - &["bash", "bats", "cgi", "command", "env", "fcgi", "ksh", "sh", "tmux", "tool", "zsh"] - }, - C => &["c", "h"], - Cpp => &["cc", "hpp", "cpp", "c++", "hh", "cxx", "cu", "ino"], - CSharp => &["cs"], - Css => &["css", "scss"], - Diff => &["diff", "patch"], - Elixir => &["ex", "exs"], - Go => &["go"], - Haskell => &["hs"], - Hcl => &["hcl", "tf", "tfvars"], - Html => &["html", "htm", "xhtml"], - Java => &["java"], - JavaScript => &["cjs", "js", "mjs", "jsx"], - Json => &["json"], - Julia => &["jl"], - Kotlin => &["kt", "ktm", "kts"], - Lua => &["lua"], - Make => &["mk", "mak"], - Markdown => &["md", "markdown", "mdx"], - Nix => &["nix"], - ObjC => &["m"], - Odin => &["odin"], - Php => &["php"], - Python => &["py", "py3", "pyi", "bzl"], - Regex => &[], // regex has no file extension - Ruby => &["rb", "rbw", "gemspec"], - Rust => &["rs"], - Scala => &["scala", "sc", "sbt"], - Solidity => &["sol"], - Starlark => &["star", "bzl"], - Swift => &["swift"], - Toml => &["toml"], - Tsx => &["tsx"], - TypeScript => &["ts", "cts", "mts"], - Verilog => &["v", "sv", "svh", "vh"], - Xml => &["xml", "xsl", "xslt", "svg", "plist"], - Yaml => &["yaml", "yml"], - Zig => &["zig"], - } + use SupportLang::*; + match lang { + Bash => &[ + "bash", "bats", "cgi", "command", "env", "fcgi", "ksh", "sh", "tmux", "tool", "zsh", + ], + C => &["c", "h"], + Cpp => &["cc", "hpp", "cpp", "c++", "hh", "cxx", "cu", "ino"], + CSharp => &["cs"], + Css => &["css", "scss"], + Diff => &["diff", "patch"], + Elixir => &["ex", "exs"], + Go => &["go"], + Haskell => &["hs"], + Hcl => &["hcl", "tf", "tfvars"], + Html => &["html", "htm", "xhtml"], + Java => &["java"], + JavaScript => &["cjs", "js", "mjs", "jsx"], + Json => &["json"], + Julia => &["jl"], + Kotlin => &["kt", "ktm", "kts"], + Lua => &["lua"], + Make => &["mk", "mak"], + Markdown => &["md", "markdown", "mdx"], + Nix => &["nix"], + ObjC => &["m"], + Odin => &["odin"], + Php => &["php"], + Python => &["py", "py3", "pyi", "bzl"], + Regex => &[], // regex has no file extension + Ruby => &["rb", "rbw", "gemspec"], + Rust => &["rs"], + Scala => &["scala", "sc", "sbt"], + Solidity => &["sol"], + Starlark => &["star", "bzl"], + Swift => &["swift"], + Toml => &["toml"], + Tsx => &["tsx"], + TypeScript => &["ts", "cts", "mts"], + Verilog => &["v", "sv", "svh", "vh"], + Xml => &["xml", "xsl", "xslt", "svg", "plist"], + Yaml => &["yaml", "yml"], + Zig => &["zig"], + } } /// Guess language from file extension. fn from_extension(path: &Path) -> Option { - let ext = path.extension()?.to_str()?; - // Special cases: Makefile has no extension - if ext.is_empty() { - let name = path.file_name()?.to_str()?; - return match name { - "Makefile" | "makefile" | "GNUmakefile" => Some(SupportLang::Make), - _ => None, - }; - } - SupportLang::all_langs() - .iter() - .copied() - .find(|&l| extensions(l).contains(&ext)) + let ext = path.extension()?.to_str()?; + // Special cases: Makefile has no extension + if ext.is_empty() { + let name = path.file_name()?.to_str()?; + return match name { + "Makefile" | "makefile" | "GNUmakefile" => Some(SupportLang::Make), + _ => None, + }; + } + SupportLang::all_langs() + .iter() + .copied() + .find(|&l| extensions(l).contains(&ext)) } diff --git a/rust-engine/crates/ast/src/language/parsers.rs b/rust-engine/crates/ast/src/language/parsers.rs index 5c31b31ca..45a0c82a5 100644 --- a/rust-engine/crates/ast/src/language/parsers.rs +++ b/rust-engine/crates/ast/src/language/parsers.rs @@ -3,116 +3,116 @@ use ast_grep_core::tree_sitter::TSLanguage; pub fn language_bash() -> TSLanguage { - tree_sitter_bash::LANGUAGE.into() + tree_sitter_bash::LANGUAGE.into() } pub fn language_c() -> TSLanguage { - tree_sitter_c::LANGUAGE.into() + tree_sitter_c::LANGUAGE.into() } pub fn language_cpp() -> TSLanguage { - tree_sitter_cpp::LANGUAGE.into() + tree_sitter_cpp::LANGUAGE.into() } pub fn language_c_sharp() -> TSLanguage { - tree_sitter_c_sharp::LANGUAGE.into() + tree_sitter_c_sharp::LANGUAGE.into() } pub fn language_css() -> TSLanguage { - tree_sitter_css::LANGUAGE.into() + tree_sitter_css::LANGUAGE.into() } pub fn language_diff() -> TSLanguage { - tree_sitter_diff::LANGUAGE.into() + tree_sitter_diff::LANGUAGE.into() } pub fn language_elixir() -> TSLanguage { - tree_sitter_elixir::LANGUAGE.into() + tree_sitter_elixir::LANGUAGE.into() } pub fn language_go() -> TSLanguage { - tree_sitter_go::LANGUAGE.into() + tree_sitter_go::LANGUAGE.into() } pub fn language_haskell() -> TSLanguage { - tree_sitter_haskell::LANGUAGE.into() + tree_sitter_haskell::LANGUAGE.into() } pub fn language_hcl() -> TSLanguage { - tree_sitter_hcl::LANGUAGE.into() + tree_sitter_hcl::LANGUAGE.into() } pub fn language_html() -> TSLanguage { - tree_sitter_html::LANGUAGE.into() + tree_sitter_html::LANGUAGE.into() } pub fn language_java() -> TSLanguage { - tree_sitter_java::LANGUAGE.into() + tree_sitter_java::LANGUAGE.into() } pub fn language_javascript() -> TSLanguage { - tree_sitter_javascript::LANGUAGE.into() + tree_sitter_javascript::LANGUAGE.into() } pub fn language_json() -> TSLanguage { - tree_sitter_json::LANGUAGE.into() + tree_sitter_json::LANGUAGE.into() } pub fn language_julia() -> TSLanguage { - tree_sitter_julia::LANGUAGE.into() + tree_sitter_julia::LANGUAGE.into() } pub fn language_kotlin() -> TSLanguage { - tree_sitter_kotlin::LANGUAGE.into() + tree_sitter_kotlin::LANGUAGE.into() } pub fn language_lua() -> TSLanguage { - tree_sitter_lua::LANGUAGE.into() + tree_sitter_lua::LANGUAGE.into() } pub fn language_make() -> TSLanguage { - tree_sitter_make::LANGUAGE.into() + tree_sitter_make::LANGUAGE.into() } pub fn language_markdown() -> TSLanguage { - tree_sitter_md::LANGUAGE.into() + tree_sitter_md::LANGUAGE.into() } pub fn language_nix() -> TSLanguage { - tree_sitter_nix::LANGUAGE.into() + tree_sitter_nix::LANGUAGE.into() } pub fn language_objc() -> TSLanguage { - tree_sitter_objc::LANGUAGE.into() + tree_sitter_objc::LANGUAGE.into() } pub fn language_odin() -> TSLanguage { - tree_sitter_odin::LANGUAGE.into() + tree_sitter_odin::LANGUAGE.into() } pub fn language_php() -> TSLanguage { - tree_sitter_php::LANGUAGE_PHP_ONLY.into() + tree_sitter_php::LANGUAGE_PHP_ONLY.into() } pub fn language_python() -> TSLanguage { - tree_sitter_python::LANGUAGE.into() + tree_sitter_python::LANGUAGE.into() } pub fn language_regex() -> TSLanguage { - tree_sitter_regex::LANGUAGE.into() + tree_sitter_regex::LANGUAGE.into() } pub fn language_ruby() -> TSLanguage { - tree_sitter_ruby::LANGUAGE.into() + tree_sitter_ruby::LANGUAGE.into() } pub fn language_rust() -> TSLanguage { - tree_sitter_rust::LANGUAGE.into() + tree_sitter_rust::LANGUAGE.into() } pub fn language_scala() -> TSLanguage { - tree_sitter_scala::LANGUAGE.into() + tree_sitter_scala::LANGUAGE.into() } pub fn language_solidity() -> TSLanguage { - tree_sitter_solidity::LANGUAGE.into() + tree_sitter_solidity::LANGUAGE.into() } pub fn language_starlark() -> TSLanguage { - tree_sitter_starlark::LANGUAGE.into() + tree_sitter_starlark::LANGUAGE.into() } pub fn language_swift() -> TSLanguage { - tree_sitter_swift::LANGUAGE.into() + tree_sitter_swift::LANGUAGE.into() } pub fn language_toml() -> TSLanguage { - tree_sitter_toml_ng::LANGUAGE.into() + tree_sitter_toml_ng::LANGUAGE.into() } pub fn language_tsx() -> TSLanguage { - tree_sitter_typescript::LANGUAGE_TSX.into() + tree_sitter_typescript::LANGUAGE_TSX.into() } pub fn language_typescript() -> TSLanguage { - tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into() + tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into() } pub fn language_verilog() -> TSLanguage { - tree_sitter_verilog::LANGUAGE.into() + tree_sitter_verilog::LANGUAGE.into() } pub fn language_xml() -> TSLanguage { - tree_sitter_xml::LANGUAGE_XML.into() + tree_sitter_xml::LANGUAGE_XML.into() } pub fn language_yaml() -> TSLanguage { - tree_sitter_yaml::LANGUAGE.into() + tree_sitter_yaml::LANGUAGE.into() } pub fn language_zig() -> TSLanguage { - tree_sitter_zig::LANGUAGE.into() + tree_sitter_zig::LANGUAGE.into() } diff --git a/rust-engine/crates/engine/src/diff.rs b/rust-engine/crates/engine/src/diff.rs index 4f83529e3..f6c56985e 100644 --- a/rust-engine/crates/engine/src/diff.rs +++ b/rust-engine/crates/engine/src/diff.rs @@ -143,7 +143,11 @@ pub struct DiffResult { /// - ` N line` for context /// - ` ... ` for skipped context #[napi(js_name = "generateDiff")] -pub fn generate_diff(old_content: String, new_content: String, context_lines: Option) -> DiffResult { +pub fn generate_diff( + old_content: String, + new_content: String, + context_lines: Option, +) -> DiffResult { let context = context_lines.unwrap_or(4) as usize; generate_diff_impl(&old_content, &new_content, context) } @@ -192,36 +196,59 @@ fn generate_diff_impl(old_content: &str, new_content: &str, context_lines: usize .iter() .map(|s| s.to_string()) .collect(); - parts.push(Part { tag: PartTag::Equal, lines }); + parts.push(Part { + tag: PartTag::Equal, + lines, + }); } - similar::DiffOp::Delete { old_index, old_len, .. } => { + similar::DiffOp::Delete { + old_index, old_len, .. + } => { let lines: Vec = old_lines[*old_index..*old_index + *old_len] .iter() .map(|s| s.to_string()) .collect(); - parts.push(Part { tag: PartTag::Removed, lines }); + parts.push(Part { + tag: PartTag::Removed, + lines, + }); } - similar::DiffOp::Insert { new_index, new_len, .. } => { + similar::DiffOp::Insert { + new_index, new_len, .. + } => { let lines: Vec = new_lines[*new_index..*new_index + *new_len] .iter() .map(|s| s.to_string()) .collect(); - parts.push(Part { tag: PartTag::Added, lines }); + parts.push(Part { + tag: PartTag::Added, + lines, + }); } similar::DiffOp::Replace { - old_index, old_len, new_index, new_len, .. + old_index, + old_len, + new_index, + new_len, + .. } => { let del_lines: Vec = old_lines[*old_index..*old_index + *old_len] .iter() .map(|s| s.to_string()) .collect(); - parts.push(Part { tag: PartTag::Removed, lines: del_lines }); + parts.push(Part { + tag: PartTag::Removed, + lines: del_lines, + }); let ins_lines: Vec = new_lines[*new_index..*new_index + *new_len] .iter() .map(|s| s.to_string()) .collect(); - parts.push(Part { tag: PartTag::Added, lines: ins_lines }); + parts.push(Part { + tag: PartTag::Added, + lines: ins_lines, + }); } } } @@ -274,11 +301,7 @@ fn generate_diff_impl(old_content: &str, new_content: &str, context_lines: usize } if skip_start > 0 { - output.push(format!( - " {:>width$} ...", - "", - width = line_num_width - )); + output.push(format!(" {:>width$} ...", "", width = line_num_width)); old_line_num += skip_start; new_line_num += skip_start; } @@ -291,11 +314,7 @@ fn generate_diff_impl(old_content: &str, new_content: &str, context_lines: usize } if skip_end > 0 { - output.push(format!( - " {:>width$} ...", - "", - width = line_num_width - )); + output.push(format!(" {:>width$} ...", "", width = line_num_width)); old_line_num += skip_end; new_line_num += skip_end; } diff --git a/rust-engine/crates/engine/src/edit.rs b/rust-engine/crates/engine/src/edit.rs index 931dcff5a..8db651875 100644 --- a/rust-engine/crates/engine/src/edit.rs +++ b/rust-engine/crates/engine/src/edit.rs @@ -379,9 +379,8 @@ pub fn apply_workspace_edit( // ── Phase 2: commit ────────────────────────────────────────────────── let mut file_results: Vec = Vec::with_capacity(staged.len()); - let mut succeeded = 0usize; - for (final_path, tmp_path, new_bytes, edits_applied) in &staged { + for (succeeded, (final_path, tmp_path, new_bytes, edits_applied)) in staged.iter().enumerate() { if let Err(e) = fs::rename(tmp_path, final_path) { // Cleanup remaining staged tmps (including this one if rename failed // before touching the original). @@ -402,7 +401,6 @@ pub fn apply_workspace_edit( edits_applied: *edits_applied, bytes_written: new_bytes.len() as u32, }); - succeeded += 1; } // ── fsync parent directories (deduplicated) ────────────────────────── diff --git a/rust-engine/crates/engine/src/fd.rs b/rust-engine/crates/engine/src/fd.rs index 71d5def55..5384a4003 100644 --- a/rust-engine/crates/engine/src/fd.rs +++ b/rust-engine/crates/engine/src/fd.rs @@ -225,9 +225,9 @@ fn collect_matches( let score = score_fuzzy_path( &entry.path, is_directory, - &query_lower, - &normalized_query, - &query_chars, + query_lower, + normalized_query, + query_chars, ); if score == 0 { continue; diff --git a/rust-engine/crates/engine/src/forge_parser.rs b/rust-engine/crates/engine/src/forge_parser.rs index 4ba0c7ace..136bd59db 100644 --- a/rust-engine/crates/engine/src/forge_parser.rs +++ b/rust-engine/crates/engine/src/forge_parser.rs @@ -141,22 +141,27 @@ fn parse_frontmatter_map_internal(lines: &[&str]) -> Vec<(String, FmValue)> { for line in lines { // Nested object property (4-space indent with key: value) - if line.starts_with(" ") && !line.starts_with(" ") { - if current_array.is_some() && current_obj.is_some() { - let rest = line.trim_start(); - if let Some(colon_pos) = rest.find(": ") { - let k = &rest[..colon_pos]; - let v = rest[colon_pos + 2..].trim(); - if k.chars().all(|c| c.is_alphanumeric() || c == '_') { - current_obj.as_mut().unwrap().push((k.to_string(), v.to_string())); - continue; + if line.starts_with(" ") + && !line.starts_with(" ") + && current_array.is_some() + && current_obj.is_some() + { + let rest = line.trim_start(); + if let Some(colon_pos) = rest.find(": ") { + let k = &rest[..colon_pos]; + let v = rest[colon_pos + 2..].trim(); + if k.chars().all(|c| c.is_alphanumeric() || c == '_') { + if let Some(current_obj) = current_obj.as_mut() { + current_obj.push((k.to_string(), v.to_string())); } - } else if rest.ends_with(':') { - let k = &rest[..rest.len() - 1]; - if k.chars().all(|c| c.is_alphanumeric() || c == '_') { - current_obj.as_mut().unwrap().push((k.to_string(), String::new())); - continue; + continue; + } + } else if let Some(k) = rest.strip_suffix(':') { + if k.chars().all(|c| c.is_alphanumeric() || c == '_') { + if let Some(current_obj) = current_obj.as_mut() { + current_obj.push((k.to_string(), String::new())); } + continue; } } } @@ -187,7 +192,10 @@ fn parse_frontmatter_map_internal(lines: &[&str]) -> Vec<(String, FmValue)> { } } - current_array.as_mut().unwrap().push(FmArrayItem::Str(val.to_string())); + current_array + .as_mut() + .unwrap() + .push(FmArrayItem::Str(val.to_string())); continue; } @@ -481,18 +489,15 @@ fn parse_roadmap_internal(content: &str) -> NativeRoadmap { .unwrap_or("") .to_string(); - let sc_section = extract_section_internal(content, "Success Criteria", 2) - .or_else(|| { - let idx = content.find("**Success Criteria:**")?; - let rest = &content[idx..]; - let next_section = rest.find("\n---"); - let block = &rest[..next_section.unwrap_or(rest.len())]; - let first_newline = block.find('\n')?; - Some(block[first_newline + 1..].to_string()) - }); - let success_criteria = sc_section - .map(|s| parse_bullets(&s)) - .unwrap_or_default(); + let sc_section = extract_section_internal(content, "Success Criteria", 2).or_else(|| { + let idx = content.find("**Success Criteria:**")?; + let rest = &content[idx..]; + let next_section = rest.find("\n---"); + let block = &rest[..next_section.unwrap_or(rest.len())]; + let first_newline = block.find('\n')?; + Some(block[first_newline + 1..].to_string()) + }); + let success_criteria = sc_section.map(|s| parse_bullets(&s)).unwrap_or_default(); let slices = parse_roadmap_slices_internal(content); let boundary_map = parse_boundary_map_internal(content); @@ -511,7 +516,7 @@ fn parse_roadmap_slices_internal(content: &str) -> Vec { Some(idx) => { let start = idx + "## Slices".len(); let rest = &content[start..]; - let rest = rest.trim_start_matches(|c: char| c == '\r' || c == '\n'); + let rest = rest.trim_start_matches(['\r', '\n']); let end = rest.find("\n## ").unwrap_or(rest.len()); rest[..end].trim_end() } @@ -532,8 +537,8 @@ fn parse_roadmap_slices_internal(content: &str) -> Vec { if let Some(ref mut s) = current_slice { let trimmed = line.trim(); - if trimmed.starts_with('>') { - let demo = trimmed[1..].trim(); + if let Some(demo) = trimmed.strip_prefix('>') { + let demo = demo.trim(); let demo = if demo.to_lowercase().starts_with("after this:") { demo["after this:".len()..].trim() } else { @@ -621,8 +626,7 @@ fn parse_boundary_map_internal(content: &str) -> Vec { let mut entries = Vec::new(); for (heading, section_content) in h3_sections { - let arrow_pos = heading.find('\u{2192}') - .or_else(|| heading.find("->")); + let arrow_pos = heading.find('\u{2192}').or_else(|| heading.find("->")); if let Some(pos) = arrow_pos { let arrow_len = if heading[pos..].starts_with('\u{2192}') { @@ -630,8 +634,16 @@ fn parse_boundary_map_internal(content: &str) -> Vec { } else { 2 }; - let from_slice = heading[..pos].trim().split_whitespace().next().unwrap_or("").to_string(); - let to_slice = heading[pos + arrow_len..].trim().split_whitespace().next().unwrap_or("").to_string(); + let from_slice = heading[..pos] + .split_whitespace() + .next() + .unwrap_or("") + .to_string(); + let to_slice = heading[pos + arrow_len..] + .split_whitespace() + .next() + .unwrap_or("") + .to_string(); let mut produces = String::new(); let mut consumes = String::new(); @@ -1057,13 +1069,9 @@ pub fn parse_plan_file(content: String) -> NativePlan { let id = fm_id.unwrap_or(heading_id); - let goal = extract_bold_field(body, "Goal") - .unwrap_or("") - .to_string(); + let goal = extract_bold_field(body, "Goal").unwrap_or("").to_string(); - let demo = extract_bold_field(body, "Demo") - .unwrap_or("") - .to_string(); + let demo = extract_bold_field(body, "Demo").unwrap_or("").to_string(); let must_haves = extract_section_internal(body, "Must-Haves", 2) .map(|s| parse_bullets(&s)) @@ -1129,10 +1137,7 @@ fn parse_plan_tasks(body: &str) -> Vec { let after_bold = &after_bracket[2 + bold_end + 2..]; let estimate = if let Some(est_start) = after_bold.find("`est:") { let val_start = est_start + 5; - let val_end = after_bold[val_start..] - .find('`') - .unwrap_or(0) - + val_start; + let val_end = after_bold[val_start..].find('`').unwrap_or(0) + val_start; after_bold[val_start..val_end].to_string() } else { String::new() @@ -1259,11 +1264,9 @@ pub fn parse_summary_file(content: String) -> NativeSummary { result }; - let what_happened = extract_section_internal(body, "What Happened", 2) - .unwrap_or_default(); + let what_happened = extract_section_internal(body, "What Happened", 2).unwrap_or_default(); - let deviations = extract_section_internal(body, "Deviations", 2) - .unwrap_or_default(); + let deviations = extract_section_internal(body, "Deviations", 2).unwrap_or_default(); let files_modified = extract_section_internal(body, "Files Created/Modified", 2) .or_else(|| extract_section_internal(body, "Files Modified", 2)) @@ -1327,8 +1330,7 @@ fn parse_summary_frontmatter(fm_map: &[(String, FmValue)]) -> NativeSummaryFront }; let blocker_str = get_scalar("blocker_discovered"); - let blocker_discovered = - blocker_str == "true" || blocker_str == "yes" || blocker_str == "True"; + let blocker_discovered = blocker_str == "true" || blocker_str == "yes" || blocker_str == "True"; NativeSummaryFrontmatter { id: get_scalar("id"), @@ -1359,15 +1361,20 @@ fn parse_files_modified(section: &str) -> Vec { }; // Parse `path` — description or `path` - description - if text.starts_with('`') { - if let Some(end_tick) = text[1..].find('`') { - let path = text[1..1 + end_tick].to_string(); - let rest = text[1 + end_tick + 1..].trim(); - let description = if rest.starts_with("—") || rest.starts_with("–") || rest.starts_with('-') { - rest[rest.find(|c: char| c != '—' && c != '–' && c != '-').unwrap_or(rest.len())..].trim().to_string() - } else { - rest.to_string() - }; + if let Some(rest) = text.strip_prefix('`') { + if let Some(end_tick) = rest.find('`') { + let path = rest[..end_tick].to_string(); + let rest = rest[end_tick + 1..].trim(); + let description = + if rest.starts_with("—") || rest.starts_with("–") || rest.starts_with('-') { + rest[rest + .find(|c: char| c != '—' && c != '–' && c != '-') + .unwrap_or(rest.len())..] + .trim() + .to_string() + } else { + rest.to_string() + }; files.push(NativeFileModified { path, description }); } } diff --git a/rust-engine/crates/engine/src/git.rs b/rust-engine/crates/engine/src/git.rs index c9fd455ec..6d8b79ade 100644 --- a/rust-engine/crates/engine/src/git.rs +++ b/rust-engine/crates/engine/src/git.rs @@ -36,17 +36,26 @@ fn git_err(context: &str, e: git2::Error) -> Error { /// Prevents path traversal attacks via patterns like `../../etc/passwd`. fn validate_path_within_repo(repo_path: &str, file_path: &str) -> Result { let repo_dir = std::fs::canonicalize(repo_path).map_err(|e| { - Error::new(Status::GenericFailure, format!("Failed to canonicalize repo path '{repo_path}': {e}")) + Error::new( + Status::GenericFailure, + format!("Failed to canonicalize repo path '{repo_path}': {e}"), + ) })?; let full_path = repo_dir.join(file_path); let canonical = if full_path.exists() { std::fs::canonicalize(&full_path).map_err(|e| { - Error::new(Status::GenericFailure, format!("Failed to canonicalize path '{file_path}': {e}")) + Error::new( + Status::GenericFailure, + format!("Failed to canonicalize path '{file_path}': {e}"), + ) })? } else if let Some(parent) = full_path.parent() { if parent.exists() { let cp = std::fs::canonicalize(parent).map_err(|e| { - Error::new(Status::GenericFailure, format!("Failed to canonicalize parent of '{file_path}': {e}")) + Error::new( + Status::GenericFailure, + format!("Failed to canonicalize parent of '{file_path}': {e}"), + ) })?; cp.join(full_path.file_name().unwrap_or_default()) } else { @@ -56,7 +65,10 @@ fn validate_path_within_repo(repo_path: &str, file_path: &str) -> Result Result> { let repo = open_repo(&repo_path)?; - let head = repo - .head() - .map_err(|e| git_err("Failed to read HEAD", e))?; + let head = repo.head().map_err(|e| git_err("Failed to read HEAD", e))?; if head.is_branch() { Ok(head.shorthand().map(String::from)) @@ -200,9 +210,7 @@ pub fn git_main_branch(repo_path: String) -> Result { return Ok("master".to_string()); } - let head = repo - .head() - .map_err(|e| git_err("Failed to read HEAD", e))?; + let head = repo.head().map_err(|e| git_err("Failed to read HEAD", e))?; Ok(head.shorthand().unwrap_or("HEAD").to_string()) } @@ -358,11 +366,7 @@ pub fn git_has_staged_changes(repo_path: String) -> Result { /// When `from_ref` is "HEAD" and `to_ref` is "INDEX", diffs index vs HEAD (staged). /// Replaces: `git diff --stat HEAD`, `git diff --stat --cached HEAD` #[napi] -pub fn git_diff_stat( - repo_path: String, - from_ref: String, - to_ref: String, -) -> Result { +pub fn git_diff_stat(repo_path: String, from_ref: String, to_ref: String) -> Result { let repo = open_repo(&repo_path)?; let diff = match (from_ref.as_str(), to_ref.as_str()) { @@ -506,14 +510,11 @@ pub fn git_diff_numstat( // Count added/removed lines per file using the patch API for (i, _) in diff.deltas().enumerate() { - if let Ok(patch) = git2::Patch::from_diff(&diff, i) { - if let Some(patch) = patch { - let (_, additions, deletions) = patch.line_stats() - .unwrap_or((0, 0, 0)); - if let Some(entry) = results.get_mut(i) { - entry.added = additions as u32; - entry.removed = deletions as u32; - } + if let Ok(Some(patch)) = git2::Patch::from_diff(&diff, i) { + let (_, additions, deletions) = patch.line_stats().unwrap_or((0, 0, 0)); + if let Some(entry) = results.get_mut(i) { + entry.added = additions as u32; + entry.removed = deletions as u32; } } } @@ -783,7 +784,9 @@ pub fn git_ls_files(repo_path: String, pathspec: String) -> Result> let mut files = Vec::new(); for entry in index.iter() { let path = String::from_utf8_lossy(&entry.path).to_string(); - if path.starts_with(&pathspec) || (pathspec.ends_with('/') && path.starts_with(pathspec.trim_end_matches('/'))) { + if path.starts_with(&pathspec) + || (pathspec.ends_with('/') && path.starts_with(pathspec.trim_end_matches('/'))) + { files.push(path); } } @@ -1030,11 +1033,7 @@ pub fn git_reset_paths(repo_path: String, paths: Vec) -> Result<()> { /// Returns the commit SHA. /// Replaces: `git commit -m `, `git commit --no-verify -F -` #[napi] -pub fn git_commit( - repo_path: String, - message: String, - allow_empty: Option, -) -> Result { +pub fn git_commit(repo_path: String, message: String, allow_empty: Option) -> Result { let repo = open_repo(&repo_path)?; let mut index = repo .index() @@ -1045,8 +1044,7 @@ pub fn git_commit( let merge_msg_path = repo.path().join("MERGE_MSG"); let squash_msg_path = repo.path().join("SQUASH_MSG"); if merge_msg_path.exists() { - std::fs::read_to_string(&merge_msg_path) - .unwrap_or_else(|_| "Merge commit".to_string()) + std::fs::read_to_string(&merge_msg_path).unwrap_or_else(|_| "Merge commit".to_string()) } else if squash_msg_path.exists() { std::fs::read_to_string(&squash_msg_path) .unwrap_or_else(|_| "Squash commit".to_string()) @@ -1107,8 +1105,12 @@ pub fn git_commit( for msg_file in &["SQUASH_MSG", "MERGE_MSG"] { let msg_path = repo.path().join(msg_file); if msg_path.exists() { - std::fs::remove_file(&msg_path) - .map_err(|e| Error::new(Status::GenericFailure, format!("Failed to clean up {msg_file}: {e}")))?; + std::fs::remove_file(&msg_path).map_err(|e| { + Error::new( + Status::GenericFailure, + format!("Failed to clean up {msg_file}: {e}"), + ) + })?; } } @@ -1183,11 +1185,19 @@ pub fn git_checkout_theirs(repo_path: String, paths: Vec) -> Result<()> .map_err(|e| git_err(&format!("Failed to find blob for '{path}'"), e))?; let full_path = validate_path_within_repo(&repo_path, path)?; if let Some(parent) = full_path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| Error::new(Status::GenericFailure, format!("Failed to create directory for '{path}': {e}")))?; + std::fs::create_dir_all(parent).map_err(|e| { + Error::new( + Status::GenericFailure, + format!("Failed to create directory for '{path}': {e}"), + ) + })?; } - std::fs::write(&full_path, blob.content()) - .map_err(|e| Error::new(Status::GenericFailure, format!("Failed to write '{path}': {e}")))?; + std::fs::write(&full_path, blob.content()).map_err(|e| { + Error::new( + Status::GenericFailure, + format!("Failed to write '{path}': {e}"), + ) + })?; } } @@ -1233,8 +1243,12 @@ pub fn git_merge_squash(repo_path: String, branch: String) -> Result Result<()> { let repo = open_repo(&repo_path)?; // Reset to HEAD - let head = repo - .head() - .map_err(|e| git_err("Failed to read HEAD", e))?; + let head = repo.head().map_err(|e| git_err("Failed to read HEAD", e))?; let obj = head .peel(ObjectType::Commit) .map_err(|e| git_err("Failed to peel HEAD", e))?; @@ -1321,12 +1333,20 @@ pub fn git_rebase_abort(repo_path: String) -> Result<()> { // Clean up rebase state directories if rebase_merge.exists() { - std::fs::remove_dir_all(&rebase_merge) - .map_err(|e| Error::new(Status::GenericFailure, format!("Failed to remove rebase-merge state: {e}")))?; + std::fs::remove_dir_all(&rebase_merge).map_err(|e| { + Error::new( + Status::GenericFailure, + format!("Failed to remove rebase-merge state: {e}"), + ) + })?; } if rebase_apply.exists() { - std::fs::remove_dir_all(&rebase_apply) - .map_err(|e| Error::new(Status::GenericFailure, format!("Failed to remove rebase-apply state: {e}")))?; + std::fs::remove_dir_all(&rebase_apply).map_err(|e| { + Error::new( + Status::GenericFailure, + format!("Failed to remove rebase-apply state: {e}"), + ) + })?; } } @@ -1341,9 +1361,7 @@ pub fn git_rebase_abort(repo_path: String) -> Result<()> { pub fn git_reset_hard(repo_path: String) -> Result<()> { let repo = open_repo(&repo_path)?; - let head = repo - .head() - .map_err(|e| git_err("Failed to read HEAD", e))?; + let head = repo.head().map_err(|e| git_err("Failed to read HEAD", e))?; let obj = head .peel(ObjectType::Commit) .map_err(|e| git_err("Failed to peel HEAD", e))?; @@ -1385,11 +1403,7 @@ pub fn git_branch_delete(repo_path: String, branch: String, force: Option) /// Force-reset a branch to point at a target ref. /// Replaces: `git branch -f ` #[napi] -pub fn git_branch_force_reset( - repo_path: String, - branch: String, - target: String, -) -> Result<()> { +pub fn git_branch_force_reset(repo_path: String, branch: String, target: String) -> Result<()> { let repo = open_repo(&repo_path)?; let target_commit = repo @@ -1446,10 +1460,8 @@ pub fn git_rm_cached( removed.push(format!("rm '{entry_path}'")); } } - } else { - if index.remove_path(Path::new(path)).is_ok() { - removed.push(format!("rm '{path}'")); - } + } else if index.remove_path(Path::new(path)).is_ok() { + removed.push(format!("rm '{path}'")); } } @@ -1472,13 +1484,18 @@ pub fn git_rm_force(repo_path: String, paths: Vec) -> Result<()> { .map_err(|e| git_err("Failed to read index", e))?; for path in &paths { - index.remove_path(Path::new(path)) + index + .remove_path(Path::new(path)) .map_err(|e| git_err(&format!("Failed to remove '{path}' from index"), e))?; // Also delete from working tree (with path traversal validation) let full_path = validate_path_within_repo(&repo_path, path)?; if full_path.exists() { - std::fs::remove_file(&full_path) - .map_err(|e| Error::new(Status::GenericFailure, format!("Failed to delete '{path}': {e}")))?; + std::fs::remove_file(&full_path).map_err(|e| { + Error::new( + Status::GenericFailure, + format!("Failed to delete '{path}': {e}"), + ) + })?; } } @@ -1523,10 +1540,7 @@ pub fn git_worktree_add( repo.worktree( &branch, // worktree name Path::new(&wt_path), - Some( - git2::WorktreeAddOptions::new() - .reference(Some(&reference)), - ), + Some(git2::WorktreeAddOptions::new().reference(Some(&reference))), ) .map_err(|e| git_err(&format!("Failed to add worktree at '{wt_path}'"), e))?; @@ -1615,8 +1629,7 @@ pub fn git_worktree_prune(repo_path: String) -> Result<()> { pub fn git_revert_commit(repo_path: String, sha: String) -> Result<()> { let repo = open_repo(&repo_path)?; - let oid = git2::Oid::from_str(&sha) - .map_err(|e| git_err(&format!("Invalid SHA '{sha}'"), e))?; + let oid = git2::Oid::from_str(&sha).map_err(|e| git_err(&format!("Invalid SHA '{sha}'"), e))?; let commit = repo .find_commit(oid) diff --git a/rust-engine/crates/engine/src/glob.rs b/rust-engine/crates/engine/src/glob.rs index 61be0e1de..8ab865b14 100644 --- a/rust-engine/crates/engine/src/glob.rs +++ b/rust-engine/crates/engine/src/glob.rs @@ -175,8 +175,12 @@ fn run_glob( } let mut matches = if config.use_cache { - let scan = - fs_cache::get_or_scan(&config.root, config.include_hidden, config.use_gitignore, &ct)?; + let scan = fs_cache::get_or_scan( + &config.root, + config.include_hidden, + config.use_gitignore, + &ct, + )?; let mut matches = filter_entries(&scan.entries, &glob_set, &config, on_match, &ct)?; // Empty-result recheck: if we got zero matches from a cached scan that's old // enough, force a rescan and try once more before returning empty. diff --git a/rust-engine/crates/engine/src/glob_util.rs b/rust-engine/crates/engine/src/glob_util.rs index 4f0d98e8e..484dae6de 100644 --- a/rust-engine/crates/engine/src/glob_util.rs +++ b/rust-engine/crates/engine/src/glob_util.rs @@ -81,10 +81,7 @@ mod tests { #[test] fn unclosed_brace_gets_closed() { - assert_eq!( - build_glob_pattern("*.{ts,tsx,js", true), - "**/*.{ts,tsx,js}" - ); + assert_eq!(build_glob_pattern("*.{ts,tsx,js", true), "**/*.{ts,tsx,js}"); } #[test] diff --git a/rust-engine/crates/engine/src/highlight.rs b/rust-engine/crates/engine/src/highlight.rs index e2ba692da..367051077 100644 --- a/rust-engine/crates/engine/src/highlight.rs +++ b/rust-engine/crates/engine/src/highlight.rs @@ -15,109 +15,109 @@ static SCOPE_MATCHERS: OnceLock = OnceLock::new(); // Thread-local cache for scope -> color index lookups thread_local! { - static SCOPE_COLOR_CACHE: RefCell> = RefCell::new(HashMap::with_capacity(256)); + static SCOPE_COLOR_CACHE: RefCell> = RefCell::new(HashMap::with_capacity(256)); } fn get_syntax_set() -> &'static SyntaxSet { - SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines) + SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines) } /// Pre-compiled scope patterns for fast matching. struct ScopeMatchers { - // Comment (index 0) - comment: Scope, + // Comment (index 0) + comment: Scope, - // String (index 4) - string: Scope, - constant_character: Scope, - meta_string: Scope, + // String (index 4) + string: Scope, + constant_character: Scope, + meta_string: Scope, - // Number (index 5) - constant_numeric: Scope, - constant_integer: Scope, - constant: Scope, + // Number (index 5) + constant_numeric: Scope, + constant_integer: Scope, + constant: Scope, - // Keyword (index 1) - keyword: Scope, - storage_type: Scope, - storage_modifier: Scope, + // Keyword (index 1) + keyword: Scope, + storage_type: Scope, + storage_modifier: Scope, - // Function (index 2) - entity_name_function: Scope, - support_function: Scope, - meta_function_call: Scope, - variable_function: Scope, + // Function (index 2) + entity_name_function: Scope, + support_function: Scope, + meta_function_call: Scope, + variable_function: Scope, - // Type (index 6) - entity_name_type: Scope, - support_type: Scope, - support_class: Scope, - entity_name_class: Scope, - entity_name_struct: Scope, - entity_name_enum: Scope, - entity_name_interface: Scope, - entity_name_trait: Scope, + // Type (index 6) + entity_name_type: Scope, + support_type: Scope, + support_class: Scope, + entity_name_class: Scope, + entity_name_struct: Scope, + entity_name_enum: Scope, + entity_name_interface: Scope, + entity_name_trait: Scope, - // Operator (index 7) - keyword_operator: Scope, - punctuation_accessor: Scope, + // Operator (index 7) + keyword_operator: Scope, + punctuation_accessor: Scope, - // Punctuation (index 8) - punctuation: Scope, + // Punctuation (index 8) + punctuation: Scope, - // Variable (index 3) - variable: Scope, - entity_name: Scope, - meta_path: Scope, + // Variable (index 3) + variable: Scope, + entity_name: Scope, + meta_path: Scope, - // Diff (indices 9, 10) - markup_inserted: Scope, - markup_deleted: Scope, - meta_diff_header: Scope, - meta_diff_range: Scope, + // Diff (indices 9, 10) + markup_inserted: Scope, + markup_deleted: Scope, + meta_diff_header: Scope, + meta_diff_range: Scope, } impl ScopeMatchers { - fn new() -> Self { - Self { - comment: Scope::new("comment").unwrap(), - string: Scope::new("string").unwrap(), - constant_character: Scope::new("constant.character").unwrap(), - meta_string: Scope::new("meta.string").unwrap(), - constant_numeric: Scope::new("constant.numeric").unwrap(), - constant_integer: Scope::new("constant.integer").unwrap(), - constant: Scope::new("constant").unwrap(), - keyword: Scope::new("keyword").unwrap(), - storage_type: Scope::new("storage.type").unwrap(), - storage_modifier: Scope::new("storage.modifier").unwrap(), - entity_name_function: Scope::new("entity.name.function").unwrap(), - support_function: Scope::new("support.function").unwrap(), - meta_function_call: Scope::new("meta.function-call").unwrap(), - variable_function: Scope::new("variable.function").unwrap(), - entity_name_type: Scope::new("entity.name.type").unwrap(), - support_type: Scope::new("support.type").unwrap(), - support_class: Scope::new("support.class").unwrap(), - entity_name_class: Scope::new("entity.name.class").unwrap(), - entity_name_struct: Scope::new("entity.name.struct").unwrap(), - entity_name_enum: Scope::new("entity.name.enum").unwrap(), - entity_name_interface: Scope::new("entity.name.interface").unwrap(), - entity_name_trait: Scope::new("entity.name.trait").unwrap(), - keyword_operator: Scope::new("keyword.operator").unwrap(), - punctuation_accessor: Scope::new("punctuation.accessor").unwrap(), - punctuation: Scope::new("punctuation").unwrap(), - variable: Scope::new("variable").unwrap(), - entity_name: Scope::new("entity.name").unwrap(), - meta_path: Scope::new("meta.path").unwrap(), - markup_inserted: Scope::new("markup.inserted").unwrap(), - markup_deleted: Scope::new("markup.deleted").unwrap(), - meta_diff_header: Scope::new("meta.diff.header").unwrap(), - meta_diff_range: Scope::new("meta.diff.range").unwrap(), - } - } + fn new() -> Self { + Self { + comment: Scope::new("comment").unwrap(), + string: Scope::new("string").unwrap(), + constant_character: Scope::new("constant.character").unwrap(), + meta_string: Scope::new("meta.string").unwrap(), + constant_numeric: Scope::new("constant.numeric").unwrap(), + constant_integer: Scope::new("constant.integer").unwrap(), + constant: Scope::new("constant").unwrap(), + keyword: Scope::new("keyword").unwrap(), + storage_type: Scope::new("storage.type").unwrap(), + storage_modifier: Scope::new("storage.modifier").unwrap(), + entity_name_function: Scope::new("entity.name.function").unwrap(), + support_function: Scope::new("support.function").unwrap(), + meta_function_call: Scope::new("meta.function-call").unwrap(), + variable_function: Scope::new("variable.function").unwrap(), + entity_name_type: Scope::new("entity.name.type").unwrap(), + support_type: Scope::new("support.type").unwrap(), + support_class: Scope::new("support.class").unwrap(), + entity_name_class: Scope::new("entity.name.class").unwrap(), + entity_name_struct: Scope::new("entity.name.struct").unwrap(), + entity_name_enum: Scope::new("entity.name.enum").unwrap(), + entity_name_interface: Scope::new("entity.name.interface").unwrap(), + entity_name_trait: Scope::new("entity.name.trait").unwrap(), + keyword_operator: Scope::new("keyword.operator").unwrap(), + punctuation_accessor: Scope::new("punctuation.accessor").unwrap(), + punctuation: Scope::new("punctuation").unwrap(), + variable: Scope::new("variable").unwrap(), + entity_name: Scope::new("entity.name").unwrap(), + meta_path: Scope::new("meta.path").unwrap(), + markup_inserted: Scope::new("markup.inserted").unwrap(), + markup_deleted: Scope::new("markup.deleted").unwrap(), + meta_diff_header: Scope::new("meta.diff.header").unwrap(), + meta_diff_range: Scope::new("meta.diff.range").unwrap(), + } + } } fn get_scope_matchers() -> &'static ScopeMatchers { - SCOPE_MATCHERS.get_or_init(ScopeMatchers::new) + SCOPE_MATCHERS.get_or_init(ScopeMatchers::new) } /// Theme colors for syntax highlighting. @@ -125,228 +125,240 @@ fn get_scope_matchers() -> &'static ScopeMatchers { #[derive(Debug)] #[napi(object)] pub struct HighlightColors { - /// ANSI color for comments. - pub comment: String, - /// ANSI color for keywords. - pub keyword: String, - /// ANSI color for function names. - pub function: String, - /// ANSI color for variables and identifiers. - pub variable: String, - /// ANSI color for string literals. - pub string: String, - /// ANSI color for numeric literals. - pub number: String, - /// ANSI color for type identifiers. - #[napi(js_name = "type")] - pub r#type: String, - /// ANSI color for operators. - pub operator: String, - /// ANSI color for punctuation tokens. - pub punctuation: String, - /// ANSI color for diff inserted lines. - #[napi(js_name = "inserted")] - pub inserted: Option, - /// ANSI color for diff deleted lines. - #[napi(js_name = "deleted")] - pub deleted: Option, + /// ANSI color for comments. + pub comment: String, + /// ANSI color for keywords. + pub keyword: String, + /// ANSI color for function names. + pub function: String, + /// ANSI color for variables and identifiers. + pub variable: String, + /// ANSI color for string literals. + pub string: String, + /// ANSI color for numeric literals. + pub number: String, + /// ANSI color for type identifiers. + #[napi(js_name = "type")] + pub r#type: String, + /// ANSI color for operators. + pub operator: String, + /// ANSI color for punctuation tokens. + pub punctuation: String, + /// ANSI color for diff inserted lines. + #[napi(js_name = "inserted")] + pub inserted: Option, + /// ANSI color for diff deleted lines. + #[napi(js_name = "deleted")] + pub deleted: Option, } /// Language alias mappings: (aliases, target syntax name). /// Used for languages not in syntect's default set or with non-standard names. const LANG_ALIASES: &[(&[&str], &str)] = &[ - (&["ts", "tsx", "typescript", "js", "jsx", "javascript", "mjs", "cjs"], "JavaScript"), - (&["py", "python"], "Python"), - (&["rb", "ruby"], "Ruby"), - (&["rs", "rust"], "Rust"), - (&["go", "golang"], "Go"), - (&["java"], "Java"), - (&["kt", "kotlin"], "Java"), - (&["swift"], "Objective-C"), - (&["c", "h"], "C"), - (&["cpp", "cc", "cxx", "c++", "hpp", "hxx", "hh"], "C++"), - (&["cs", "csharp"], "C#"), - (&["php"], "PHP"), - (&["sh", "bash", "zsh", "shell"], "Bash"), - (&["fish"], "Shell-Unix-Generic"), - (&["ps1", "powershell"], "PowerShell"), - (&["html", "htm"], "HTML"), - (&["css"], "CSS"), - (&["scss"], "SCSS"), - (&["sass"], "Sass"), - (&["less"], "LESS"), - (&["json"], "JSON"), - (&["yaml", "yml"], "YAML"), - (&["toml"], "TOML"), - (&["xml"], "XML"), - (&["md", "markdown"], "Markdown"), - (&["sql"], "SQL"), - (&["lua"], "Lua"), - (&["perl", "pl"], "Perl"), - (&["r"], "R"), - (&["scala"], "Scala"), - (&["clj", "clojure"], "Clojure"), - (&["ex", "exs", "elixir"], "Ruby"), - (&["erl", "erlang"], "Erlang"), - (&["hs", "haskell"], "Haskell"), - (&["ml", "ocaml"], "OCaml"), - (&["vim"], "VimL"), - (&["graphql", "gql"], "GraphQL"), - (&["proto", "protobuf"], "Protocol Buffers"), - (&["tf", "hcl", "terraform"], "Terraform"), - (&["dockerfile", "docker"], "Dockerfile"), - (&["makefile", "make"], "Makefile"), - (&["cmake"], "CMake"), - (&["ini", "cfg", "conf", "config", "properties"], "INI"), - (&["diff", "patch"], "Diff"), - (&["gitignore", "gitattributes", "gitmodules"], "Git Ignore"), + ( + &[ + "ts", + "tsx", + "typescript", + "js", + "jsx", + "javascript", + "mjs", + "cjs", + ], + "JavaScript", + ), + (&["py", "python"], "Python"), + (&["rb", "ruby"], "Ruby"), + (&["rs", "rust"], "Rust"), + (&["go", "golang"], "Go"), + (&["java"], "Java"), + (&["kt", "kotlin"], "Java"), + (&["swift"], "Objective-C"), + (&["c", "h"], "C"), + (&["cpp", "cc", "cxx", "c++", "hpp", "hxx", "hh"], "C++"), + (&["cs", "csharp"], "C#"), + (&["php"], "PHP"), + (&["sh", "bash", "zsh", "shell"], "Bash"), + (&["fish"], "Shell-Unix-Generic"), + (&["ps1", "powershell"], "PowerShell"), + (&["html", "htm"], "HTML"), + (&["css"], "CSS"), + (&["scss"], "SCSS"), + (&["sass"], "Sass"), + (&["less"], "LESS"), + (&["json"], "JSON"), + (&["yaml", "yml"], "YAML"), + (&["toml"], "TOML"), + (&["xml"], "XML"), + (&["md", "markdown"], "Markdown"), + (&["sql"], "SQL"), + (&["lua"], "Lua"), + (&["perl", "pl"], "Perl"), + (&["r"], "R"), + (&["scala"], "Scala"), + (&["clj", "clojure"], "Clojure"), + (&["ex", "exs", "elixir"], "Ruby"), + (&["erl", "erlang"], "Erlang"), + (&["hs", "haskell"], "Haskell"), + (&["ml", "ocaml"], "OCaml"), + (&["vim"], "VimL"), + (&["graphql", "gql"], "GraphQL"), + (&["proto", "protobuf"], "Protocol Buffers"), + (&["tf", "hcl", "terraform"], "Terraform"), + (&["dockerfile", "docker"], "Dockerfile"), + (&["makefile", "make"], "Makefile"), + (&["cmake"], "CMake"), + (&["ini", "cfg", "conf", "config", "properties"], "INI"), + (&["diff", "patch"], "Diff"), + (&["gitignore", "gitattributes", "gitmodules"], "Git Ignore"), ]; /// Find syntax name from alias table using case-insensitive comparison. #[inline] fn find_alias(lang: &str) -> Option<&'static str> { - LANG_ALIASES - .iter() - .find(|(aliases, _)| aliases.iter().any(|a| lang.eq_ignore_ascii_case(a))) - .map(|(_, target)| *target) + LANG_ALIASES + .iter() + .find(|(aliases, _)| aliases.iter().any(|a| lang.eq_ignore_ascii_case(a))) + .map(|(_, target)| *target) } /// Check if language is in the alias table. #[inline] fn is_known_alias(lang: &str) -> bool { - LANG_ALIASES - .iter() - .any(|(aliases, _)| aliases.iter().any(|a| lang.eq_ignore_ascii_case(a))) + LANG_ALIASES + .iter() + .any(|(aliases, _)| aliases.iter().any(|a| lang.eq_ignore_ascii_case(a))) } /// Compute the color index for a single scope (uncached). #[inline] fn compute_scope_color(s: Scope) -> usize { - let m = get_scope_matchers(); + let m = get_scope_matchers(); - // Comment (index 0) - if m.comment.is_prefix_of(s) { - return 0; - } + // Comment (index 0) + if m.comment.is_prefix_of(s) { + return 0; + } - // Diff inserted (index 9) - if m.markup_inserted.is_prefix_of(s) { - return 9; - } + // Diff inserted (index 9) + if m.markup_inserted.is_prefix_of(s) { + return 9; + } - // Diff deleted (index 10) - if m.markup_deleted.is_prefix_of(s) { - return 10; - } + // Diff deleted (index 10) + if m.markup_deleted.is_prefix_of(s) { + return 10; + } - // Diff header/range -> keyword (index 1) - if m.meta_diff_header.is_prefix_of(s) || m.meta_diff_range.is_prefix_of(s) { - return 1; - } + // Diff header/range -> keyword (index 1) + if m.meta_diff_header.is_prefix_of(s) || m.meta_diff_range.is_prefix_of(s) { + return 1; + } - // String (index 4) - if m.string.is_prefix_of(s) - || m.constant_character.is_prefix_of(s) - || m.meta_string.is_prefix_of(s) - { - return 4; - } + // String (index 4) + if m.string.is_prefix_of(s) + || m.constant_character.is_prefix_of(s) + || m.meta_string.is_prefix_of(s) + { + return 4; + } - // Number (index 5) - if m.constant_numeric.is_prefix_of(s) || m.constant_integer.is_prefix_of(s) { - return 5; - } + // Number (index 5) + if m.constant_numeric.is_prefix_of(s) || m.constant_integer.is_prefix_of(s) { + return 5; + } - // Keyword (index 1) - if m.keyword.is_prefix_of(s) - || m.storage_type.is_prefix_of(s) - || m.storage_modifier.is_prefix_of(s) - { - return 1; - } + // Keyword (index 1) + if m.keyword.is_prefix_of(s) + || m.storage_type.is_prefix_of(s) + || m.storage_modifier.is_prefix_of(s) + { + return 1; + } - // Function (index 2) - if m.entity_name_function.is_prefix_of(s) - || m.support_function.is_prefix_of(s) - || m.meta_function_call.is_prefix_of(s) - || m.variable_function.is_prefix_of(s) - { - return 2; - } + // Function (index 2) + if m.entity_name_function.is_prefix_of(s) + || m.support_function.is_prefix_of(s) + || m.meta_function_call.is_prefix_of(s) + || m.variable_function.is_prefix_of(s) + { + return 2; + } - // Type (index 6) - if m.entity_name_type.is_prefix_of(s) - || m.support_type.is_prefix_of(s) - || m.support_class.is_prefix_of(s) - || m.entity_name_class.is_prefix_of(s) - || m.entity_name_struct.is_prefix_of(s) - || m.entity_name_enum.is_prefix_of(s) - || m.entity_name_interface.is_prefix_of(s) - || m.entity_name_trait.is_prefix_of(s) - { - return 6; - } + // Type (index 6) + if m.entity_name_type.is_prefix_of(s) + || m.support_type.is_prefix_of(s) + || m.support_class.is_prefix_of(s) + || m.entity_name_class.is_prefix_of(s) + || m.entity_name_struct.is_prefix_of(s) + || m.entity_name_enum.is_prefix_of(s) + || m.entity_name_interface.is_prefix_of(s) + || m.entity_name_trait.is_prefix_of(s) + { + return 6; + } - // Operator (index 7) - if m.keyword_operator.is_prefix_of(s) || m.punctuation_accessor.is_prefix_of(s) { - return 7; - } + // Operator (index 7) + if m.keyword_operator.is_prefix_of(s) || m.punctuation_accessor.is_prefix_of(s) { + return 7; + } - // Punctuation (index 8) - if m.punctuation.is_prefix_of(s) { - return 8; - } + // Punctuation (index 8) + if m.punctuation.is_prefix_of(s) { + return 8; + } - // Variable (index 3) - if m.variable.is_prefix_of(s) || m.entity_name.is_prefix_of(s) || m.meta_path.is_prefix_of(s) { - return 3; - } + // Variable (index 3) + if m.variable.is_prefix_of(s) || m.entity_name.is_prefix_of(s) || m.meta_path.is_prefix_of(s) { + return 3; + } - // Generic constant -> number (index 5) - if m.constant.is_prefix_of(s) { - return 5; - } + // Generic constant -> number (index 5) + if m.constant.is_prefix_of(s) { + return 5; + } - // No match - usize::MAX + // No match + usize::MAX } /// Determine the semantic color category from a scope stack. /// Uses per-scope caching to avoid repeated prefix checks. #[inline] fn scope_to_color_index(scope: &ScopeStack) -> usize { - SCOPE_COLOR_CACHE.with(|cache| { - let mut cache = cache.borrow_mut(); + SCOPE_COLOR_CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); - // Walk from innermost to outermost scope - for s in scope.as_slice().iter().rev() { - let color_idx = *cache.entry(*s).or_insert_with(|| compute_scope_color(*s)); - if color_idx != usize::MAX { - return color_idx; - } - } + // Walk from innermost to outermost scope + for s in scope.as_slice().iter().rev() { + let color_idx = *cache.entry(*s).or_insert_with(|| compute_scope_color(*s)); + if color_idx != usize::MAX { + return color_idx; + } + } - usize::MAX - }) + usize::MAX + }) } /// Find the appropriate syntax for a language name. fn find_syntax<'a>(ss: &'a SyntaxSet, lang: &str) -> Option<&'a SyntaxReference> { - // Direct name/token match (syntect APIs are case-insensitive) - if let Some(syn) = ss.find_syntax_by_token(lang) { - return Some(syn); - } + // Direct name/token match (syntect APIs are case-insensitive) + if let Some(syn) = ss.find_syntax_by_token(lang) { + return Some(syn); + } - // Extension-based match - if let Some(syn) = ss.find_syntax_by_extension(lang) { - return Some(syn); - } + // Extension-based match + if let Some(syn) = ss.find_syntax_by_extension(lang) { + return Some(syn); + } - // Alias lookup for languages not in syntect's default set - let alias = find_alias(lang)?; + // Alias lookup for languages not in syntect's default set + let alias = find_alias(lang)?; - ss.find_syntax_by_name(alias) - .or_else(|| ss.find_syntax_by_token(alias)) + ss.find_syntax_by_name(alias) + .or_else(|| ss.find_syntax_by_token(alias)) } /// Highlight code and return ANSI-colored lines. @@ -361,93 +373,93 @@ fn find_syntax<'a>(ss: &'a SyntaxSet, lang: &str) -> Option<&'a SyntaxReference> /// fails. #[napi(js_name = "highlightCode")] pub fn highlight_code(code: String, lang: Option, colors: HighlightColors) -> String { - let inserted = colors.inserted.as_deref().unwrap_or(""); - let deleted = colors.deleted.as_deref().unwrap_or(""); + let inserted = colors.inserted.as_deref().unwrap_or(""); + let deleted = colors.deleted.as_deref().unwrap_or(""); - // Color palette as array for quick indexing - let palette = [ - colors.comment.as_str(), // 0 - colors.keyword.as_str(), // 1 - colors.function.as_str(), // 2 - colors.variable.as_str(), // 3 - colors.string.as_str(), // 4 - colors.number.as_str(), // 5 - colors.r#type.as_str(), // 6 - colors.operator.as_str(), // 7 - colors.punctuation.as_str(), // 8 - inserted, // 9 - deleted, // 10 - ]; + // Color palette as array for quick indexing + let palette = [ + colors.comment.as_str(), // 0 + colors.keyword.as_str(), // 1 + colors.function.as_str(), // 2 + colors.variable.as_str(), // 3 + colors.string.as_str(), // 4 + colors.number.as_str(), // 5 + colors.r#type.as_str(), // 6 + colors.operator.as_str(), // 7 + colors.punctuation.as_str(), // 8 + inserted, // 9 + deleted, // 10 + ]; - let ss = get_syntax_set(); + let ss = get_syntax_set(); - // Find syntax for the language - let syntax = match &lang { - Some(l) => find_syntax(ss, l), - None => None, - } - .unwrap_or_else(|| ss.find_syntax_plain_text()); + // Find syntax for the language + let syntax = match &lang { + Some(l) => find_syntax(ss, l), + None => None, + } + .unwrap_or_else(|| ss.find_syntax_plain_text()); - let mut parse_state = ParseState::new(syntax); - let mut scope_stack = ScopeStack::new(); - let mut result = String::with_capacity(code.len() * 2); + let mut parse_state = ParseState::new(syntax); + let mut scope_stack = ScopeStack::new(); + let mut result = String::with_capacity(code.len() * 2); - for line in syntect::util::LinesWithEndings::from(code.as_str()) { - let Ok(ops) = parse_state.parse_line(line, ss) else { - // Parse error - append unhighlighted line and continue - result.push_str(line); - continue; - }; + for line in syntect::util::LinesWithEndings::from(code.as_str()) { + let Ok(ops) = parse_state.parse_line(line, ss) else { + // Parse error - append unhighlighted line and continue + result.push_str(line); + continue; + }; - let mut prev_end = 0; - for (offset, op) in ops { - let offset = offset.min(line.len()); + let mut prev_end = 0; + for (offset, op) in ops { + let offset = offset.min(line.len()); - // Output text BEFORE this operation using current scope - if offset > prev_end { - let text = &line[prev_end..offset]; - let color_idx = scope_to_color_index(&scope_stack); + // Output text BEFORE this operation using current scope + if offset > prev_end { + let text = &line[prev_end..offset]; + let color_idx = scope_to_color_index(&scope_stack); - if color_idx < palette.len() && !palette[color_idx].is_empty() { - result.push_str(palette[color_idx]); - result.push_str(text); - result.push_str("\x1b[39m"); - } else { - result.push_str(text); - } - } - prev_end = offset; + if color_idx < palette.len() && !palette[color_idx].is_empty() { + result.push_str(palette[color_idx]); + result.push_str(text); + result.push_str("\x1b[39m"); + } else { + result.push_str(text); + } + } + prev_end = offset; - // Now apply scope operation for NEXT segment - match op { - ScopeStackOp::Push(scope) => { - scope_stack.push(scope); - }, - ScopeStackOp::Pop(count) => { - for _ in 0..count { - scope_stack.pop(); - } - }, - ScopeStackOp::Restore | ScopeStackOp::Clear(_) | ScopeStackOp::Noop => {}, - } - } + // Now apply scope operation for NEXT segment + match op { + ScopeStackOp::Push(scope) => { + scope_stack.push(scope); + } + ScopeStackOp::Pop(count) => { + for _ in 0..count { + scope_stack.pop(); + } + } + ScopeStackOp::Restore | ScopeStackOp::Clear(_) | ScopeStackOp::Noop => {} + } + } - // Output remaining text with current scope - if prev_end < line.len() { - let text = &line[prev_end..]; - let color_idx = scope_to_color_index(&scope_stack); + // Output remaining text with current scope + if prev_end < line.len() { + let text = &line[prev_end..]; + let color_idx = scope_to_color_index(&scope_stack); - if color_idx < palette.len() && !palette[color_idx].is_empty() { - result.push_str(palette[color_idx]); - result.push_str(text); - result.push_str("\x1b[39m"); - } else { - result.push_str(text); - } - } - } + if color_idx < palette.len() && !palette[color_idx].is_empty() { + result.push_str(palette[color_idx]); + result.push_str(text); + result.push_str("\x1b[39m"); + } else { + result.push_str(text); + } + } + } - result + result } /// Check if a language is supported for highlighting. @@ -455,18 +467,18 @@ pub fn highlight_code(code: String, lang: Option, colors: HighlightColor /// mapping. #[napi(js_name = "supportsLanguage")] pub fn supports_language(lang: String) -> bool { - if is_known_alias(&lang) { - return true; - } + if is_known_alias(&lang) { + return true; + } - // Fall back to direct syntax lookup - let ss = get_syntax_set(); - find_syntax(ss, &lang).is_some() + // Fall back to direct syntax lookup + let ss = get_syntax_set(); + find_syntax(ss, &lang).is_some() } /// Get list of supported languages. #[napi(js_name = "getSupportedLanguages")] pub fn get_supported_languages() -> Vec { - let ss = get_syntax_set(); - ss.syntaxes().iter().map(|s| s.name.clone()).collect() + let ss = get_syntax_set(); + ss.syntaxes().iter().map(|s| s.name.clone()).collect() } diff --git a/rust-engine/crates/engine/src/image.rs b/rust-engine/crates/engine/src/image.rs index 7481e9f7e..dcb15fe56 100644 --- a/rust-engine/crates/engine/src/image.rs +++ b/rust-engine/crates/engine/src/image.rs @@ -9,9 +9,9 @@ use std::{io::Cursor, sync::Arc}; use image::{ - DynamicImage, ImageFormat, ImageReader, codecs::{jpeg::JpegEncoder, webp::WebPEncoder}, imageops::FilterType, + DynamicImage, ImageFormat, ImageReader, }; use napi::bindgen_prelude::*; use napi_derive::napi; @@ -22,15 +22,15 @@ use crate::task; #[napi] pub enum SamplingFilter { /// Nearest-neighbor sampling (fast, low quality). - Nearest = 1, + Nearest = 1, /// Triangle filter (linear interpolation). - Triangle = 2, + Triangle = 2, /// Catmull-Rom filter with sharper edges. CatmullRom = 3, /// Gaussian filter for smoother results. - Gaussian = 4, + Gaussian = 4, /// Lanczos3 filter for high-quality downscaling. - Lanczos3 = 5, + Lanczos3 = 5, } impl From for FilterType { @@ -81,7 +81,9 @@ impl NativeImage { #[napi(js_name = "encode")] pub fn encode(&self, format: u8, quality: u8) -> task::Async> { let img = Arc::clone(&self.img); - task::blocking("image.encode", (), move |_| encode_image(&img, format, quality)) + task::blocking("image.encode", (), move |_| { + encode_image(&img, format, quality) + }) } /// Resize to exact dimensions. Returns a new NativeImage. @@ -89,7 +91,9 @@ impl NativeImage { pub fn resize(&self, width: u32, height: u32, filter: SamplingFilter) -> ImageTask { let img = Arc::clone(&self.img); task::blocking("image.resize", (), move |_| { - Ok(Self { img: Arc::new(img.resize_exact(width, height, filter.into())) }) + Ok(Self { + img: Arc::new(img.resize_exact(width, height, filter.into())), + }) }) } } @@ -122,27 +126,29 @@ fn encode_image(img: &DynamicImage, format: u8, quality: u8) -> Result> img.write_to(&mut Cursor::new(&mut buffer), ImageFormat::Png) .map_err(|e| Error::from_reason(format!("Failed to encode PNG: {e}")))?; Ok(buffer) - }, + } 1 => { let mut buffer = Vec::with_capacity(encode_capacity(w, h, 3)?); let encoder = JpegEncoder::new_with_quality(&mut buffer, quality); img.write_with_encoder(encoder) .map_err(|e| Error::from_reason(format!("Failed to encode JPEG: {e}")))?; Ok(buffer) - }, + } 2 => { let mut buffer = Vec::with_capacity(encode_capacity(w, h, 4)?); let encoder = WebPEncoder::new_lossless(&mut buffer); img.write_with_encoder(encoder) .map_err(|e| Error::from_reason(format!("Failed to encode WebP: {e}")))?; Ok(buffer) - }, + } 3 => { let mut buffer = Vec::with_capacity(encode_capacity(w, h, 1)?); img.write_to(&mut Cursor::new(&mut buffer), ImageFormat::Gif) .map_err(|e| Error::from_reason(format!("Failed to encode GIF: {e}")))?; Ok(buffer) - }, - _ => Err(Error::from_reason(format!("Invalid image format: {format}"))), + } + _ => Err(Error::from_reason(format!( + "Invalid image format: {format}" + ))), } } diff --git a/rust-engine/crates/engine/src/json_parse.rs b/rust-engine/crates/engine/src/json_parse.rs index 7aa0d0fdf..de545da1f 100644 --- a/rust-engine/crates/engine/src/json_parse.rs +++ b/rust-engine/crates/engine/src/json_parse.rs @@ -169,12 +169,24 @@ fn handle_truncated_value(result: &mut String) { // at the end after a value-position character if len > 0 { let last = bytes[len - 1]; - if last.is_ascii_digit() || last == b'.' || last == b'-' || last == b'e' || last == b'E' || last == b'+' { + if last.is_ascii_digit() + || last == b'.' + || last == b'-' + || last == b'e' + || last == b'E' + || last == b'+' + { // Walk backwards to find the start of the number-like token let mut start = len; while start > 0 { let b = bytes[start - 1]; - if b.is_ascii_digit() || b == b'.' || b == b'-' || b == b'e' || b == b'E' || b == b'+' { + if b.is_ascii_digit() + || b == b'.' + || b == b'-' + || b == b'e' + || b == b'E' + || b == b'+' + { start -= 1; } else { break; @@ -228,12 +240,8 @@ fn handle_truncated_value(result: &mut String) { /// Convert a serde_json::Value to a napi JsUnknown. fn serde_value_to_napi(env: &Env, value: &serde_json::Value) -> Result { match value { - serde_json::Value::Null => { - env.get_null().map(|v| v.into_unknown()) - } - serde_json::Value::Bool(b) => { - env.get_boolean(*b).map(|v| v.into_unknown()) - } + serde_json::Value::Null => env.get_null().map(|v| v.into_unknown()), + serde_json::Value::Bool(b) => env.get_boolean(*b).map(|v| v.into_unknown()), serde_json::Value::Number(n) => { if let Some(i) = n.as_i64() { // Use i32 if it fits, otherwise f64 @@ -248,9 +256,7 @@ fn serde_value_to_napi(env: &Env, value: &serde_json::Value) -> Result { - env.create_string(s).map(|v| v.into_unknown()) - } + serde_json::Value::String(s) => env.create_string(s).map(|v| v.into_unknown()), serde_json::Value::Array(arr) => { let mut js_arr = env.create_array_with_length(arr.len())?; for (idx, item) in arr.iter().enumerate() { diff --git a/rust-engine/crates/engine/src/ps.rs b/rust-engine/crates/engine/src/ps.rs index f1a5cf759..d2f4bc143 100644 --- a/rust-engine/crates/engine/src/ps.rs +++ b/rust-engine/crates/engine/src/ps.rs @@ -13,224 +13,234 @@ use napi_derive::napi; #[cfg(target_os = "linux")] mod platform { - use std::fs; + use std::fs; - /// Collect all descendant PIDs of `pid` into `pids`. - /// Skips branches when `/proc/{pid}/children` cannot be read. - pub fn collect_descendants(pid: i32, pids: &mut Vec) { - let children_path = format!("/proc/{pid}/task/{pid}/children"); - let Ok(content) = fs::read_to_string(&children_path) else { - return; - }; + /// Collect all descendant PIDs of `pid` into `pids`. + /// Skips branches when `/proc/{pid}/children` cannot be read. + pub fn collect_descendants(pid: i32, pids: &mut Vec) { + let children_path = format!("/proc/{pid}/task/{pid}/children"); + let Ok(content) = fs::read_to_string(&children_path) else { + return; + }; - for part in content.split_whitespace() { - if let Ok(child_pid) = part.parse::() { - pids.push(child_pid); - collect_descendants(child_pid, pids); - } - } - } + for part in content.split_whitespace() { + if let Ok(child_pid) = part.parse::() { + pids.push(child_pid); + collect_descendants(child_pid, pids); + } + } + } - /// Send `signal` to `pid`. - /// Returns true when the signal is delivered successfully. - pub fn kill_pid(pid: i32, signal: i32) -> bool { - // SAFETY: libc::kill is safe to call with any pid/signal combination - unsafe { libc::kill(pid, signal) == 0 } - } + /// Send `signal` to `pid`. + /// Returns true when the signal is delivered successfully. + pub fn kill_pid(pid: i32, signal: i32) -> bool { + // SAFETY: libc::kill is safe to call with any pid/signal combination + unsafe { libc::kill(pid, signal) == 0 } + } - /// Get the process group id for `pid`. - /// Returns `None` when the process does not exist or is inaccessible. - pub fn process_group_id(pid: i32) -> Option { - // SAFETY: `libc::getpgid` is safe to call with any pid - let pgid = unsafe { libc::getpgid(pid) }; - if pgid < 0 { None } else { Some(pgid) } - } + /// Get the process group id for `pid`. + /// Returns `None` when the process does not exist or is inaccessible. + pub fn process_group_id(pid: i32) -> Option { + // SAFETY: `libc::getpgid` is safe to call with any pid + let pgid = unsafe { libc::getpgid(pid) }; + if pgid < 0 { + None + } else { + Some(pgid) + } + } - /// Send `signal` to the process group `pgid`. - /// Returns true when the signal is delivered successfully. - pub fn kill_process_group(pgid: i32, signal: i32) -> bool { - // SAFETY: libc::kill is safe to call with any pid/signal combination - unsafe { libc::kill(-pgid, signal) == 0 } - } + /// Send `signal` to the process group `pgid`. + /// Returns true when the signal is delivered successfully. + pub fn kill_process_group(pgid: i32, signal: i32) -> bool { + // SAFETY: libc::kill is safe to call with any pid/signal combination + unsafe { libc::kill(-pgid, signal) == 0 } + } } #[cfg(target_os = "macos")] mod platform { - use std::ptr; + use std::ptr; - #[link(name = "proc", kind = "dylib")] - unsafe extern "C" { - fn proc_listchildpids(ppid: i32, buffer: *mut i32, buffersize: i32) -> i32; - } + #[link(name = "proc", kind = "dylib")] + unsafe extern "C" { + fn proc_listchildpids(ppid: i32, buffer: *mut i32, buffersize: i32) -> i32; + } - /// Collect all descendant PIDs of `pid` into `pids` using libproc. - /// Skips branches when libproc returns no children. - pub fn collect_descendants(pid: i32, pids: &mut Vec) { - // First call to get count - // SAFETY: passing null buffer with size 0 to query child count is valid per - // libproc API. - let count = unsafe { proc_listchildpids(pid, ptr::null_mut(), 0) }; - if count <= 0 { - return; - } + /// Collect all descendant PIDs of `pid` into `pids` using libproc. + /// Skips branches when libproc returns no children. + pub fn collect_descendants(pid: i32, pids: &mut Vec) { + // First call to get count + // SAFETY: passing null buffer with size 0 to query child count is valid per + // libproc API. + let count = unsafe { proc_listchildpids(pid, ptr::null_mut(), 0) }; + if count <= 0 { + return; + } - let mut buffer = vec![0i32; count as usize]; - // SAFETY: buffer is correctly sized and aligned for `count` i32 elements. - let actual = unsafe { - proc_listchildpids(pid, buffer.as_mut_ptr(), (buffer.len() * size_of::()) as i32) - }; + let mut buffer = vec![0i32; count as usize]; + // SAFETY: buffer is correctly sized and aligned for `count` i32 elements. + let actual = unsafe { + proc_listchildpids( + pid, + buffer.as_mut_ptr(), + (buffer.len() * size_of::()) as i32, + ) + }; - if actual <= 0 { - return; - } + if actual <= 0 { + return; + } - let child_count = actual as usize / size_of::(); - for &child_pid in &buffer[..child_count] { - if child_pid > 0 { - pids.push(child_pid); - collect_descendants(child_pid, pids); - } - } - } + let child_count = actual as usize / size_of::(); + for &child_pid in &buffer[..child_count] { + if child_pid > 0 { + pids.push(child_pid); + collect_descendants(child_pid, pids); + } + } + } - /// Send `signal` to `pid`. - /// Returns true when the signal is delivered successfully. - pub fn kill_pid(pid: i32, signal: i32) -> bool { - // SAFETY: libc::kill is safe to call with any pid/signal combination - unsafe { libc::kill(pid, signal) == 0 } - } + /// Send `signal` to `pid`. + /// Returns true when the signal is delivered successfully. + pub fn kill_pid(pid: i32, signal: i32) -> bool { + // SAFETY: libc::kill is safe to call with any pid/signal combination + unsafe { libc::kill(pid, signal) == 0 } + } - /// Get the process group id for `pid`. - /// Returns `None` when the process does not exist or is inaccessible. - pub fn process_group_id(pid: i32) -> Option { - // SAFETY: libc::getpgid is safe to call with any pid - let pgid = unsafe { libc::getpgid(pid) }; - if pgid < 0 { None } else { Some(pgid) } - } + /// Get the process group id for `pid`. + /// Returns `None` when the process does not exist or is inaccessible. + pub fn process_group_id(pid: i32) -> Option { + // SAFETY: libc::getpgid is safe to call with any pid + let pgid = unsafe { libc::getpgid(pid) }; + if pgid < 0 { + None + } else { + Some(pgid) + } + } - /// Send `signal` to the process group `pgid`. - /// Returns true when the signal is delivered successfully. - pub fn kill_process_group(pgid: i32, signal: i32) -> bool { - // SAFETY: libc::kill is safe to call with any pid/signal combination - unsafe { libc::kill(-pgid, signal) == 0 } - } + /// Send `signal` to the process group `pgid`. + /// Returns true when the signal is delivered successfully. + pub fn kill_process_group(pgid: i32, signal: i32) -> bool { + // SAFETY: libc::kill is safe to call with any pid/signal combination + unsafe { libc::kill(-pgid, signal) == 0 } + } } #[cfg(target_os = "windows")] mod platform { - use std::{collections::HashMap, mem}; + use std::{collections::HashMap, mem}; - #[repr(C)] - #[allow(non_snake_case, reason = "Windows PROCESSENTRY32W field names must match Win32 ABI")] - struct PROCESSENTRY32W { - dwSize: u32, - cntUsage: u32, - th32ProcessID: u32, - th32DefaultHeapID: usize, - th32ModuleID: u32, - cntThreads: u32, - th32ParentProcessID: u32, - pcPriClassBase: i32, - dwFlags: u32, - szExeFile: [u16; 260], - } + #[repr(C)] + #[allow( + non_snake_case, + reason = "Windows PROCESSENTRY32W field names must match Win32 ABI" + )] + struct PROCESSENTRY32W { + dwSize: u32, + cntUsage: u32, + th32ProcessID: u32, + th32DefaultHeapID: usize, + th32ModuleID: u32, + cntThreads: u32, + th32ParentProcessID: u32, + pcPriClassBase: i32, + dwFlags: u32, + szExeFile: [u16; 260], + } - type Handle = *mut std::ffi::c_void; - const INVALID_HANDLE_VALUE: Handle = -1isize as Handle; - const TH32CS_SNAPPROCESS: u32 = 0x00000002; - const PROCESS_TERMINATE: u32 = 0x0001; + type Handle = *mut std::ffi::c_void; + const INVALID_HANDLE_VALUE: Handle = -1isize as Handle; + const TH32CS_SNAPPROCESS: u32 = 0x00000002; + const PROCESS_TERMINATE: u32 = 0x0001; - #[link(name = "kernel32")] - unsafe extern "system" { - fn CreateToolhelp32Snapshot(dwFlags: u32, th32ProcessID: u32) -> Handle; - fn Process32FirstW(hSnapshot: Handle, lppe: *mut PROCESSENTRY32W) -> i32; - fn Process32NextW(hSnapshot: Handle, lppe: *mut PROCESSENTRY32W) -> i32; - fn CloseHandle(hObject: Handle) -> i32; - fn OpenProcess(dwDesiredAccess: u32, bInheritHandle: i32, dwProcessId: u32) -> Handle; - fn TerminateProcess(hProcess: Handle, uExitCode: u32) -> i32; - } + #[link(name = "kernel32")] + unsafe extern "system" { + fn CreateToolhelp32Snapshot(dwFlags: u32, th32ProcessID: u32) -> Handle; + fn Process32FirstW(hSnapshot: Handle, lppe: *mut PROCESSENTRY32W) -> i32; + fn Process32NextW(hSnapshot: Handle, lppe: *mut PROCESSENTRY32W) -> i32; + fn CloseHandle(hObject: Handle) -> i32; + fn OpenProcess(dwDesiredAccess: u32, bInheritHandle: i32, dwProcessId: u32) -> Handle; + fn TerminateProcess(hProcess: Handle, uExitCode: u32) -> i32; + } - /// Build a map of `parent_pid` -> [`child_pids`] for all processes. - fn build_process_tree() -> HashMap> { - let mut tree: HashMap> = HashMap::new(); + /// Build a map of `parent_pid` -> [`child_pids`] for all processes. + fn build_process_tree() -> HashMap> { + let mut tree: HashMap> = HashMap::new(); - // SAFETY: Toolhelp snapshot APIs are called with initialized structs and valid - // handles. - unsafe { - let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); - if snapshot == INVALID_HANDLE_VALUE { - return tree; - } + // SAFETY: Toolhelp snapshot APIs are called with initialized structs and valid + // handles. + unsafe { + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if snapshot == INVALID_HANDLE_VALUE { + return tree; + } - let mut entry: PROCESSENTRY32W = mem::zeroed(); - entry.dwSize = mem::size_of::() as u32; + let mut entry: PROCESSENTRY32W = mem::zeroed(); + entry.dwSize = mem::size_of::() as u32; - if Process32FirstW(snapshot, &raw mut entry) != 0 { - loop { - tree - .entry(entry.th32ParentProcessID) - .or_default() - .push(entry.th32ProcessID); + if Process32FirstW(snapshot, &raw mut entry) != 0 { + loop { + tree.entry(entry.th32ParentProcessID) + .or_default() + .push(entry.th32ProcessID); - if Process32NextW(snapshot, &raw mut entry) == 0 { - break; - } - } - } + if Process32NextW(snapshot, &raw mut entry) == 0 { + break; + } + } + } - CloseHandle(snapshot); - } + CloseHandle(snapshot); + } - tree - } + tree + } - /// Collect all descendant PIDs of `pid` into `pids`. - /// Uses a snapshot of the current process table. - pub fn collect_descendants(pid: i32, pids: &mut Vec) { - let tree = build_process_tree(); - collect_descendants_from_tree(pid as u32, &tree, pids); - } + /// Collect all descendant PIDs of `pid` into `pids`. + /// Uses a snapshot of the current process table. + pub fn collect_descendants(pid: i32, pids: &mut Vec) { + let tree = build_process_tree(); + collect_descendants_from_tree(pid as u32, &tree, pids); + } - fn collect_descendants_from_tree( - pid: u32, - tree: &HashMap>, - pids: &mut Vec, - ) { - if let Some(children) = tree.get(&pid) { - for &child_pid in children { - pids.push(child_pid as i32); - collect_descendants_from_tree(child_pid, tree, pids); - } - } - } + fn collect_descendants_from_tree(pid: u32, tree: &HashMap>, pids: &mut Vec) { + if let Some(children) = tree.get(&pid) { + for &child_pid in children { + pids.push(child_pid as i32); + collect_descendants_from_tree(child_pid, tree, pids); + } + } + } - /// Terminate `pid` (Windows ignores `signal`). - /// Returns true when the process is terminated. - pub fn kill_pid(pid: i32, _signal: i32) -> bool { - // SAFETY: OpenProcess/TerminateProcess are called with kernel-provided process - // IDs and handles are always closed. - unsafe { - let handle = OpenProcess(PROCESS_TERMINATE, 0, pid as u32); - if handle.is_null() || handle == INVALID_HANDLE_VALUE { - return false; - } - let result = TerminateProcess(handle, 1); - CloseHandle(handle); - result != 0 - } - } + /// Terminate `pid` (Windows ignores `signal`). + /// Returns true when the process is terminated. + pub fn kill_pid(pid: i32, _signal: i32) -> bool { + // SAFETY: OpenProcess/TerminateProcess are called with kernel-provided process + // IDs and handles are always closed. + unsafe { + let handle = OpenProcess(PROCESS_TERMINATE, 0, pid as u32); + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + return false; + } + let result = TerminateProcess(handle, 1); + CloseHandle(handle); + result != 0 + } + } - /// Process groups are not exposed on Windows. - /// Always returns `None`. - pub const fn process_group_id(_pid: i32) -> Option { - None - } + /// Process groups are not exposed on Windows. + /// Always returns `None`. + pub const fn process_group_id(_pid: i32) -> Option { + None + } - /// Process groups are not exposed on Windows. - /// Always returns `false`. - pub const fn kill_process_group(_pgid: i32, _signal: i32) -> bool { - false - } + /// Process groups are not exposed on Windows. + /// Always returns `false`. + pub const fn kill_process_group(_pgid: i32, _signal: i32) -> bool { + false + } } /// Kill a process tree (the process and all its descendants). @@ -240,24 +250,24 @@ mod platform { /// Returns the number of processes successfully killed. #[napi] pub fn kill_tree(pid: i32, signal: i32) -> u32 { - let mut descendants = Vec::new(); - platform::collect_descendants(pid, &mut descendants); + let mut descendants = Vec::new(); + platform::collect_descendants(pid, &mut descendants); - let mut killed = 0u32; + let mut killed = 0u32; - // Kill children first (deepest first by reversing the DFS order) - for &child_pid in descendants.iter().rev() { - if platform::kill_pid(child_pid, signal) { - killed += 1; - } - } + // Kill children first (deepest first by reversing the DFS order) + for &child_pid in descendants.iter().rev() { + if platform::kill_pid(child_pid, signal) { + killed += 1; + } + } - // Kill the root process last - if platform::kill_pid(pid, signal) { - killed += 1; - } + // Kill the root process last + if platform::kill_pid(pid, signal) { + killed += 1; + } - killed + killed } /// List all descendant PIDs of `pid`. @@ -265,16 +275,16 @@ pub fn kill_tree(pid: i32, signal: i32) -> u32 { /// Returns an empty array if the process has no children or doesn't exist. #[napi] pub fn list_descendants(pid: i32) -> Vec { - let mut descendants = Vec::new(); - platform::collect_descendants(pid, &mut descendants); - descendants + let mut descendants = Vec::new(); + platform::collect_descendants(pid, &mut descendants); + descendants } /// Get the process group id for `pid`. /// Returns `null` when the process is missing or unsupported on the platform. #[napi] pub fn process_group_id(pid: i32) -> Option { - platform::process_group_id(pid) + platform::process_group_id(pid) } /// Kill an entire process group. @@ -284,5 +294,5 @@ pub fn process_group_id(pid: i32) -> Option { /// Returns false on Windows (process groups not supported). #[napi] pub fn kill_process_group(pgid: i32, signal: i32) -> bool { - platform::kill_process_group(pgid, signal) + platform::kill_process_group(pgid, signal) } diff --git a/rust-engine/crates/engine/src/stream_process.rs b/rust-engine/crates/engine/src/stream_process.rs index 36b81cb9a..28e4817dd 100644 --- a/rust-engine/crates/engine/src/stream_process.rs +++ b/rust-engine/crates/engine/src/stream_process.rs @@ -47,19 +47,15 @@ pub struct StreamChunkResult { /// strips ANSI escape sequences, removes control characters (except tab and /// newline), removes carriage returns, and filters Unicode format characters. #[napi(js_name = "processStreamChunk")] -pub fn process_stream_chunk( - chunk: Buffer, - state: Option, -) -> StreamChunkResult { +pub fn process_stream_chunk(chunk: Buffer, state: Option) -> StreamChunkResult { let state = state.unwrap_or_default(); let bytes = chunk.as_ref(); // Prepend any pending bytes from previous chunk let mut input: Vec; let src: &[u8] = if !state.utf8_pending.is_empty() || !state.ansi_pending.is_empty() { - input = Vec::with_capacity( - state.ansi_pending.len() + state.utf8_pending.len() + bytes.len(), - ); + input = + Vec::with_capacity(state.ansi_pending.len() + state.utf8_pending.len() + bytes.len()); input.extend_from_slice(&state.ansi_pending); input.extend_from_slice(&state.utf8_pending); input.extend_from_slice(bytes); @@ -134,7 +130,7 @@ fn find_incomplete_utf8_tail(bytes: &[u8]) -> usize { // that starts an incomplete sequence. let len = bytes.len(); // Check at most the last 3 bytes (max UTF-8 continuation trail) - let check_start = if len > 3 { len - 3 } else { 0 }; + let check_start = len.saturating_sub(3); for i in (check_start..len).rev() { let b = bytes[i]; @@ -326,8 +322,8 @@ fn could_be_incomplete_ansi(bytes: &[u8], pos: usize) -> bool { // CSI: ESC [ ... b'[' => { // If we don't see a final byte, it's incomplete - for j in (pos + 2)..bytes.len() { - if (0x40..=0x7E).contains(&bytes[j]) { + for byte in bytes.iter().skip(pos + 2) { + if (0x40..=0x7E).contains(byte) { return false; // found terminator — it's complete (but malformed since ansi_sequence_len returned None) } } @@ -335,11 +331,11 @@ fn could_be_incomplete_ansi(bytes: &[u8], pos: usize) -> bool { } // OSC: ESC ] ... (terminated by BEL or ST) b']' => { - for j in (pos + 2)..bytes.len() { - if bytes[j] == 0x07 { + for (j, byte) in bytes.iter().enumerate().skip(pos + 2) { + if *byte == 0x07 { return false; } - if bytes[j] == 0x1B && j + 1 < bytes.len() && bytes[j + 1] == b'\\' { + if *byte == 0x1B && j + 1 < bytes.len() && bytes[j + 1] == b'\\' { return false; } } @@ -347,8 +343,8 @@ fn could_be_incomplete_ansi(bytes: &[u8], pos: usize) -> bool { } // DCS, SOS, PM, APC b'P' | b'X' | b'^' | b'_' => { - for j in (pos + 2)..bytes.len() { - if bytes[j] == 0x1B && j + 1 < bytes.len() && bytes[j + 1] == b'\\' { + for (j, byte) in bytes.iter().enumerate().skip(pos + 2) { + if *byte == 0x1B && j + 1 < bytes.len() && bytes[j + 1] == b'\\' { return false; } } @@ -358,8 +354,8 @@ fn could_be_incomplete_ansi(bytes: &[u8], pos: usize) -> bool { 0x40..=0x7E => false, // Intermediate bytes (ESC + intermediate + final) 0x20..=0x2F => { - for j in (pos + 2)..bytes.len() { - if (0x30..=0x7E).contains(&bytes[j]) { + for byte in bytes.iter().skip(pos + 2) { + if (0x30..=0x7E).contains(byte) { return false; } } @@ -383,8 +379,8 @@ fn ansi_sequence_len(bytes: &[u8], pos: usize) -> Option { match bytes[pos + 1] { // CSI: ESC [ b'[' => { - for j in (pos + 2)..len { - if (0x40..=0x7E).contains(&bytes[j]) { + for (j, byte) in bytes.iter().enumerate().take(len).skip(pos + 2) { + if (0x40..=0x7E).contains(byte) { return Some(j - pos + 1); } } @@ -392,11 +388,11 @@ fn ansi_sequence_len(bytes: &[u8], pos: usize) -> Option { } // OSC: ESC ] b']' => { - for j in (pos + 2)..len { - if bytes[j] == 0x07 { + for (j, byte) in bytes.iter().enumerate().take(len).skip(pos + 2) { + if *byte == 0x07 { return Some(j - pos + 1); } - if bytes[j] == 0x1B && j + 1 < len && bytes[j + 1] == b'\\' { + if *byte == 0x1B && j + 1 < len && bytes[j + 1] == b'\\' { return Some(j - pos + 2); } } @@ -404,8 +400,8 @@ fn ansi_sequence_len(bytes: &[u8], pos: usize) -> Option { } // DCS, SOS, PM, APC — terminated by ST (ESC \) b'P' | b'X' | b'^' | b'_' => { - for j in (pos + 2)..len { - if bytes[j] == 0x1B && j + 1 < len && bytes[j + 1] == b'\\' { + for (j, byte) in bytes.iter().enumerate().take(len).skip(pos + 2) { + if *byte == 0x1B && j + 1 < len && bytes[j + 1] == b'\\' { return Some(j - pos + 2); } } @@ -413,8 +409,8 @@ fn ansi_sequence_len(bytes: &[u8], pos: usize) -> Option { } // ESC + intermediates (0x20-0x2F) + final byte (0x30-0x7E) 0x20..=0x2F => { - for j in (pos + 2)..len { - if (0x30..=0x7E).contains(&bytes[j]) { + for (j, byte) in bytes.iter().enumerate().take(len).skip(pos + 2) { + if (0x30..=0x7E).contains(byte) { return Some(j - pos + 1); } } @@ -661,9 +657,8 @@ mod tests { fn process_chunk(bytes: &[u8], state: Option) -> StreamChunkResult { let state = state.unwrap_or_default(); - let mut input: Vec = Vec::with_capacity( - state.ansi_pending.len() + state.utf8_pending.len() + bytes.len(), - ); + let mut input: Vec = + Vec::with_capacity(state.ansi_pending.len() + state.utf8_pending.len() + bytes.len()); input.extend_from_slice(&state.ansi_pending); input.extend_from_slice(&state.utf8_pending); input.extend_from_slice(bytes); diff --git a/rust-engine/crates/engine/src/symbol.rs b/rust-engine/crates/engine/src/symbol.rs index 99cfb8123..b952003aa 100644 --- a/rust-engine/crates/engine/src/symbol.rs +++ b/rust-engine/crates/engine/src/symbol.rs @@ -208,10 +208,9 @@ fn find_symbol_matches( ) -> Result> { let mut compiled: Vec = Vec::new(); for pat_str in patterns { - match Pattern::try_new(pat_str, lang) { - Ok(p) => compiled.push(p), - Err(_) => {} // skip patterns that don't compile for this lang variant - } + if let Ok(p) = Pattern::try_new(pat_str, lang) { + compiled.push(p); + } // skip patterns that don't compile for this lang variant } if compiled.is_empty() { return Err(Error::from_reason( diff --git a/rust-engine/crates/engine/src/task.rs b/rust-engine/crates/engine/src/task.rs index f609fec6e..a80f9b3b7 100644 --- a/rust-engine/crates/engine/src/task.rs +++ b/rust-engine/crates/engine/src/task.rs @@ -9,7 +9,7 @@ use std::time::{Duration, Instant}; -use napi::{Env, Error, Result, Task, bindgen_prelude::*}; +use napi::{bindgen_prelude::*, Env, Error, Result, Task}; // ───────────────────────────────────────────────────────────────────────────── // Cancellation diff --git a/rust-engine/crates/engine/src/text.rs b/rust-engine/crates/engine/src/text.rs index 526107af3..fa1fcb383 100644 --- a/rust-engine/crates/engine/src/text.rs +++ b/rust-engine/crates/engine/src/text.rs @@ -10,9 +10,9 @@ use std::cell::RefCell; -use napi::{JsString, bindgen_prelude::*}; +use napi::{bindgen_prelude::*, JsString}; use napi_derive::napi; -use smallvec::{SmallVec, smallvec}; +use smallvec::{smallvec, SmallVec}; use unicode_segmentation::UnicodeSegmentation; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; @@ -23,36 +23,36 @@ const ESC: u16 = 0x1b; #[inline] const fn clamp_tab_width(tab_width: Option) -> usize { - let width = match tab_width { - Some(tab_width) => tab_width as usize, - None => DEFAULT_TAB_WIDTH, - }; - if width < MIN_TAB_WIDTH { - MIN_TAB_WIDTH - } else if width > MAX_TAB_WIDTH { - MAX_TAB_WIDTH - } else { - width - } + let width = match tab_width { + Some(tab_width) => tab_width as usize, + None => DEFAULT_TAB_WIDTH, + }; + if width < MIN_TAB_WIDTH { + MIN_TAB_WIDTH + } else if width > MAX_TAB_WIDTH { + MAX_TAB_WIDTH + } else { + width + } } /// Clamp a u64 to u32::MAX, returning as u32. #[inline] const fn clamp_u32(v: u64) -> u32 { - if v > u32::MAX as u64 { - u32::MAX - } else { - v as u32 - } + if v > u32::MAX as u64 { + u32::MAX + } else { + v as u32 + } } fn utf16_to_string(data: impl AsRef<[u16]>) -> String { - let mut slice = data.as_ref(); - // Strip trailing null terminators (from JsStringUtf16::as_slice()) - while slice.last() == Some(&0) { - slice = &slice[..slice.len() - 1]; - } - String::from_utf16_lossy(slice) + let mut slice = data.as_ref(); + // Strip trailing null terminators (from JsStringUtf16::as_slice()) + while slice.last() == Some(&0) { + slice = &slice[..slice.len() - 1]; + } + String::from_utf16_lossy(slice) } // ============================================================================ @@ -61,24 +61,24 @@ fn utf16_to_string(data: impl AsRef<[u16]>) -> String { #[napi(object)] pub struct SliceResult { - /// UTF-16 slice containing the selected text. - pub text: String, - /// Visible width of the slice in terminal cells. - pub width: u32, + /// UTF-16 slice containing the selected text. + pub text: String, + /// Visible width of the slice in terminal cells. + pub width: u32, } #[napi(object)] pub struct ExtractSegmentsResult { - /// UTF-16 content before the overlay region. - pub before: String, - #[napi(js_name = "beforeWidth")] - /// Visible width of the `before` segment. - pub before_width: u32, - /// UTF-16 content after the overlay region. - pub after: String, - #[napi(js_name = "afterWidth")] - /// Visible width of the `after` segment. - pub after_width: u32, + /// UTF-16 content before the overlay region. + pub before: String, + #[napi(js_name = "beforeWidth")] + /// Visible width of the `before` segment. + pub before_width: u32, + /// UTF-16 content after the overlay region. + pub after: String, + #[napi(js_name = "afterWidth")] + /// Visible width of the `after` segment. + pub after_width: u32, } // ============================================================================ @@ -99,215 +99,219 @@ const COLOR_NONE: ColorVal = 0; #[derive(Clone, Copy, Default)] struct AnsiState { - attrs: u16, - fg: ColorVal, - bg: ColorVal, + attrs: u16, + fg: ColorVal, + bg: ColorVal, } impl AnsiState { - #[inline] - const fn new() -> Self { - Self { attrs: 0, fg: COLOR_NONE, bg: COLOR_NONE } - } + #[inline] + const fn new() -> Self { + Self { + attrs: 0, + fg: COLOR_NONE, + bg: COLOR_NONE, + } + } - #[inline] - const fn is_empty(&self) -> bool { - self.attrs == 0 && self.fg == COLOR_NONE && self.bg == COLOR_NONE - } + #[inline] + const fn is_empty(&self) -> bool { + self.attrs == 0 && self.fg == COLOR_NONE && self.bg == COLOR_NONE + } - #[inline] - const fn reset(&mut self) { - *self = Self::new(); - } + #[inline] + const fn reset(&mut self) { + *self = Self::new(); + } - fn apply_sgr_u16(&mut self, params: &[u16]) { - if params.is_empty() { - self.reset(); - return; - } + fn apply_sgr_u16(&mut self, params: &[u16]) { + if params.is_empty() { + self.reset(); + return; + } - let mut i = 0; - while i < params.len() { - let (code, next_i) = parse_sgr_num_u16(params, i); - i = next_i; + let mut i = 0; + while i < params.len() { + let (code, next_i) = parse_sgr_num_u16(params, i); + i = next_i; - match code { - 0 => self.reset(), - 1 => self.attrs |= ATTR_BOLD, - 2 => self.attrs |= ATTR_DIM, - 3 => self.attrs |= ATTR_ITALIC, - 4 => self.attrs |= ATTR_UNDERLINE, - 5 => self.attrs |= ATTR_BLINK, - 7 => self.attrs |= ATTR_INVERSE, - 8 => self.attrs |= ATTR_HIDDEN, - 9 => self.attrs |= ATTR_STRIKE, + match code { + 0 => self.reset(), + 1 => self.attrs |= ATTR_BOLD, + 2 => self.attrs |= ATTR_DIM, + 3 => self.attrs |= ATTR_ITALIC, + 4 => self.attrs |= ATTR_UNDERLINE, + 5 => self.attrs |= ATTR_BLINK, + 7 => self.attrs |= ATTR_INVERSE, + 8 => self.attrs |= ATTR_HIDDEN, + 9 => self.attrs |= ATTR_STRIKE, - 21 => self.attrs &= !ATTR_BOLD, - 22 => self.attrs &= !(ATTR_BOLD | ATTR_DIM), - 23 => self.attrs &= !ATTR_ITALIC, - 24 => self.attrs &= !ATTR_UNDERLINE, - 25 => self.attrs &= !ATTR_BLINK, - 27 => self.attrs &= !ATTR_INVERSE, - 28 => self.attrs &= !ATTR_HIDDEN, - 29 => self.attrs &= !ATTR_STRIKE, + 21 => self.attrs &= !ATTR_BOLD, + 22 => self.attrs &= !(ATTR_BOLD | ATTR_DIM), + 23 => self.attrs &= !ATTR_ITALIC, + 24 => self.attrs &= !ATTR_UNDERLINE, + 25 => self.attrs &= !ATTR_BLINK, + 27 => self.attrs &= !ATTR_INVERSE, + 28 => self.attrs &= !ATTR_HIDDEN, + 29 => self.attrs &= !ATTR_STRIKE, - 30..=37 => self.fg = (code - 29) as ColorVal, - 39 => self.fg = COLOR_NONE, - 40..=47 => self.bg = (code - 39) as ColorVal, - 49 => self.bg = COLOR_NONE, - 90..=97 => self.fg = (code - 81) as ColorVal, - 100..=107 => self.bg = (code - 91) as ColorVal, + 30..=37 => self.fg = (code - 29) as ColorVal, + 39 => self.fg = COLOR_NONE, + 40..=47 => self.bg = (code - 39) as ColorVal, + 49 => self.bg = COLOR_NONE, + 90..=97 => self.fg = (code - 81) as ColorVal, + 100..=107 => self.bg = (code - 91) as ColorVal, - 38 | 48 => { - let (mode, ni) = parse_sgr_num_u16(params, i); - i = ni; + 38 | 48 => { + let (mode, ni) = parse_sgr_num_u16(params, i); + i = ni; - let color = match mode { - 5 => { - let (idx, ni) = parse_sgr_num_u16(params, i); - i = ni; - 0x100 | (idx as ColorVal & 0xff) - }, - 2 => { - let (r, ni) = parse_sgr_num_u16(params, i); - let (g, ni) = parse_sgr_num_u16(params, ni); - let (b, ni) = parse_sgr_num_u16(params, ni); - i = ni; - 0x1000000 - | ((r as ColorVal & 0xff) << 16) - | ((g as ColorVal & 0xff) << 8) - | (b as ColorVal & 0xff) - }, - _ => continue, - }; + let color = match mode { + 5 => { + let (idx, ni) = parse_sgr_num_u16(params, i); + i = ni; + 0x100 | (idx as ColorVal & 0xff) + } + 2 => { + let (r, ni) = parse_sgr_num_u16(params, i); + let (g, ni) = parse_sgr_num_u16(params, ni); + let (b, ni) = parse_sgr_num_u16(params, ni); + i = ni; + 0x1000000 + | ((r as ColorVal & 0xff) << 16) + | ((g as ColorVal & 0xff) << 8) + | (b as ColorVal & 0xff) + } + _ => continue, + }; - if code == 38 { - self.fg = color; - } else { - self.bg = color; - } - }, + if code == 38 { + self.fg = color; + } else { + self.bg = color; + } + } - _ => {}, - } - } - } + _ => {} + } + } + } - fn write_restore_u16(&self, out: &mut Vec) { - if self.is_empty() { - return; - } + fn write_restore_u16(&self, out: &mut Vec) { + if self.is_empty() { + return; + } - out.extend_from_slice(&[ESC, b'[' as u16]); - let mut first = true; + out.extend_from_slice(&[ESC, b'[' as u16]); + let mut first = true; - macro_rules! push_code { - ($code:expr) => {{ - if !first { - out.push(b';' as u16); - } - first = false; - write_u32_u16(out, $code); - }}; - } + macro_rules! push_code { + ($code:expr) => {{ + if !first { + out.push(b';' as u16); + } + first = false; + write_u32_u16(out, $code); + }}; + } - if self.attrs & ATTR_BOLD != 0 { - push_code!(1); - } - if self.attrs & ATTR_DIM != 0 { - push_code!(2); - } - if self.attrs & ATTR_ITALIC != 0 { - push_code!(3); - } - if self.attrs & ATTR_UNDERLINE != 0 { - push_code!(4); - } - if self.attrs & ATTR_BLINK != 0 { - push_code!(5); - } - if self.attrs & ATTR_INVERSE != 0 { - push_code!(7); - } - if self.attrs & ATTR_HIDDEN != 0 { - push_code!(8); - } - if self.attrs & ATTR_STRIKE != 0 { - push_code!(9); - } + if self.attrs & ATTR_BOLD != 0 { + push_code!(1); + } + if self.attrs & ATTR_DIM != 0 { + push_code!(2); + } + if self.attrs & ATTR_ITALIC != 0 { + push_code!(3); + } + if self.attrs & ATTR_UNDERLINE != 0 { + push_code!(4); + } + if self.attrs & ATTR_BLINK != 0 { + push_code!(5); + } + if self.attrs & ATTR_INVERSE != 0 { + push_code!(7); + } + if self.attrs & ATTR_HIDDEN != 0 { + push_code!(8); + } + if self.attrs & ATTR_STRIKE != 0 { + push_code!(9); + } - write_color_u16(out, self.fg, 38, &mut first); - write_color_u16(out, self.bg, 48, &mut first); + write_color_u16(out, self.fg, 38, &mut first); + write_color_u16(out, self.bg, 48, &mut first); - out.push(b'm' as u16); - } + out.push(b'm' as u16); + } } #[inline] fn write_color_u16(out: &mut Vec, color: ColorVal, base: u32, first: &mut bool) { - if color == COLOR_NONE { - return; - } + if color == COLOR_NONE { + return; + } - if !*first { - out.push(b';' as u16); - } - *first = false; + if !*first { + out.push(b';' as u16); + } + *first = false; - if color < 0x100 { - let code = if color <= 8 { color + 29 } else { color + 81 }; - let code = if base == 48 { code + 10 } else { code }; - write_u32_u16(out, code); - } else if color < 0x1000000 { - write_u32_u16(out, base); - out.extend_from_slice(&[b';' as u16, b'5' as u16, b';' as u16]); - write_u32_u16(out, color & 0xff); - } else { - write_u32_u16(out, base); - out.extend_from_slice(&[b';' as u16, b'2' as u16, b';' as u16]); - write_u32_u16(out, (color >> 16) & 0xff); - out.push(b';' as u16); - write_u32_u16(out, (color >> 8) & 0xff); - out.push(b';' as u16); - write_u32_u16(out, color & 0xff); - } + if color < 0x100 { + let code = if color <= 8 { color + 29 } else { color + 81 }; + let code = if base == 48 { code + 10 } else { code }; + write_u32_u16(out, code); + } else if color < 0x1000000 { + write_u32_u16(out, base); + out.extend_from_slice(&[b';' as u16, b'5' as u16, b';' as u16]); + write_u32_u16(out, color & 0xff); + } else { + write_u32_u16(out, base); + out.extend_from_slice(&[b';' as u16, b'2' as u16, b';' as u16]); + write_u32_u16(out, (color >> 16) & 0xff); + out.push(b';' as u16); + write_u32_u16(out, (color >> 8) & 0xff); + out.push(b';' as u16); + write_u32_u16(out, color & 0xff); + } } #[inline] fn parse_sgr_num_u16(params: &[u16], mut i: usize) -> (u32, usize) { - while i < params.len() && params[i] == b';' as u16 { - i += 1; - } + while i < params.len() && params[i] == b';' as u16 { + i += 1; + } - let mut val: u32 = 0; - while i < params.len() { - let b = params[i]; - if b == b';' as u16 { - i += 1; - break; - } - if (b'0' as u16..=b'9' as u16).contains(&b) { - val = val - .saturating_mul(10) - .saturating_add((b - b'0' as u16) as u32); - } - i += 1; - } - (val, i) + let mut val: u32 = 0; + while i < params.len() { + let b = params[i]; + if b == b';' as u16 { + i += 1; + break; + } + if (b'0' as u16..=b'9' as u16).contains(&b) { + val = val + .saturating_mul(10) + .saturating_add((b - b'0' as u16) as u32); + } + i += 1; + } + (val, i) } #[inline] fn write_u32_u16(out: &mut Vec, mut val: u32) { - if val == 0 { - out.push(b'0' as u16); - return; - } - let start = out.len(); - while val > 0 { - out.push(b'0' as u16 + (val % 10) as u16); - val /= 10; - } - out[start..].reverse(); + if val == 0 { + out.push(b'0' as u16); + return; + } + let start = out.len(); + while val > 0 { + out.push(b'0' as u16 + (val % 10) as u16); + val /= 10; + } + out[start..].reverse(); } // ============================================================================ @@ -316,61 +320,61 @@ fn write_u32_u16(out: &mut Vec, mut val: u32) { #[inline] fn ansi_seq_len_u16(data: &[u16], pos: usize) -> Option { - if pos >= data.len() || data[pos] != ESC { - return None; - } - if pos + 1 >= data.len() { - return None; - } + if pos >= data.len() || data[pos] != ESC { + return None; + } + if pos + 1 >= data.len() { + return None; + } - match data[pos + 1] { - 0x5b => { - // '[' CSI - for (i, b) in data[pos + 2..].iter().enumerate() { - if (0x40..=0x7e).contains(b) { - return Some(i + 3); - } - } - None - }, - 0x5d => { - // ']' OSC - for (i, &b) in data[pos + 2..].iter().enumerate() { - if b == 0x07 { - return Some(i + 3); - } - if b == ESC && data.get(pos + 2 + i + 1) == Some(&0x5c) { - return Some(i + 4); - } - } - None - }, - 0x50 | 0x58 | 0x5e | 0x5f => { - // 'P' DCS, 'X' SOS, '^' PM, '_' APC (terminated by ST) - for (i, &b) in data[pos + 2..].iter().enumerate() { - if b == ESC && data.get(pos + 2 + i + 1) == Some(&0x5c) { - return Some(i + 4); - } - } - None - }, - 0x20..=0x2f => { - // ESC + intermediates + final byte - for (i, b) in data[pos + 2..].iter().enumerate() { - if (0x30..=0x7e).contains(b) { - return Some(i + 3); - } - } - None - }, - 0x40..=0x7e => Some(2), - _ => None, - } + match data[pos + 1] { + 0x5b => { + // '[' CSI + for (i, b) in data[pos + 2..].iter().enumerate() { + if (0x40..=0x7e).contains(b) { + return Some(i + 3); + } + } + None + } + 0x5d => { + // ']' OSC + for (i, &b) in data[pos + 2..].iter().enumerate() { + if b == 0x07 { + return Some(i + 3); + } + if b == ESC && data.get(pos + 2 + i + 1) == Some(&0x5c) { + return Some(i + 4); + } + } + None + } + 0x50 | 0x58 | 0x5e | 0x5f => { + // 'P' DCS, 'X' SOS, '^' PM, '_' APC (terminated by ST) + for (i, &b) in data[pos + 2..].iter().enumerate() { + if b == ESC && data.get(pos + 2 + i + 1) == Some(&0x5c) { + return Some(i + 4); + } + } + None + } + 0x20..=0x2f => { + // ESC + intermediates + final byte + for (i, b) in data[pos + 2..].iter().enumerate() { + if (0x30..=0x7e).contains(b) { + return Some(i + 3); + } + } + None + } + 0x40..=0x7e => Some(2), + _ => None, + } } #[inline] fn is_sgr_u16(seq: &[u16]) -> bool { - seq.len() >= 3 && seq[1] == b'[' as u16 && *seq.last().unwrap() == b'm' as u16 + seq.len() >= 3 && seq[1] == b'[' as u16 && *seq.last().unwrap() == b'm' as u16 } // ============================================================================ @@ -379,31 +383,31 @@ fn is_sgr_u16(seq: &[u16]) -> bool { #[inline] const fn ascii_cell_width_u16(u: u16, tab_width: usize) -> usize { - let b = u as u8; - match b { - b'\t' => tab_width, - 0x20..=0x7e => 1, - _ => 0, - } + let b = u as u8; + match b { + b'\t' => tab_width, + 0x20..=0x7e => 1, + _ => 0, + } } #[inline] fn grapheme_width_str(g: &str, tab_width: usize) -> usize { - if g == "\t" { - return tab_width; - } - let mut it = g.chars(); - let Some(c0) = it.next() else { - return 0; - }; - if it.next().is_none() { - return UnicodeWidthChar::width(c0).unwrap_or(0); - } - UnicodeWidthStr::width(g) + if g == "\t" { + return tab_width; + } + let mut it = g.chars(); + let Some(c0) = it.next() else { + return 0; + }; + if it.next().is_none() { + return UnicodeWidthChar::width(c0).unwrap_or(0); + } + UnicodeWidthStr::width(g) } thread_local! { - static SCRATCH: RefCell = const { RefCell::new(String::new()) }; + static SCRATCH: RefCell = const { RefCell::new(String::new()) }; } /// Iterate graphemes in a non-ASCII UTF-16 segment. @@ -412,86 +416,86 @@ thread_local! { #[inline] fn for_each_grapheme_u16_slow(segment: &[u16], tab_width: usize, mut f: F) -> bool where - F: FnMut(&[u16], usize) -> bool, + F: FnMut(&[u16], usize) -> bool, { - if segment.is_empty() { - return true; - } + if segment.is_empty() { + return true; + } - SCRATCH.with_borrow_mut(|scratch| { - scratch.clear(); - scratch.reserve(segment.len()); + SCRATCH.with_borrow_mut(|scratch| { + scratch.clear(); + scratch.reserve(segment.len()); - for r in std::char::decode_utf16(segment.iter().copied()) { - scratch.push(r.unwrap_or('\u{FFFD}')); - } + for r in std::char::decode_utf16(segment.iter().copied()) { + scratch.push(r.unwrap_or('\u{FFFD}')); + } - let mut utf16_pos = 0usize; - for g in scratch.graphemes(true) { - let w = grapheme_width_str(g, tab_width); + let mut utf16_pos = 0usize; + for g in scratch.graphemes(true) { + let w = grapheme_width_str(g, tab_width); - let g_u16_len: usize = g.chars().map(|c| c.len_utf16()).sum(); - let u16_slice = &segment[utf16_pos..utf16_pos + g_u16_len]; - utf16_pos += g_u16_len; + let g_u16_len: usize = g.chars().map(|c| c.len_utf16()).sum(); + let u16_slice = &segment[utf16_pos..utf16_pos + g_u16_len]; + utf16_pos += g_u16_len; - if !f(u16_slice, w) { - return false; - } - } + if !f(u16_slice, w) { + return false; + } + } - true - }) + true + }) } /// Visible width, with early-exit if width exceeds `limit`. fn visible_width_u16_up_to(data: &[u16], limit: usize, tab_width: usize) -> (usize, bool) { - let mut width = 0usize; - let mut i = 0usize; - let len = data.len(); + let mut width = 0usize; + let mut i = 0usize; + let len = data.len(); - while i < len { - if data[i] == ESC { - if let Some(seq_len) = ansi_seq_len_u16(data, i) { - i += seq_len; - continue; - } - i += 1; - continue; - } + while i < len { + if data[i] == ESC { + if let Some(seq_len) = ansi_seq_len_u16(data, i) { + i += seq_len; + continue; + } + i += 1; + continue; + } - let start = i; - let mut is_ascii = true; - while i < len && data[i] != ESC { - if data[i] > 0x7f { - is_ascii = false; - } - i += 1; - } - let seg = &data[start..i]; + let start = i; + let mut is_ascii = true; + while i < len && data[i] != ESC { + if data[i] > 0x7f { + is_ascii = false; + } + i += 1; + } + let seg = &data[start..i]; - if is_ascii { - for &u in seg { - width += ascii_cell_width_u16(u, tab_width); - if width > limit { - return (width, true); - } - } - } else { - let ok = for_each_grapheme_u16_slow(seg, tab_width, |_, w| { - width += w; - width <= limit - }); - if !ok { - return (width, true); - } - } - } + if is_ascii { + for &u in seg { + width += ascii_cell_width_u16(u, tab_width); + if width > limit { + return (width, true); + } + } + } else { + let ok = for_each_grapheme_u16_slow(seg, tab_width, |_, w| { + width += w; + width <= limit + }); + if !ok { + return (width, true); + } + } + } - (width, width > limit) + (width, width > limit) } fn visible_width_u16(data: &[u16], tab_width: usize) -> usize { - visible_width_u16_up_to(data, usize::MAX, tab_width).0 + visible_width_u16_up_to(data, usize::MAX, tab_width).0 } // ============================================================================ @@ -502,379 +506,389 @@ fn visible_width_u16(data: &[u16], tab_width: usize) -> usize { // close it before a line break and re-open it on the next line. #[derive(Clone, Default)] struct Osc8State { - /// The full OSC 8 open sequence (e.g. ESC ]8;params;uri BEL), stored as - /// UTF-16 code units. Empty means no active hyperlink. - open_seq: Vec, + /// The full OSC 8 open sequence (e.g. ESC ]8;params;uri BEL), stored as + /// UTF-16 code units. Empty means no active hyperlink. + open_seq: Vec, } impl Osc8State { - fn new() -> Self { - Self { open_seq: Vec::new() } - } + fn new() -> Self { + Self { + open_seq: Vec::new(), + } + } - fn is_active(&self) -> bool { - !self.open_seq.is_empty() - } + fn is_active(&self) -> bool { + !self.open_seq.is_empty() + } - /// Write the OSC 8 close sequence: ESC ]8;; BEL - fn write_close(out: &mut Vec) { - out.extend_from_slice(&[ESC, b']' as u16, b'8' as u16, b';' as u16, b';' as u16, 0x07]); - } + /// Write the OSC 8 close sequence: ESC ]8;; BEL + fn write_close(out: &mut Vec) { + out.extend_from_slice(&[ + ESC, + b']' as u16, + b'8' as u16, + b';' as u16, + b';' as u16, + 0x07, + ]); + } - /// Write the stored open sequence to re-open the hyperlink. - fn write_open(&self, out: &mut Vec) { - if self.is_active() { - out.extend_from_slice(&self.open_seq); - } - } + /// Write the stored open sequence to re-open the hyperlink. + fn write_open(&self, out: &mut Vec) { + if self.is_active() { + out.extend_from_slice(&self.open_seq); + } + } - /// Parse an OSC sequence and update state. Returns true if it was an OSC 8. - fn update_from_osc(&mut self, seq: &[u16]) -> bool { - // OSC 8 format: ESC ]8; params ; uri BEL (or ST) - // Minimum: ESC ]8;; BEL = 6 code units - if seq.len() < 6 { - return false; - } - if seq[0] != ESC || seq[1] != b']' as u16 || seq[2] != b'8' as u16 || seq[3] != b';' as u16 { - return false; - } - // Find the second semicolon that separates params from URI - let mut second_semi = None; - for i in 4..seq.len() { - if seq[i] == b';' as u16 { - second_semi = Some(i); - break; - } - } - let second_semi = match second_semi { - Some(i) => i, - None => return false, - }; - // URI is between second_semi+1 and the terminator (BEL or ST) - let uri_start = second_semi + 1; - // Terminator is at the end (BEL = 1 unit, ST = 2 units) - let terminator_len = if *seq.last().unwrap() == 0x07 { 1 } else { 2 }; - let uri_end = seq.len() - terminator_len; - if uri_start >= uri_end { - // Empty URI = close hyperlink - self.open_seq.clear(); - } else { - // Non-empty URI = open hyperlink - self.open_seq = seq.to_vec(); - } - true - } + /// Parse an OSC sequence and update state. Returns true if it was an OSC 8. + fn update_from_osc(&mut self, seq: &[u16]) -> bool { + // OSC 8 format: ESC ]8; params ; uri BEL (or ST) + // Minimum: ESC ]8;; BEL = 6 code units + if seq.len() < 6 { + return false; + } + if seq[0] != ESC || seq[1] != b']' as u16 || seq[2] != b'8' as u16 || seq[3] != b';' as u16 + { + return false; + } + // Find the second semicolon that separates params from URI + let mut second_semi = None; + for (i, &item) in seq.iter().enumerate().skip(4) { + if item == b';' as u16 { + second_semi = Some(i); + break; + } + } + let second_semi = match second_semi { + Some(i) => i, + None => return false, + }; + // URI is between second_semi+1 and the terminator (BEL or ST) + let uri_start = second_semi + 1; + // Terminator is at the end (BEL = 1 unit, ST = 2 units) + let terminator_len = if *seq.last().unwrap() == 0x07 { 1 } else { 2 }; + let uri_end = seq.len() - terminator_len; + if uri_start >= uri_end { + // Empty URI = close hyperlink + self.open_seq.clear(); + } else { + // Non-empty URI = open hyperlink + self.open_seq = seq.to_vec(); + } + true + } } fn is_osc_u16(seq: &[u16]) -> bool { - seq.len() >= 3 && seq[0] == ESC && seq[1] == b']' as u16 + seq.len() >= 3 && seq[0] == ESC && seq[1] == b']' as u16 } #[inline] fn write_active_codes(state: &AnsiState, osc8: &Osc8State, out: &mut Vec) { - if !state.is_empty() { - state.write_restore_u16(out); - } - osc8.write_open(out); + if !state.is_empty() { + state.write_restore_u16(out); + } + osc8.write_open(out); } #[inline] fn write_line_end_reset(state: &AnsiState, osc8: &Osc8State, out: &mut Vec) { - if osc8.is_active() { - Osc8State::write_close(out); - } - let has_underline = state.attrs & ATTR_UNDERLINE != 0; - let has_strike = state.attrs & ATTR_STRIKE != 0; - if !has_underline && !has_strike { - return; - } + if osc8.is_active() { + Osc8State::write_close(out); + } + let has_underline = state.attrs & ATTR_UNDERLINE != 0; + let has_strike = state.attrs & ATTR_STRIKE != 0; + if !has_underline && !has_strike { + return; + } - out.extend_from_slice(&[ESC, b'[' as u16]); - if has_underline { - out.extend_from_slice(&[b'2' as u16, b'4' as u16]); - if has_strike { - out.push(b';' as u16); - } - } - if has_strike { - out.extend_from_slice(&[b'2' as u16, b'9' as u16]); - } - out.push(b'm' as u16); + out.extend_from_slice(&[ESC, b'[' as u16]); + if has_underline { + out.extend_from_slice(&[b'2' as u16, b'4' as u16]); + if has_strike { + out.push(b';' as u16); + } + } + if has_strike { + out.extend_from_slice(&[b'2' as u16, b'9' as u16]); + } + out.push(b'm' as u16); } fn update_state_from_text(data: &[u16], state: &mut AnsiState, osc8: &mut Osc8State) { - let mut i = 0usize; - while i < data.len() { - if data[i] == ESC { - if let Some(seq_len) = ansi_seq_len_u16(data, i) { - let seq = &data[i..i + seq_len]; - if is_sgr_u16(seq) { - state.apply_sgr_u16(&seq[2..seq_len - 1]); - } else if is_osc_u16(seq) { - osc8.update_from_osc(seq); - } - i += seq_len; - continue; - } - } - i += 1; - } + let mut i = 0usize; + while i < data.len() { + if data[i] == ESC { + if let Some(seq_len) = ansi_seq_len_u16(data, i) { + let seq = &data[i..i + seq_len]; + if is_sgr_u16(seq) { + state.apply_sgr_u16(&seq[2..seq_len - 1]); + } else if is_osc_u16(seq) { + osc8.update_from_osc(seq); + } + i += seq_len; + continue; + } + } + i += 1; + } } fn token_is_whitespace(token: &[u16]) -> bool { - let mut i = 0usize; - while i < token.len() { - if token[i] == ESC { - if let Some(seq_len) = ansi_seq_len_u16(token, i) { - i += seq_len; - continue; - } - } - if token[i] != b' ' as u16 { - return false; - } - i += 1; - } - true + let mut i = 0usize; + while i < token.len() { + if token[i] == ESC { + if let Some(seq_len) = ansi_seq_len_u16(token, i) { + i += seq_len; + continue; + } + } + if token[i] != b' ' as u16 { + return false; + } + i += 1; + } + true } fn trim_end_spaces_in_place(line: &mut Vec) { - while let Some(&last) = line.last() { - if last == b' ' as u16 { - line.pop(); - } else { - break; - } - } + while let Some(&last) = line.last() { + if last == b' ' as u16 { + line.pop(); + } else { + break; + } + } } fn split_into_tokens_with_ansi(line: &[u16]) -> SmallVec<[Vec; 4]> { - let mut tokens = SmallVec::<[Vec; 4]>::new(); - let mut current = Vec::::new(); - let mut pending_ansi = SmallVec::<[u16; 32]>::new(); - let mut in_whitespace = false; - let mut i = 0usize; + let mut tokens = SmallVec::<[Vec; 4]>::new(); + let mut current = Vec::::new(); + let mut pending_ansi = SmallVec::<[u16; 32]>::new(); + let mut in_whitespace = false; + let mut i = 0usize; - while i < line.len() { - if line[i] == ESC { - if let Some(seq_len) = ansi_seq_len_u16(line, i) { - pending_ansi.extend_from_slice(&line[i..i + seq_len]); - i += seq_len; - continue; - } - } + while i < line.len() { + if line[i] == ESC { + if let Some(seq_len) = ansi_seq_len_u16(line, i) { + pending_ansi.extend_from_slice(&line[i..i + seq_len]); + i += seq_len; + continue; + } + } - let ch = line[i]; - let char_is_space = ch == b' ' as u16; - if char_is_space != in_whitespace && !current.is_empty() { - tokens.push(current); - current = Vec::new(); - } + let ch = line[i]; + let char_is_space = ch == b' ' as u16; + if char_is_space != in_whitespace && !current.is_empty() { + tokens.push(current); + current = Vec::new(); + } - if !pending_ansi.is_empty() { - current.extend_from_slice(&pending_ansi); - pending_ansi.clear(); - } + if !pending_ansi.is_empty() { + current.extend_from_slice(&pending_ansi); + pending_ansi.clear(); + } - in_whitespace = char_is_space; - current.push(ch); - i += 1; - } + in_whitespace = char_is_space; + current.push(ch); + i += 1; + } - if !pending_ansi.is_empty() { - current.extend_from_slice(&pending_ansi); - } + if !pending_ansi.is_empty() { + current.extend_from_slice(&pending_ansi); + } - if !current.is_empty() { - tokens.push(current); - } + if !current.is_empty() { + tokens.push(current); + } - tokens + tokens } fn break_long_word( - word: &[u16], - width: usize, - tab_width: usize, - state: &mut AnsiState, - osc8: &mut Osc8State, + word: &[u16], + width: usize, + tab_width: usize, + state: &mut AnsiState, + osc8: &mut Osc8State, ) -> SmallVec<[Vec; 4]> { - let mut lines = SmallVec::<[Vec; 4]>::new(); - let mut current_line = Vec::::new(); - write_active_codes(state, osc8, &mut current_line); - let mut current_width = 0usize; - let mut i = 0usize; + let mut lines = SmallVec::<[Vec; 4]>::new(); + let mut current_line = Vec::::new(); + write_active_codes(state, osc8, &mut current_line); + let mut current_width = 0usize; + let mut i = 0usize; - while i < word.len() { - if word[i] == ESC { - if let Some(seq_len) = ansi_seq_len_u16(word, i) { - let seq = &word[i..i + seq_len]; - current_line.extend_from_slice(seq); - if is_sgr_u16(seq) { - state.apply_sgr_u16(&seq[2..seq_len - 1]); - } else if is_osc_u16(seq) { - osc8.update_from_osc(seq); - } - i += seq_len; - continue; - } - } + while i < word.len() { + if word[i] == ESC { + if let Some(seq_len) = ansi_seq_len_u16(word, i) { + let seq = &word[i..i + seq_len]; + current_line.extend_from_slice(seq); + if is_sgr_u16(seq) { + state.apply_sgr_u16(&seq[2..seq_len - 1]); + } else if is_osc_u16(seq) { + osc8.update_from_osc(seq); + } + i += seq_len; + continue; + } + } - let start = i; - let mut is_ascii = true; - while i < word.len() && word[i] != ESC { - if word[i] > 0x7f { - is_ascii = false; - } - i += 1; - } - let seg = &word[start..i]; + let start = i; + let mut is_ascii = true; + while i < word.len() && word[i] != ESC { + if word[i] > 0x7f { + is_ascii = false; + } + i += 1; + } + let seg = &word[start..i]; - if is_ascii { - for &u in seg { - let gw = ascii_cell_width_u16(u, tab_width); - if current_width + gw > width { - write_line_end_reset(state, osc8, &mut current_line); - lines.push(current_line); - current_line = Vec::new(); - write_active_codes(state, osc8, &mut current_line); - current_width = 0; - } - current_line.push(u); - current_width += gw; - } - } else { - let _ = for_each_grapheme_u16_slow(seg, tab_width, |gu16, gw| { - if current_width + gw > width { - write_line_end_reset(state, osc8, &mut current_line); - lines.push(std::mem::take(&mut current_line)); - write_active_codes(state, osc8, &mut current_line); - current_width = 0; - } - current_line.extend_from_slice(gu16); - current_width += gw; - true - }); - } - } + if is_ascii { + for &u in seg { + let gw = ascii_cell_width_u16(u, tab_width); + if current_width + gw > width { + write_line_end_reset(state, osc8, &mut current_line); + lines.push(current_line); + current_line = Vec::new(); + write_active_codes(state, osc8, &mut current_line); + current_width = 0; + } + current_line.push(u); + current_width += gw; + } + } else { + let _ = for_each_grapheme_u16_slow(seg, tab_width, |gu16, gw| { + if current_width + gw > width { + write_line_end_reset(state, osc8, &mut current_line); + lines.push(std::mem::take(&mut current_line)); + write_active_codes(state, osc8, &mut current_line); + current_width = 0; + } + current_line.extend_from_slice(gu16); + current_width += gw; + true + }); + } + } - if !current_line.is_empty() { - lines.push(current_line); - } + if !current_line.is_empty() { + lines.push(current_line); + } - lines + lines } fn wrap_single_line(line: &[u16], width: usize, tab_width: usize) -> SmallVec<[Vec; 4]> { - if line.is_empty() { - return smallvec![Vec::new()]; - } + if line.is_empty() { + return smallvec![Vec::new()]; + } - if visible_width_u16(line, tab_width) <= width { - return smallvec![line.to_vec()]; - } + if visible_width_u16(line, tab_width) <= width { + return smallvec![line.to_vec()]; + } - let tokens = split_into_tokens_with_ansi(line); - let mut wrapped = SmallVec::<[Vec; 4]>::new(); - let mut current_line = Vec::::new(); - let mut current_width = 0usize; - let mut state = AnsiState::new(); - let mut osc8 = Osc8State::new(); + let tokens = split_into_tokens_with_ansi(line); + let mut wrapped = SmallVec::<[Vec; 4]>::new(); + let mut current_line = Vec::::new(); + let mut current_width = 0usize; + let mut state = AnsiState::new(); + let mut osc8 = Osc8State::new(); - for token in tokens { - let token_width = visible_width_u16(&token, tab_width); - let is_whitespace = token_is_whitespace(&token); + for token in tokens { + let token_width = visible_width_u16(&token, tab_width); + let is_whitespace = token_is_whitespace(&token); - if token_width > width && !is_whitespace { - if !current_line.is_empty() { - write_line_end_reset(&state, &osc8, &mut current_line); - wrapped.push(current_line); - current_line = Vec::new(); - current_width = 0; - } + if token_width > width && !is_whitespace { + if !current_line.is_empty() { + write_line_end_reset(&state, &osc8, &mut current_line); + wrapped.push(current_line); + current_line = Vec::new(); + current_width = 0; + } - let mut broken = break_long_word(&token, width, tab_width, &mut state, &mut osc8); - if let Some(last) = broken.pop() { - wrapped.extend(broken); - current_line = last; - current_width = visible_width_u16(¤t_line, tab_width); - } - continue; - } + let mut broken = break_long_word(&token, width, tab_width, &mut state, &mut osc8); + if let Some(last) = broken.pop() { + wrapped.extend(broken); + current_line = last; + current_width = visible_width_u16(¤t_line, tab_width); + } + continue; + } - let total_needed = current_width + token_width; - if total_needed > width && current_width > 0 { - let mut line_to_wrap = current_line; - trim_end_spaces_in_place(&mut line_to_wrap); - write_line_end_reset(&state, &osc8, &mut line_to_wrap); - wrapped.push(line_to_wrap); + let total_needed = current_width + token_width; + if total_needed > width && current_width > 0 { + let mut line_to_wrap = current_line; + trim_end_spaces_in_place(&mut line_to_wrap); + write_line_end_reset(&state, &osc8, &mut line_to_wrap); + wrapped.push(line_to_wrap); - current_line = Vec::new(); - write_active_codes(&state, &osc8, &mut current_line); - if is_whitespace { - current_width = 0; - } else { - current_line.extend_from_slice(&token); - current_width = token_width; - } - } else { - current_line.extend_from_slice(&token); - current_width += token_width; - } + current_line = Vec::new(); + write_active_codes(&state, &osc8, &mut current_line); + if is_whitespace { + current_width = 0; + } else { + current_line.extend_from_slice(&token); + current_width = token_width; + } + } else { + current_line.extend_from_slice(&token); + current_width += token_width; + } - update_state_from_text(&token, &mut state, &mut osc8); - } + update_state_from_text(&token, &mut state, &mut osc8); + } - if !current_line.is_empty() { - wrapped.push(current_line); - } + if !current_line.is_empty() { + wrapped.push(current_line); + } - for line in &mut wrapped { - trim_end_spaces_in_place(line); - } + for line in &mut wrapped { + trim_end_spaces_in_place(line); + } - if wrapped.is_empty() { - wrapped.push(Vec::new()); - } + if wrapped.is_empty() { + wrapped.push(Vec::new()); + } - wrapped + wrapped } fn wrap_text_with_ansi_impl( - text: &[u16], - width: usize, - tab_width: usize, + text: &[u16], + width: usize, + tab_width: usize, ) -> SmallVec<[Vec; 4]> { - if text.is_empty() { - return smallvec![Vec::new()]; - } + if text.is_empty() { + return smallvec![Vec::new()]; + } - let mut result = SmallVec::<[Vec; 4]>::new(); - let mut state = AnsiState::new(); - let mut osc8 = Osc8State::new(); - let mut line_start = 0usize; + let mut result = SmallVec::<[Vec; 4]>::new(); + let mut state = AnsiState::new(); + let mut osc8 = Osc8State::new(); + let mut line_start = 0usize; - for i in 0..=text.len() { - if i == text.len() || text[i] == b'\n' as u16 { - let line = &text[line_start..i]; - let mut line_with_prefix: Vec = Vec::new(); - if !result.is_empty() { - write_active_codes(&state, &osc8, &mut line_with_prefix); - } - line_with_prefix.extend_from_slice(line); + for i in 0..=text.len() { + if i == text.len() || text[i] == b'\n' as u16 { + let line = &text[line_start..i]; + let mut line_with_prefix: Vec = Vec::new(); + if !result.is_empty() { + write_active_codes(&state, &osc8, &mut line_with_prefix); + } + line_with_prefix.extend_from_slice(line); - let wrapped = wrap_single_line(&line_with_prefix, width, tab_width); - result.extend(wrapped); - update_state_from_text(line, &mut state, &mut osc8); - line_start = i + 1; - } - } + let wrapped = wrap_single_line(&line_with_prefix, width, tab_width); + result.extend(wrapped); + update_state_from_text(line, &mut state, &mut osc8); + line_start = i + 1; + } + } - if result.is_empty() { - result.push(Vec::new()); - } + if result.is_empty() { + result.push(Vec::new()); + } - result + result } /// Wrap text to a visible width, preserving ANSI escape codes across line @@ -883,14 +897,14 @@ fn wrap_text_with_ansi_impl( /// Returns UTF-16 lines with active SGR codes carried across line boundaries. #[napi(js_name = "wrapTextWithAnsi")] pub fn wrap_text_with_ansi( - text: JsString, - width: u32, - tab_width: Option, + text: JsString, + width: u32, + tab_width: Option, ) -> Result> { - let text_u16 = text.into_utf16()?; - let tab_width = clamp_tab_width(tab_width); - let lines = wrap_text_with_ansi_impl(text_u16.as_slice(), width as usize, tab_width); - Ok(lines.into_iter().map(utf16_to_string).collect()) + let text_u16 = text.into_utf16()?; + let tab_width = clamp_tab_width(tab_width); + let lines = wrap_text_with_ansi_impl(text_u16.as_slice(), width as usize, tab_width); + Ok(lines.into_iter().map(utf16_to_string).collect()) } // ============================================================================ @@ -903,135 +917,135 @@ pub fn wrap_text_with_ansi( /// spaces when requested. #[napi(js_name = "truncateToWidth")] pub fn truncate_to_width( - text: JsString, - max_width: u32, - ellipsis_kind: u8, - pad: bool, - tab_width: Option, + text: JsString, + max_width: u32, + ellipsis_kind: u8, + pad: bool, + tab_width: Option, ) -> Result { - let max_width = max_width as usize; - let tab_width = clamp_tab_width(tab_width); + let max_width = max_width as usize; + let tab_width = clamp_tab_width(tab_width); - let text_u16 = text.into_utf16()?; - let text = text_u16.as_slice(); + let text_u16 = text.into_utf16()?; + let text = text_u16.as_slice(); - // Fast path: early-exit width check - let (text_w, exceeded) = visible_width_u16_up_to(text, max_width, tab_width); - if !exceeded { - if !pad || text_w == max_width { - return Ok(utf16_to_string(text.to_vec())); - } + // Fast path: early-exit width check + let (text_w, exceeded) = visible_width_u16_up_to(text, max_width, tab_width); + if !exceeded { + if !pad || text_w == max_width { + return Ok(utf16_to_string(text)); + } - let mut out = Vec::with_capacity(text.len() + (max_width - text_w)); - out.extend_from_slice(text); - out.resize(out.len() + (max_width - text_w), b' ' as u16); - return Ok(utf16_to_string(out)); - } + let mut out = Vec::with_capacity(text.len() + (max_width - text_w)); + out.extend_from_slice(text); + out.resize(out.len() + (max_width - text_w), b' ' as u16); + return Ok(utf16_to_string(out)); + } - const ELLIPSIS_UNICODE: &[u16] = &[0x2026]; - const ELLIPSIS_ASCII: &[u16] = &[0x2e, 0x2e, 0x2e]; - const ELLIPSIS_OMIT: &[u16] = &[]; + const ELLIPSIS_UNICODE: &[u16] = &[0x2026]; + const ELLIPSIS_ASCII: &[u16] = &[0x2e, 0x2e, 0x2e]; + const ELLIPSIS_OMIT: &[u16] = &[]; - let (ellipsis, ellipsis_w): (&[u16], usize) = match ellipsis_kind { - 0 => (ELLIPSIS_UNICODE, 1), - 1 => (ELLIPSIS_ASCII, 3), - 2 => (ELLIPSIS_OMIT, 0), - _ => (ELLIPSIS_UNICODE, 1), - }; + let (ellipsis, ellipsis_w): (&[u16], usize) = match ellipsis_kind { + 0 => (ELLIPSIS_UNICODE, 1), + 1 => (ELLIPSIS_ASCII, 3), + 2 => (ELLIPSIS_OMIT, 0), + _ => (ELLIPSIS_UNICODE, 1), + }; - let target_w = max_width.saturating_sub(ellipsis_w); + let target_w = max_width.saturating_sub(ellipsis_w); - if target_w == 0 { - let mut out = Vec::with_capacity(ellipsis.len().min(max_width * 2)); - let mut w = 0usize; - let _ = for_each_grapheme_u16_slow(ellipsis, tab_width, |gu16, gw| { - if w + gw > max_width { - return false; - } - out.extend_from_slice(gu16); - w += gw; - true - }); + if target_w == 0 { + let mut out = Vec::with_capacity(ellipsis.len().min(max_width * 2)); + let mut w = 0usize; + let _ = for_each_grapheme_u16_slow(ellipsis, tab_width, |gu16, gw| { + if w + gw > max_width { + return false; + } + out.extend_from_slice(gu16); + w += gw; + true + }); - if pad && w < max_width { - out.resize(out.len() + (max_width - w), b' ' as u16); - } - return Ok(utf16_to_string(out)); - } + if pad && w < max_width { + out.resize(out.len() + (max_width - w), b' ' as u16); + } + return Ok(utf16_to_string(out)); + } - let mut out = Vec::with_capacity(text.len().min(max_width * 2) + ellipsis.len() + 8); - let mut w = 0usize; - let mut i = 0usize; - let text_len = text.len(); + let mut out = Vec::with_capacity(text.len().min(max_width * 2) + ellipsis.len() + 8); + let mut w = 0usize; + let mut i = 0usize; + let text_len = text.len(); - let mut saw_sgr = false; + let mut saw_sgr = false; - while i < text_len { - if text[i] == ESC { - if let Some(seq_len) = ansi_seq_len_u16(text, i) { - let seq = &text[i..i + seq_len]; - out.extend_from_slice(seq); - if is_sgr_u16(seq) { - saw_sgr = true; - } - i += seq_len; - continue; - } - out.push(ESC); - i += 1; - continue; - } + while i < text_len { + if text[i] == ESC { + if let Some(seq_len) = ansi_seq_len_u16(text, i) { + let seq = &text[i..i + seq_len]; + out.extend_from_slice(seq); + if is_sgr_u16(seq) { + saw_sgr = true; + } + i += seq_len; + continue; + } + out.push(ESC); + i += 1; + continue; + } - let start = i; - let mut is_ascii = true; - while i < text_len && text[i] != ESC { - if text[i] > 0x7f { - is_ascii = false; - } - i += 1; - } - let seg = &text[start..i]; + let start = i; + let mut is_ascii = true; + while i < text_len && text[i] != ESC { + if text[i] > 0x7f { + is_ascii = false; + } + i += 1; + } + let seg = &text[start..i]; - if is_ascii { - for &u in seg { - let gw = ascii_cell_width_u16(u, tab_width); - if w + gw > target_w { - break; - } - out.push(u); - w += gw; - } - if w >= target_w { - break; - } - } else { - let keep_going = for_each_grapheme_u16_slow(seg, tab_width, |gu16, gw| { - if w + gw > target_w { - return false; - } - out.extend_from_slice(gu16); - w += gw; - true - }); - if !keep_going { - break; - } - } - } + if is_ascii { + for &u in seg { + let gw = ascii_cell_width_u16(u, tab_width); + if w + gw > target_w { + break; + } + out.push(u); + w += gw; + } + if w >= target_w { + break; + } + } else { + let keep_going = for_each_grapheme_u16_slow(seg, tab_width, |gu16, gw| { + if w + gw > target_w { + return false; + } + out.extend_from_slice(gu16); + w += gw; + true + }); + if !keep_going { + break; + } + } + } - if saw_sgr { - out.extend_from_slice(&[ESC, b'[' as u16, b'0' as u16, b'm' as u16]); - } - out.extend_from_slice(ellipsis); + if saw_sgr { + out.extend_from_slice(&[ESC, b'[' as u16, b'0' as u16, b'm' as u16]); + } + out.extend_from_slice(ellipsis); - if pad { - let out_w = w + ellipsis_w; - if out_w < max_width { - out.resize(out.len() + (max_width - out_w), b' ' as u16); - } - } + if pad { + let out_w = w + ellipsis_w; + if out_w < max_width { + out.resize(out.len() + (max_width - out_w), b' ' as u16); + } + } - Ok(utf16_to_string(out)) + Ok(utf16_to_string(out)) } // ============================================================================ @@ -1039,111 +1053,111 @@ pub fn truncate_to_width( // ============================================================================ fn slice_with_width_impl( - line: &[u16], - start_col: usize, - length: usize, - strict: bool, - tab_width: usize, + line: &[u16], + start_col: usize, + length: usize, + strict: bool, + tab_width: usize, ) -> (Vec, usize) { - let end_col = start_col.saturating_add(length); + let end_col = start_col.saturating_add(length); - let mut out = Vec::with_capacity(length * 2); - let mut out_w = 0usize; + let mut out = Vec::with_capacity(length * 2); + let mut out_w = 0usize; - let mut current_col = 0usize; - let mut i = 0usize; - let line_len = line.len(); + let mut current_col = 0usize; + let mut i = 0usize; + let line_len = line.len(); - let mut pending_ansi: SmallVec<[(usize, usize); 4]> = SmallVec::new(); + let mut pending_ansi: SmallVec<[(usize, usize); 4]> = SmallVec::new(); - while i < line_len && current_col < end_col { - if line[i] == ESC { - if let Some(seq_len) = ansi_seq_len_u16(line, i) { - if current_col >= start_col { - out.extend_from_slice(&line[i..i + seq_len]); - } else { - pending_ansi.push((i, seq_len)); - } - i += seq_len; - continue; - } - if current_col >= start_col { - out.push(ESC); - } - i += 1; - continue; - } + while i < line_len && current_col < end_col { + if line[i] == ESC { + if let Some(seq_len) = ansi_seq_len_u16(line, i) { + if current_col >= start_col { + out.extend_from_slice(&line[i..i + seq_len]); + } else { + pending_ansi.push((i, seq_len)); + } + i += seq_len; + continue; + } + if current_col >= start_col { + out.push(ESC); + } + i += 1; + continue; + } - let start = i; - let mut is_ascii = true; - while i < line_len && line[i] != ESC { - if line[i] > 0x7f { - is_ascii = false; - } - i += 1; - } - let seg = &line[start..i]; + let start = i; + let mut is_ascii = true; + while i < line_len && line[i] != ESC { + if line[i] > 0x7f { + is_ascii = false; + } + i += 1; + } + let seg = &line[start..i]; - if is_ascii { - for &u in seg { - if current_col >= end_col { - break; - } - let gw = ascii_cell_width_u16(u, tab_width); - let in_range = current_col >= start_col; - let fits = !strict || current_col + gw <= end_col; + if is_ascii { + for &u in seg { + if current_col >= end_col { + break; + } + let gw = ascii_cell_width_u16(u, tab_width); + let in_range = current_col >= start_col; + let fits = !strict || current_col + gw <= end_col; - if in_range && fits { - if !pending_ansi.is_empty() { - for &(p, l) in &pending_ansi { - out.extend_from_slice(&line[p..p + l]); - } - pending_ansi.clear(); - } - out.push(u); - out_w += gw; - } - current_col += gw; - } - } else { - let _ = for_each_grapheme_u16_slow(seg, tab_width, |gu16, gw| { - if current_col >= end_col { - return false; - } + if in_range && fits { + if !pending_ansi.is_empty() { + for &(p, l) in &pending_ansi { + out.extend_from_slice(&line[p..p + l]); + } + pending_ansi.clear(); + } + out.push(u); + out_w += gw; + } + current_col += gw; + } + } else { + let _ = for_each_grapheme_u16_slow(seg, tab_width, |gu16, gw| { + if current_col >= end_col { + return false; + } - let in_range = current_col >= start_col; - let fits = !strict || current_col + gw <= end_col; + let in_range = current_col >= start_col; + let fits = !strict || current_col + gw <= end_col; - if in_range && fits { - if !pending_ansi.is_empty() { - for &(p, l) in &pending_ansi { - out.extend_from_slice(&line[p..p + l]); - } - pending_ansi.clear(); - } - out.extend_from_slice(gu16); - out_w += gw; - } + if in_range && fits { + if !pending_ansi.is_empty() { + for &(p, l) in &pending_ansi { + out.extend_from_slice(&line[p..p + l]); + } + pending_ansi.clear(); + } + out.extend_from_slice(gu16); + out_w += gw; + } - current_col += gw; - current_col < end_col - }); - } - } + current_col += gw; + current_col < end_col + }); + } + } - // Include trailing ANSI sequences (e.g., reset codes) that immediately follow - while i < line.len() { - if line[i] == ESC { - if let Some(len) = ansi_seq_len_u16(line, i) { - out.extend_from_slice(&line[i..i + len]); - i += len; - continue; - } - } - break; - } + // Include trailing ANSI sequences (e.g., reset codes) that immediately follow + while i < line.len() { + if line[i] == ESC { + if let Some(len) = ansi_seq_len_u16(line, i) { + out.extend_from_slice(&line[i..i + len]); + i += len; + continue; + } + } + break; + } - (out, out_w) + (out, out_w) } /// Slice a range of visible columns from a line. @@ -1152,20 +1166,23 @@ fn slice_with_width_impl( /// width. #[napi(js_name = "sliceWithWidth")] pub fn slice_with_width( - line: JsString, - start_col: u32, - length: u32, - strict: bool, - tab_width: Option, + line: JsString, + start_col: u32, + length: u32, + strict: bool, + tab_width: Option, ) -> Result { - let line_u16 = line.into_utf16()?; - let line = line_u16.as_slice(); + let line_u16 = line.into_utf16()?; + let line = line_u16.as_slice(); - let tab_width = clamp_tab_width(tab_width); - let (out, w) = - slice_with_width_impl(line, start_col as usize, length as usize, strict, tab_width); + let tab_width = clamp_tab_width(tab_width); + let (out, w) = + slice_with_width_impl(line, start_col as usize, length as usize, strict, tab_width); - Ok(SliceResult { text: utf16_to_string(out), width: clamp_u32(w as u64) }) + Ok(SliceResult { + text: utf16_to_string(out), + width: clamp_u32(w as u64), + }) } // ============================================================================ @@ -1173,132 +1190,136 @@ pub fn slice_with_width( // ============================================================================ fn extract_segments_impl( - line: &[u16], - before_end: usize, - after_start: usize, - after_len: usize, - strict_after: bool, - tab_width: usize, + line: &[u16], + before_end: usize, + after_start: usize, + after_len: usize, + strict_after: bool, + tab_width: usize, ) -> (Vec, usize, Vec, usize) { - let after_end = after_start.saturating_add(after_len); + let after_end = after_start.saturating_add(after_len); - let mut before = Vec::with_capacity(before_end * 2); - let mut before_w = 0usize; + let mut before = Vec::with_capacity(before_end * 2); + let mut before_w = 0usize; - let mut after = Vec::with_capacity(after_len * 2); - let mut after_w = 0usize; + let mut after = Vec::with_capacity(after_len * 2); + let mut after_w = 0usize; - let mut current_col = 0usize; - let mut i = 0usize; - let line_len = line.len(); + let mut current_col = 0usize; + let mut i = 0usize; + let line_len = line.len(); - let mut pending_before_ansi: SmallVec<[(usize, usize); 4]> = SmallVec::new(); + let mut pending_before_ansi: SmallVec<[(usize, usize); 4]> = SmallVec::new(); - let mut after_started = false; - let mut state = AnsiState::new(); + let mut after_started = false; + let mut state = AnsiState::new(); - let done_col = if after_len == 0 { before_end } else { after_end }; + let done_col = if after_len == 0 { + before_end + } else { + after_end + }; - while i < line_len && current_col < done_col { - if line[i] == ESC { - if let Some(seq_len) = ansi_seq_len_u16(line, i) { - let seq = &line[i..i + seq_len]; - if is_sgr_u16(seq) { - state.apply_sgr_u16(&seq[2..seq_len - 1]); - } + while i < line_len && current_col < done_col { + if line[i] == ESC { + if let Some(seq_len) = ansi_seq_len_u16(line, i) { + let seq = &line[i..i + seq_len]; + if is_sgr_u16(seq) { + state.apply_sgr_u16(&seq[2..seq_len - 1]); + } - if current_col < before_end { - pending_before_ansi.push((i, seq_len)); - } else if current_col >= after_start && current_col < after_end && after_started { - after.extend_from_slice(seq); - } + if current_col < before_end { + pending_before_ansi.push((i, seq_len)); + } else if current_col >= after_start && current_col < after_end && after_started { + after.extend_from_slice(seq); + } - i += seq_len; - continue; - } + i += seq_len; + continue; + } - if current_col < before_end { - before.push(ESC); - } else if current_col >= after_start && current_col < after_end && after_started { - after.push(ESC); - } - i += 1; - continue; - } + if current_col < before_end { + before.push(ESC); + } else if current_col >= after_start && current_col < after_end && after_started { + after.push(ESC); + } + i += 1; + continue; + } - let start = i; - let mut is_ascii = true; - while i < line_len && line[i] != ESC { - if line[i] > 0x7f { - is_ascii = false; - } - i += 1; - } - let seg = &line[start..i]; + let start = i; + let mut is_ascii = true; + while i < line_len && line[i] != ESC { + if line[i] > 0x7f { + is_ascii = false; + } + i += 1; + } + let seg = &line[start..i]; - if is_ascii { - for &u in seg { - if current_col >= done_col { - break; - } - let gw = ascii_cell_width_u16(u, tab_width); + if is_ascii { + for &u in seg { + if current_col >= done_col { + break; + } + let gw = ascii_cell_width_u16(u, tab_width); - if current_col < before_end { - if !pending_before_ansi.is_empty() { - for &(p, l) in &pending_before_ansi { - before.extend_from_slice(&line[p..p + l]); - } - pending_before_ansi.clear(); - } - before.push(u); - before_w += gw; - } else if current_col >= after_start && current_col < after_end { - let fits = !strict_after || current_col + gw <= after_end; - if fits { - if !after_started { - state.write_restore_u16(&mut after); - after_started = true; - } - after.push(u); - after_w += gw; - } - } - current_col += gw; - } - } else { - let _ = for_each_grapheme_u16_slow(seg, tab_width, |gu16, gw| { - if current_col >= done_col { - return false; - } + if current_col < before_end { + if !pending_before_ansi.is_empty() { + for &(p, l) in &pending_before_ansi { + before.extend_from_slice(&line[p..p + l]); + } + pending_before_ansi.clear(); + } + before.push(u); + before_w += gw; + } else if current_col >= after_start && current_col < after_end { + let fits = !strict_after || current_col + gw <= after_end; + if fits { + if !after_started { + state.write_restore_u16(&mut after); + after_started = true; + } + after.push(u); + after_w += gw; + } + } + current_col += gw; + } + } else { + let _ = for_each_grapheme_u16_slow(seg, tab_width, |gu16, gw| { + if current_col >= done_col { + return false; + } - if current_col < before_end { - if !pending_before_ansi.is_empty() { - for &(p, l) in &pending_before_ansi { - before.extend_from_slice(&line[p..p + l]); - } - pending_before_ansi.clear(); - } - before.extend_from_slice(gu16); - before_w += gw; - } else if current_col >= after_start && current_col < after_end { - let fits = !strict_after || current_col + gw <= after_end; - if fits { - if !after_started { - state.write_restore_u16(&mut after); - after_started = true; - } - after.extend_from_slice(gu16); - after_w += gw; - } - } + if current_col < before_end { + if !pending_before_ansi.is_empty() { + for &(p, l) in &pending_before_ansi { + before.extend_from_slice(&line[p..p + l]); + } + pending_before_ansi.clear(); + } + before.extend_from_slice(gu16); + before_w += gw; + } else if current_col >= after_start && current_col < after_end { + let fits = !strict_after || current_col + gw <= after_end; + if fits { + if !after_started { + state.write_restore_u16(&mut after); + after_started = true; + } + after.extend_from_slice(gu16); + after_w += gw; + } + } - current_col += gw; - true - }); - } - } + current_col += gw; + true + }); + } + } - (before, before_w, after, after_w) + (before, before_w, after, after_w) } /// Extract the before/after slices around an overlay region. @@ -1307,32 +1328,32 @@ fn extract_segments_impl( /// truncation. #[napi(js_name = "extractSegments")] pub fn extract_segments( - line: JsString, - before_end: u32, - after_start: u32, - after_len: u32, - strict_after: bool, - tab_width: Option, + line: JsString, + before_end: u32, + after_start: u32, + after_len: u32, + strict_after: bool, + tab_width: Option, ) -> Result { - let line_u16 = line.into_utf16()?; - let line = line_u16.as_slice(); + let line_u16 = line.into_utf16()?; + let line = line_u16.as_slice(); - let tab_width = clamp_tab_width(tab_width); - let (before, bw, after, aw) = extract_segments_impl( - line, - before_end as usize, - after_start as usize, - after_len as usize, - strict_after, - tab_width, - ); + let tab_width = clamp_tab_width(tab_width); + let (before, bw, after, aw) = extract_segments_impl( + line, + before_end as usize, + after_start as usize, + after_len as usize, + strict_after, + tab_width, + ); - Ok(ExtractSegmentsResult { - before: utf16_to_string(before), - before_width: clamp_u32(bw as u64), - after: utf16_to_string(after), - after_width: clamp_u32(aw as u64), - }) + Ok(ExtractSegmentsResult { + before: utf16_to_string(before), + before_width: clamp_u32(bw as u64), + after: utf16_to_string(after), + after_width: clamp_u32(aw as u64), + }) } // ============================================================================ @@ -1343,71 +1364,69 @@ pub fn extract_segments( /// and normalize line endings. #[napi(js_name = "sanitizeText")] pub fn sanitize_text(text: JsString) -> Result { - let text_u16 = text.into_utf16()?; - let data = text_u16.as_slice(); + let text_u16 = text.into_utf16()?; + let data = text_u16.as_slice(); - let mut did_change = false; - let mut out: Vec = Vec::new(); - let mut last = 0usize; - let mut i = 0usize; - let len = data.len(); + let mut did_change = false; + let mut out: Vec = Vec::new(); + let mut last = 0usize; + let mut i = 0usize; + let len = data.len(); - while i < len { - let u = data[i]; + while i < len { + let u = data[i]; - if u == 0x09 || u == 0x0a { - i += 1; - continue; - } + if u == 0x09 || u == 0x0a { + i += 1; + continue; + } - let mut remove_len = if u == ESC { - ansi_seq_len_u16(data, i).unwrap_or(0) - } else { - 0usize - }; + let mut remove_len = if u == ESC { + ansi_seq_len_u16(data, i).unwrap_or(0) + } else { + 0usize + }; - if remove_len == 0 { - if u == 0x0d { - remove_len = 1; - } else if u <= 0x1f || u == 0x7f || (0x80..=0x9f).contains(&u) { - remove_len = 1; - } else if (0xd800..=0xdbff).contains(&u) { - if i + 1 < len { - let lo = data[i + 1]; - if (0xdc00..=0xdfff).contains(&lo) { - i += 2; - continue; - } - } - remove_len = 1; - } else if (0xdc00..=0xdfff).contains(&u) { - remove_len = 1; - } - } + if remove_len == 0 { + if u == 0x0d || u <= 0x1f || u == 0x7f || (0x80..=0x9f).contains(&u) { + remove_len = 1; + } else if (0xd800..=0xdbff).contains(&u) { + if i + 1 < len { + let lo = data[i + 1]; + if (0xdc00..=0xdfff).contains(&lo) { + i += 2; + continue; + } + } + remove_len = 1; + } else if (0xdc00..=0xdfff).contains(&u) { + remove_len = 1; + } + } - if remove_len == 0 { - i += 1; - continue; - } + if remove_len == 0 { + i += 1; + continue; + } - if !did_change { - did_change = true; - out = Vec::with_capacity(len); - } - if last != i { - out.extend_from_slice(&data[last..i]); - } - i += remove_len; - last = i; - } + if !did_change { + did_change = true; + out = Vec::with_capacity(len); + } + if last != i { + out.extend_from_slice(&data[last..i]); + } + i += remove_len; + last = i; + } - if !did_change { - return Ok(utf16_to_string(data.to_vec())); - } - if last < len { - out.extend_from_slice(&data[last..]); - } - Ok(utf16_to_string(out)) + if !did_change { + return Ok(utf16_to_string(data)); + } + if last < len { + out.extend_from_slice(&data[last..]); + } + Ok(utf16_to_string(out)) } // ============================================================================ @@ -1419,248 +1438,283 @@ pub fn sanitize_text(text: JsString) -> Result { /// Tabs count as a fixed-width cell. #[napi(js_name = "visibleWidth")] pub fn visible_width_napi(text: JsString, tab_width: Option) -> Result { - let text_u16 = text.into_utf16()?; - let tab_width = clamp_tab_width(tab_width); - Ok(clamp_u32(visible_width_u16(text_u16.as_slice(), tab_width) as u64)) + let text_u16 = text.into_utf16()?; + let tab_width = clamp_tab_width(tab_width); + Ok(clamp_u32( + visible_width_u16(text_u16.as_slice(), tab_width) as u64 + )) } #[cfg(test)] mod tests { - use super::*; + use super::*; - fn to_u16(s: &str) -> Vec { - s.encode_utf16().collect() - } + fn to_u16(s: &str) -> Vec { + s.encode_utf16().collect() + } - #[test] - fn test_visible_width() { - assert_eq!(visible_width_u16(&to_u16("hello"), DEFAULT_TAB_WIDTH), 5); - assert_eq!( - visible_width_u16(&to_u16("\x1b[31mhello\x1b[0m"), DEFAULT_TAB_WIDTH), - 5 - ); - assert_eq!( - visible_width_u16(&to_u16("\x1b[38;5;196mred\x1b[0m"), DEFAULT_TAB_WIDTH), - 3 - ); - assert_eq!( - visible_width_u16(&to_u16("a\tb"), DEFAULT_TAB_WIDTH), - 1 + DEFAULT_TAB_WIDTH + 1 - ); - } + #[test] + fn test_visible_width() { + assert_eq!(visible_width_u16(&to_u16("hello"), DEFAULT_TAB_WIDTH), 5); + assert_eq!( + visible_width_u16(&to_u16("\x1b[31mhello\x1b[0m"), DEFAULT_TAB_WIDTH), + 5 + ); + assert_eq!( + visible_width_u16(&to_u16("\x1b[38;5;196mred\x1b[0m"), DEFAULT_TAB_WIDTH), + 3 + ); + assert_eq!( + visible_width_u16(&to_u16("a\tb"), DEFAULT_TAB_WIDTH), + 1 + DEFAULT_TAB_WIDTH + 1 + ); + } - #[test] - fn test_visible_width_cjk() { - assert_eq!( - visible_width_u16(&to_u16("\u{4e16}\u{754c}"), DEFAULT_TAB_WIDTH), - 4 - ); - assert_eq!(visible_width_u16(&to_u16("a\u{4e16}b"), DEFAULT_TAB_WIDTH), 4); - } + #[test] + fn test_visible_width_cjk() { + assert_eq!( + visible_width_u16(&to_u16("\u{4e16}\u{754c}"), DEFAULT_TAB_WIDTH), + 4 + ); + assert_eq!( + visible_width_u16(&to_u16("a\u{4e16}b"), DEFAULT_TAB_WIDTH), + 4 + ); + } - #[test] - fn test_visible_width_emoji() { - assert_eq!(visible_width_u16(&to_u16("\u{1f600}"), DEFAULT_TAB_WIDTH), 2); - } + #[test] + fn test_visible_width_emoji() { + assert_eq!( + visible_width_u16(&to_u16("\u{1f600}"), DEFAULT_TAB_WIDTH), + 2 + ); + } - #[test] - fn test_ansi_detection() { - let data = to_u16("\x1b[31mred\x1b[0m"); - assert_eq!(ansi_seq_len_u16(&data, 0), Some(5)); - assert_eq!(ansi_seq_len_u16(&data, 8), Some(4)); - } + #[test] + fn test_ansi_detection() { + let data = to_u16("\x1b[31mred\x1b[0m"); + assert_eq!(ansi_seq_len_u16(&data, 0), Some(5)); + assert_eq!(ansi_seq_len_u16(&data, 8), Some(4)); + } - #[test] - fn test_ansi_detection_osc() { - let data = to_u16("\x1b]0;title\x07rest"); - assert_eq!(ansi_seq_len_u16(&data, 0), Some(10)); - } + #[test] + fn test_ansi_detection_osc() { + let data = to_u16("\x1b]0;title\x07rest"); + assert_eq!(ansi_seq_len_u16(&data, 0), Some(10)); + } - #[test] - fn test_slice_basic() { - let data = to_u16("hello world"); - let (out, width) = slice_with_width_impl(&data, 0, 5, false, DEFAULT_TAB_WIDTH); - assert_eq!(String::from_utf16_lossy(&out), "hello"); - assert_eq!(width, 5); - } + #[test] + fn test_slice_basic() { + let data = to_u16("hello world"); + let (out, width) = slice_with_width_impl(&data, 0, 5, false, DEFAULT_TAB_WIDTH); + assert_eq!(String::from_utf16_lossy(&out), "hello"); + assert_eq!(width, 5); + } - #[test] - fn test_slice_middle() { - let data = to_u16("hello world"); - let (out, width) = slice_with_width_impl(&data, 6, 5, false, DEFAULT_TAB_WIDTH); - assert_eq!(String::from_utf16_lossy(&out), "world"); - assert_eq!(width, 5); - } + #[test] + fn test_slice_middle() { + let data = to_u16("hello world"); + let (out, width) = slice_with_width_impl(&data, 6, 5, false, DEFAULT_TAB_WIDTH); + assert_eq!(String::from_utf16_lossy(&out), "world"); + assert_eq!(width, 5); + } - #[test] - fn test_slice_with_ansi() { - let data = to_u16("\x1b[31mhello\x1b[0m world"); - let (out, width) = slice_with_width_impl(&data, 0, 5, false, DEFAULT_TAB_WIDTH); - assert_eq!(String::from_utf16_lossy(&out), "\x1b[31mhello\x1b[0m"); - assert_eq!(width, 5); - } + #[test] + fn test_slice_with_ansi() { + let data = to_u16("\x1b[31mhello\x1b[0m world"); + let (out, width) = slice_with_width_impl(&data, 0, 5, false, DEFAULT_TAB_WIDTH); + assert_eq!(String::from_utf16_lossy(&out), "\x1b[31mhello\x1b[0m"); + assert_eq!(width, 5); + } - #[test] - fn test_early_exit() { - let data = to_u16(&"a]b".repeat(1000)); - let (w, exceeded) = visible_width_u16_up_to(&data, 10, DEFAULT_TAB_WIDTH); - assert!(exceeded); - assert!(w > 10); - } + #[test] + fn test_early_exit() { + let data = to_u16(&"a]b".repeat(1000)); + let (w, exceeded) = visible_width_u16_up_to(&data, 10, DEFAULT_TAB_WIDTH); + assert!(exceeded); + assert!(w > 10); + } - #[test] - fn test_wrap_text_basic() { - let data = to_u16("hello world"); - let lines = wrap_text_with_ansi_impl(&data, 5, DEFAULT_TAB_WIDTH); - assert_eq!(lines.len(), 2); - assert_eq!(String::from_utf16_lossy(&lines[0]), "hello"); - assert_eq!(String::from_utf16_lossy(&lines[1]), "world"); - } + #[test] + fn test_wrap_text_basic() { + let data = to_u16("hello world"); + let lines = wrap_text_with_ansi_impl(&data, 5, DEFAULT_TAB_WIDTH); + assert_eq!(lines.len(), 2); + assert_eq!(String::from_utf16_lossy(&lines[0]), "hello"); + assert_eq!(String::from_utf16_lossy(&lines[1]), "world"); + } - #[test] - fn test_wrap_text_with_ansi_preserves_color() { - let data = to_u16("\x1b[38;2;156;163;176mhello world\x1b[0m"); - let lines = wrap_text_with_ansi_impl(&data, 5, DEFAULT_TAB_WIDTH); - assert_eq!(lines.len(), 2); - let first = String::from_utf16_lossy(&lines[0]); - let second = String::from_utf16_lossy(&lines[1]); - assert!(first.starts_with("\x1b[38;2;156;163;176m")); - assert!(second.starts_with("\x1b[38;2;156;163;176m")); - assert!(second.contains("world")); - } + #[test] + fn test_wrap_text_with_ansi_preserves_color() { + let data = to_u16("\x1b[38;2;156;163;176mhello world\x1b[0m"); + let lines = wrap_text_with_ansi_impl(&data, 5, DEFAULT_TAB_WIDTH); + assert_eq!(lines.len(), 2); + let first = String::from_utf16_lossy(&lines[0]); + let second = String::from_utf16_lossy(&lines[1]); + assert!(first.starts_with("\x1b[38;2;156;163;176m")); + assert!(second.starts_with("\x1b[38;2;156;163;176m")); + assert!(second.contains("world")); + } - #[test] - fn test_wrap_text_with_ansi_resets_strike() { - let data = to_u16( - "\x1b[38;5;196m\x1b[48;5;236m\x1b[9mstrikethrough content wraps\x1b[29m\x1b[0m", - ); - let lines = wrap_text_with_ansi_impl(&data, 12, DEFAULT_TAB_WIDTH); - assert!(lines.len() > 1); + #[test] + fn test_wrap_text_with_ansi_resets_strike() { + let data = + to_u16("\x1b[38;5;196m\x1b[48;5;236m\x1b[9mstrikethrough content wraps\x1b[29m\x1b[0m"); + let lines = wrap_text_with_ansi_impl(&data, 12, DEFAULT_TAB_WIDTH); + assert!(lines.len() > 1); - for line in &lines[..lines.len() - 1] { - let line_text = String::from_utf16_lossy(line); - if line_text.contains("\x1b[9m") { - assert!(line_text.ends_with("\x1b[29m")); - assert!(!line_text.ends_with("\x1b[0m")); - } - } + for line in &lines[..lines.len() - 1] { + let line_text = String::from_utf16_lossy(line); + if line_text.contains("\x1b[9m") { + assert!(line_text.ends_with("\x1b[29m")); + assert!(!line_text.ends_with("\x1b[0m")); + } + } - for line in &lines[1..] { - let line_text = String::from_utf16_lossy(line); - assert!(line_text.contains("38;5;196")); - assert!(line_text.contains("48;5;236")); - } - } + for line in &lines[1..] { + let line_text = String::from_utf16_lossy(line); + assert!(line_text.contains("38;5;196")); + assert!(line_text.contains("48;5;236")); + } + } - #[test] - fn test_wrap_text_multiline() { - let data = to_u16("line one\nline two"); - let lines = wrap_text_with_ansi_impl(&data, 20, DEFAULT_TAB_WIDTH); - assert_eq!(lines.len(), 2); - assert_eq!(String::from_utf16_lossy(&lines[0]), "line one"); - assert_eq!(String::from_utf16_lossy(&lines[1]), "line two"); - } + #[test] + fn test_wrap_text_multiline() { + let data = to_u16("line one\nline two"); + let lines = wrap_text_with_ansi_impl(&data, 20, DEFAULT_TAB_WIDTH); + assert_eq!(lines.len(), 2); + assert_eq!(String::from_utf16_lossy(&lines[0]), "line one"); + assert_eq!(String::from_utf16_lossy(&lines[1]), "line two"); + } - #[test] - fn test_wrap_text_empty() { - let data = to_u16(""); - let lines = wrap_text_with_ansi_impl(&data, 10, DEFAULT_TAB_WIDTH); - assert_eq!(lines.len(), 1); - assert!(lines[0].is_empty()); - } + #[test] + fn test_wrap_text_empty() { + let data = to_u16(""); + let lines = wrap_text_with_ansi_impl(&data, 10, DEFAULT_TAB_WIDTH); + assert_eq!(lines.len(), 1); + assert!(lines[0].is_empty()); + } - #[test] - fn test_extract_segments_basic() { - let data = to_u16("hello world test"); - let (before, bw, after, aw) = - extract_segments_impl(&data, 5, 6, 5, false, DEFAULT_TAB_WIDTH); - assert_eq!(String::from_utf16_lossy(&before), "hello"); - assert_eq!(bw, 5); - assert_eq!(String::from_utf16_lossy(&after), "world"); - assert_eq!(aw, 5); - } + #[test] + fn test_extract_segments_basic() { + let data = to_u16("hello world test"); + let (before, bw, after, aw) = + extract_segments_impl(&data, 5, 6, 5, false, DEFAULT_TAB_WIDTH); + assert_eq!(String::from_utf16_lossy(&before), "hello"); + assert_eq!(bw, 5); + assert_eq!(String::from_utf16_lossy(&after), "world"); + assert_eq!(aw, 5); + } - #[test] - fn test_ansi_state_sgr_parsing() { - let mut state = AnsiState::new(); - let params = to_u16("1;31"); - state.apply_sgr_u16(¶ms); - assert!(state.attrs & ATTR_BOLD != 0); - assert_eq!(state.fg, 2); // 31 - 29 = 2 + #[test] + fn test_ansi_state_sgr_parsing() { + let mut state = AnsiState::new(); + let params = to_u16("1;31"); + state.apply_sgr_u16(¶ms); + assert!(state.attrs & ATTR_BOLD != 0); + assert_eq!(state.fg, 2); // 31 - 29 = 2 - let params = to_u16("0"); - state.apply_sgr_u16(¶ms); - assert!(state.is_empty()); - } + let params = to_u16("0"); + state.apply_sgr_u16(¶ms); + assert!(state.is_empty()); + } - #[test] - fn test_ansi_state_256_color() { - let mut state = AnsiState::new(); - let params = to_u16("38;5;196"); - state.apply_sgr_u16(¶ms); - assert_eq!(state.fg, 0x100 | 196); - } + #[test] + fn test_ansi_state_256_color() { + let mut state = AnsiState::new(); + let params = to_u16("38;5;196"); + state.apply_sgr_u16(¶ms); + assert_eq!(state.fg, 0x100 | 196); + } - #[test] - fn test_ansi_state_rgb_color() { - let mut state = AnsiState::new(); - let params = to_u16("38;2;255;128;0"); - state.apply_sgr_u16(¶ms); - assert_eq!(state.fg, 0x1000000 | (255 << 16) | (128 << 8) | 0); - } + #[test] + fn test_ansi_state_rgb_color() { + let mut state = AnsiState::new(); + let params = to_u16("38;2;255;128;0"); + state.apply_sgr_u16(¶ms); + assert_eq!(state.fg, 0x1000000 | (255 << 16) | (128 << 8) | 0); + } - #[test] - fn test_wrap_text_osc8_hyperlink_carried_across_lines() { - // OSC 8 hyperlink wrapping: \x1b]8;;https://example.com\x07click here please\x1b]8;;\x07 - let url = "https://example.com"; - let open = format!("\x1b]8;;{}\x07", url); - let close = "\x1b]8;;\x07"; - let text = format!("{}click here please{}", open, close); - let data = to_u16(&text); - // Width 10 forces "click here please" (18 chars) to wrap - let lines = wrap_text_with_ansi_impl(&data, 10, DEFAULT_TAB_WIDTH); - assert!(lines.len() >= 2, "Expected wrapping, got {} lines", lines.len()); + #[test] + fn test_wrap_text_osc8_hyperlink_carried_across_lines() { + // OSC 8 hyperlink wrapping: \x1b]8;;https://example.com\x07click here please\x1b]8;;\x07 + let url = "https://example.com"; + let open = format!("\x1b]8;;{}\x07", url); + let close = "\x1b]8;;\x07"; + let text = format!("{}click here please{}", open, close); + let data = to_u16(&text); + // Width 10 forces "click here please" (18 chars) to wrap + let lines = wrap_text_with_ansi_impl(&data, 10, DEFAULT_TAB_WIDTH); + assert!( + lines.len() >= 2, + "Expected wrapping, got {} lines", + lines.len() + ); - let first = String::from_utf16_lossy(&lines[0]); - let second = String::from_utf16_lossy(&lines[1]); + let first = String::from_utf16_lossy(&lines[0]); + let second = String::from_utf16_lossy(&lines[1]); - // First line should open the hyperlink and close it at the end - assert!(first.starts_with(&open), "First line should start with OSC 8 open: {:?}", first); - assert!(first.ends_with(close), "First line should end with OSC 8 close: {:?}", first); + // First line should open the hyperlink and close it at the end + assert!( + first.starts_with(&open), + "First line should start with OSC 8 open: {:?}", + first + ); + assert!( + first.ends_with(close), + "First line should end with OSC 8 close: {:?}", + first + ); - // Second line should re-open the hyperlink - assert!(second.starts_with(&open), "Second line should re-open OSC 8: {:?}", second); - } + // Second line should re-open the hyperlink + assert!( + second.starts_with(&open), + "Second line should re-open OSC 8: {:?}", + second + ); + } - #[test] - fn test_wrap_text_osc8_long_url_break() { - // A long URL wrapped inside an OSC 8 hyperlink - let url = "https://accounts.google.com/o/oauth2/v2/auth?client_id=abc&redirect_uri=http://localhost:9004&scope=email&state=xyz"; - let open = format!("\x1b]8;;{}\x07", url); - let close = "\x1b]8;;\x07"; - let text = format!("{}{}{}", open, url, close); - let data = to_u16(&text); - let lines = wrap_text_with_ansi_impl(&data, 40, DEFAULT_TAB_WIDTH); - assert!(lines.len() >= 2, "Expected wrapping, got {} lines", lines.len()); + #[test] + fn test_wrap_text_osc8_long_url_break() { + // A long URL wrapped inside an OSC 8 hyperlink + let url = "https://accounts.google.com/o/oauth2/v2/auth?client_id=abc&redirect_uri=http://localhost:9004&scope=email&state=xyz"; + let open = format!("\x1b]8;;{}\x07", url); + let close = "\x1b]8;;\x07"; + let text = format!("{}{}{}", open, url, close); + let data = to_u16(&text); + let lines = wrap_text_with_ansi_impl(&data, 40, DEFAULT_TAB_WIDTH); + assert!( + lines.len() >= 2, + "Expected wrapping, got {} lines", + lines.len() + ); - for (i, line) in lines.iter().enumerate() { - let s = String::from_utf16_lossy(line); - // Every line except possibly the last (which has the close) should - // have the OSC 8 open sequence - assert!(s.contains(&open) || s.contains(close), - "Line {} should contain OSC 8 open or close: {:?}", i, s); - } + for (i, line) in lines.iter().enumerate() { + let s = String::from_utf16_lossy(line); + // Every line except possibly the last (which has the close) should + // have the OSC 8 open sequence + assert!( + s.contains(&open) || s.contains(close), + "Line {} should contain OSC 8 open or close: {:?}", + i, + s + ); + } - // Last line should contain the close - let last = String::from_utf16_lossy(lines.last().unwrap()); - assert!(last.contains(close), "Last line should contain OSC 8 close: {:?}", last); - } + // Last line should contain the close + let last = String::from_utf16_lossy(lines.last().unwrap()); + assert!( + last.contains(close), + "Last line should contain OSC 8 close: {:?}", + last + ); + } - #[test] - fn test_clamp_u32_helper() { - assert_eq!(clamp_u32(0), 0); - assert_eq!(clamp_u32(42), 42); - assert_eq!(clamp_u32(u32::MAX as u64), u32::MAX); - assert_eq!(clamp_u32(u32::MAX as u64 + 1), u32::MAX); - } + #[test] + fn test_clamp_u32_helper() { + assert_eq!(clamp_u32(0), 0); + assert_eq!(clamp_u32(42), 42); + assert_eq!(clamp_u32(u32::MAX as u64), u32::MAX); + assert_eq!(clamp_u32(u32::MAX as u64 + 1), u32::MAX); + } } diff --git a/rust-engine/crates/engine/src/truncate.rs b/rust-engine/crates/engine/src/truncate.rs index 101759894..d4783f0e4 100644 --- a/rust-engine/crates/engine/src/truncate.rs +++ b/rust-engine/crates/engine/src/truncate.rs @@ -44,7 +44,11 @@ pub fn truncate_tail(text: String, max_bytes: u32) -> TruncateResult { // Fast path: fits entirely if total_bytes <= max { let line_count = memchr::memchr_iter(b'\n', text.as_bytes()).count() - + if text.is_empty() || text.ends_with('\n') { 0 } else { 1 }; + + if text.is_empty() || text.ends_with('\n') { + 0 + } else { + 1 + }; return TruncateResult { text, truncated: false, @@ -73,7 +77,9 @@ pub fn truncate_tail(text: String, max_bytes: u32) -> TruncateResult { let kept_lines = count_lines(kept); TruncateResult { - text: std::str::from_utf8(kept).expect("split at newline boundary preserves UTF-8").to_owned(), + text: std::str::from_utf8(kept) + .expect("split at newline boundary preserves UTF-8") + .to_owned(), truncated: true, original_lines, kept_lines, @@ -93,7 +99,11 @@ pub fn truncate_head(text: String, max_bytes: u32) -> TruncateResult { // Fast path if total_bytes <= max { let line_count = memchr::memchr_iter(b'\n', text.as_bytes()).count() - + if text.is_empty() || text.ends_with('\n') { 0 } else { 1 }; + + if text.is_empty() || text.ends_with('\n') { + 0 + } else { + 1 + }; return TruncateResult { text, truncated: false, @@ -124,7 +134,9 @@ pub fn truncate_head(text: String, max_bytes: u32) -> TruncateResult { let kept_lines = count_lines(kept); TruncateResult { - text: std::str::from_utf8(kept).expect("split at newline boundary preserves UTF-8").to_owned(), + text: std::str::from_utf8(kept) + .expect("split at newline boundary preserves UTF-8") + .to_owned(), truncated: true, original_lines, kept_lines, @@ -138,11 +150,7 @@ pub fn truncate_head(text: String, max_bytes: u32) -> TruncateResult { /// - `"head"`: keep the end (tail truncation removes head) /// - `"both"`: keep beginning and end, elide the middle #[napi(js_name = "truncateOutput")] -pub fn truncate_output( - text: String, - max_bytes: u32, - mode: Option, -) -> TruncateOutputResult { +pub fn truncate_output(text: String, max_bytes: u32, mode: Option) -> TruncateOutputResult { let max = max_bytes as usize; if text.len() <= max { diff --git a/rust-engine/crates/engine/src/watch.rs b/rust-engine/crates/engine/src/watch.rs index 8b913557b..5951876f5 100644 --- a/rust-engine/crates/engine/src/watch.rs +++ b/rust-engine/crates/engine/src/watch.rs @@ -13,7 +13,7 @@ use napi_derive::napi; use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; -use std::sync::{Arc, mpsc}; +use std::sync::{mpsc, Arc}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; @@ -81,8 +81,8 @@ fn build_ignore_set(patterns: &[String]) -> std::result::Result } fn event_kind(kind: &EventKind) -> Option<&'static str> { - use notify::EventKind::*; use notify::event::ModifyKind; + use notify::EventKind::*; match kind { Create(_) => Some("create"), @@ -182,8 +182,9 @@ pub fn watch_tree( build_ignore_set(&ignore_patterns).map_err(|e| Error::new(Status::InvalidArg, e))?; let has_ignores = !ignore_patterns.is_empty(); - let tsfn: ThreadsafeFunction> = on_events - .create_threadsafe_function(0, |ctx: ThreadSafeCallContext>| { + let tsfn: ThreadsafeFunction> = on_events.create_threadsafe_function( + 0, + |ctx: ThreadSafeCallContext>| { let events: Vec = ctx.value; let env = ctx.env; let mut arr = env.create_array_with_length(events.len())?; @@ -194,7 +195,8 @@ pub fn watch_tree( arr.set_element(i as u32, obj)?; } Ok(vec![arr]) - })?; + }, + )?; let (sender, receiver) = mpsc::channel(); let mut watcher = RecommendedWatcher::new( @@ -203,7 +205,12 @@ pub fn watch_tree( }, Config::default(), ) - .map_err(|e| Error::new(Status::GenericFailure, format!("failed to create watcher: {e}")))?; + .map_err(|e| { + Error::new( + Status::GenericFailure, + format!("failed to create watcher: {e}"), + ) + })?; let mode = if recursive { RecursiveMode::Recursive diff --git a/rust-engine/crates/engine/src/xxhash.rs b/rust-engine/crates/engine/src/xxhash.rs index d6092c52e..a31075d98 100644 --- a/rust-engine/crates/engine/src/xxhash.rs +++ b/rust-engine/crates/engine/src/xxhash.rs @@ -11,33 +11,33 @@ use napi_derive::napi; /// the input string is converted to UTF-8 bytes and hashed. #[napi(js_name = "xxHash32")] pub fn xx_hash32(input: String, seed: u32) -> u32 { - xxhash_rust::xxh32::xxh32(input.as_bytes(), seed) + xxhash_rust::xxh32::xxh32(input.as_bytes(), seed) } #[cfg(test)] mod tests { - use super::*; + use super::*; - /// Reference vectors verified against the pure-JS implementation. - #[test] - fn known_vectors() { - // Empty string, seed 0 - assert_eq!(xx_hash32(String::new(), 0), 0x02CC5D05); - // "hello", seed 0 - assert_eq!(xx_hash32("hello".into(), 0), 0xFB0DA52A); - // "hello", seed 42 - assert_eq!(xx_hash32("hello".into(), 42), 0x0AA8E13E); - } + /// Reference vectors verified against the pure-JS implementation. + #[test] + fn known_vectors() { + // Empty string, seed 0 + assert_eq!(xx_hash32(String::new(), 0), 0x02CC5D05); + // "hello", seed 0 + assert_eq!(xx_hash32("hello".into(), 0), 0xFB0DA52A); + // "hello", seed 42 + assert_eq!(xx_hash32("hello".into(), 42), 0x0AA8E13E); + } - #[test] - fn short_and_long_inputs() { - // < 16 bytes (no stripe loop) - let short = xx_hash32("abc".into(), 0); - assert_ne!(short, 0); + #[test] + fn short_and_long_inputs() { + // < 16 bytes (no stripe loop) + let short = xx_hash32("abc".into(), 0); + assert_ne!(short, 0); - // >= 16 bytes (enters stripe loop) - let long = xx_hash32("abcdefghijklmnop".into(), 0); - assert_ne!(long, 0); - assert_ne!(short, long); - } + // >= 16 bytes (enters stripe loop) + let long = xx_hash32("abcdefghijklmnop".into(), 0); + assert_ne!(long, 0); + assert_ne!(short, long); + } } diff --git a/src/resources/extensions/remote-questions/config.d.ts b/src/resources/extensions/remote-questions/config.d.ts deleted file mode 100644 index aa3c9b16b..000000000 --- a/src/resources/extensions/remote-questions/config.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface RemoteConfig { - endpoint: string; - apiKey?: string; - timeout?: number; -} - -export function resolveRemoteConfig(): RemoteConfig; -export function resolveRemotePreferenceConfig(hydrateTokens?: boolean): RemoteConfig; -export function getRemoteConfigStatus(): string; -export function isValidChannelId(channel: string, id: string): boolean; diff --git a/src/resources/extensions/search-the-web/url-utils.d.ts b/src/resources/extensions/search-the-web/url-utils.d.ts deleted file mode 100644 index 3f7542a19..000000000 --- a/src/resources/extensions/search-the-web/url-utils.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function setFetchAllowedUrls(hostnames: string[]): void; -export function getFetchAllowedUrls(): string[]; -export function isBlockedUrl(url: string): boolean; -export function normalizeQuery(query: string): string; -export function toDedupeKey(url: string): string; -export function extractDomain(url: string): string; -export function detectFreshness(query: string): string | null; -export function detectDomainHints(query: string): string[]; diff --git a/src/resources/extensions/sf/agentic-docs-scaffold.d.ts b/src/resources/extensions/sf/agentic-docs-scaffold.d.ts deleted file mode 100644 index b188266db..000000000 --- a/src/resources/extensions/sf/agentic-docs-scaffold.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const SCAFFOLD_FILES: string[]; -export function ensureAgenticDocsScaffold(basePath?: string): void; diff --git a/src/resources/extensions/sf/code-intelligence.d.ts b/src/resources/extensions/sf/code-intelligence.d.ts deleted file mode 100644 index ea97f587c..000000000 --- a/src/resources/extensions/sf/code-intelligence.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -export const PROJECT_RAG_MCP_SERVER_NAME: string; -export function detectProjectRag(projectRoot: string, prefs: Record, env?: NodeJS.ProcessEnv): unknown; -export function resolveProjectRagBinary(env?: NodeJS.ProcessEnv): string | null; -export function resolveSiftBinary(env?: NodeJS.ProcessEnv): string | null; -export function detectSift(_projectRoot: string, prefs: Record, env?: NodeJS.ProcessEnv): unknown; -export function ensureSiftIndexWarmup(projectRoot: string, prefs: Record, options?: Record): Promise; -export function resolveProjectRagBuildJobs(env?: NodeJS.ProcessEnv): number; -export function findProjectRagSourceDir(projectRoot: string, env?: NodeJS.ProcessEnv): string | null; -export function resolveProjectRagBinaryForProject(projectRoot: string, env?: NodeJS.ProcessEnv): string | null; -export function buildProjectRagMcpServerConfig(projectRoot?: string, env?: NodeJS.ProcessEnv): Record; -export function buildProjectRagBinary(projectRoot: string, env?: NodeJS.ProcessEnv): boolean; -export function ensureProjectRagMcpConfig(projectRoot: string, env?: NodeJS.ProcessEnv): void; -export function resolveCodebaseIndexerBackendName(prefs: Record): string; -export function resolveEffectiveCodebaseIndexerBackendName(projectRoot: string, prefs: Record, env?: NodeJS.ProcessEnv): string; -export function getCodebaseIndexerBackend(prefsOrName: Record | string): unknown; -export function detectCodebaseIndexer(projectRoot: string, prefs: Record, env?: NodeJS.ProcessEnv): unknown; -export function formatCodebaseIndexerStatus(projectRoot: string, prefs: Record, env?: NodeJS.ProcessEnv): string; -export function buildCodeIntelligenceContextBlock(projectRoot: string, prefs: Record, env?: NodeJS.ProcessEnv): string; -export function formatProjectRagStatus(projectRoot: string, prefs: Record, env?: NodeJS.ProcessEnv): string; -export function formatSiftStatus(projectRoot: string, prefs: Record, env?: NodeJS.ProcessEnv): string; -export const PROJECT_RAG_CODEBASE_INDEXER_BACKEND: Record; -export const SIFT_CODEBASE_INDEXER_BACKEND: Record; -export const NO_CODEBASE_INDEXER_BACKEND: Record; -export const CODEBASE_INDEXER_BACKENDS: Record; diff --git a/src/resources/extensions/sf/doc-checker.d.ts b/src/resources/extensions/sf/doc-checker.d.ts deleted file mode 100644 index 5afb7a1f5..000000000 --- a/src/resources/extensions/sf/doc-checker.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -export interface DocCheckResult { - checkedAt: string; - repoRoot: string; - checks: Array<{ file: string; status: string; message?: string }>; - summary: { - total: number; - ok: number; - empty: number; - stub: number; - missing: number; - }; -} - -export function checkDocsScaffold(repoRoot: string): DocCheckResult; -export function formatDocCheckReport(report: DocCheckResult): string; diff --git a/src/resources/extensions/sf/doctor.d.ts b/src/resources/extensions/sf/doctor.d.ts deleted file mode 100644 index 71a1b76f9..000000000 --- a/src/resources/extensions/sf/doctor.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -export function validateTitle(title: string): string | null; -export function buildStateMarkdown(state: Record): string; - -export interface DoctorIssue { - severity: "error" | "warning"; - code: string; - scope: string; - unitId: string; - message: string; - file?: string; - fixable?: boolean; -} - -export interface DoctorReport { - ok: boolean; - basePath: string; - issues: DoctorIssue[]; - fixesApplied: string[]; - timing?: Record; - scope?: string; -} - -export function runSFDoctor(basePath: string, options?: Record): Promise; -export function formatDoctorReport(report: DoctorReport): string; -export function formatDoctorReportJson(report: DoctorReport): string; diff --git a/src/resources/extensions/sf/gitignore.d.ts b/src/resources/extensions/sf/gitignore.d.ts deleted file mode 100644 index 55582388a..000000000 --- a/src/resources/extensions/sf/gitignore.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function isSfGitignored(basePath?: string): boolean; -export function hasGitTrackedSfFiles(basePath?: string): boolean; -export function ensureGitInfoExclude(basePath?: string): void; -export function ensureGitignore(basePath?: string, options?: Record): void; -export function untrackRuntimeFiles(basePath?: string): void; -export function ensurePreferences(basePath?: string): void; diff --git a/src/resources/extensions/sf/native-git-bridge.d.ts b/src/resources/extensions/sf/native-git-bridge.d.ts deleted file mode 100644 index 2477d3cdc..000000000 --- a/src/resources/extensions/sf/native-git-bridge.d.ts +++ /dev/null @@ -1,52 +0,0 @@ -export function nativeGetCurrentBranch(basePath: string): string; -export function nativeDetectMainBranch(basePath: string): string; -export function nativeBranchExists(basePath: string, branch: string): boolean; -export function nativeHasMergeConflicts(basePath: string): boolean; -export function nativeWorkingTreeStatus(basePath: string): string; -export function nativeHasChanges(basePath: string): boolean; -export function _resetHasChangesCache(): void; -export function nativeCommitCountBetween(basePath: string, fromRef: string, toRef: string): number; -export function nativeIsRepo(basePath: string): boolean; -export function nativeHasStagedChanges(basePath: string): boolean; -export function nativeDiffStat(basePath: string, fromRef: string, toRef: string): string; -export function nativeDiffNameStatus(basePath: string, fromRef: string, toRef: string, pathspec?: string, useMergeBase?: boolean): string[]; -export function nativeDiffNumstat(basePath: string, fromRef: string, toRef: string): string; -export function nativeDiffContent(basePath: string, fromRef: string, toRef: string, pathspec?: string, exclude?: string[], useMergeBase?: boolean): string; -export function nativeLogOneline(basePath: string, fromRef?: string, toRef?: string): string[]; -export function nativeWorktreeList(basePath: string): string[]; -export function nativeBranchList(basePath: string, pattern?: string): string[]; -export function nativeBranchListMerged(basePath: string, target: string, pattern?: string): string[]; -export function nativeLsFiles(basePath: string, pathspec?: string): string[]; -export function nativeForEachRef(basePath: string, prefix?: string): string[]; -export function nativeConflictFiles(basePath: string): string[]; -export function nativeBatchInfo(basePath: string): Record; -export function nativeInit(basePath: string, initialBranch?: string): void; -export function nativeAddAll(basePath: string): void; -export function nativeAddTracked(basePath: string): void; -export function nativeAddAllWithExclusions(basePath: string, exclusions: string[]): void; -export function nativeAddPaths(basePath: string, paths: string[]): void; -export function nativeResetPaths(basePath: string, paths: string[]): void; -export function nativeCommit(basePath: string, message: string, options?: Record): void; -export function nativeCheckoutBranch(basePath: string, branch: string): void; -export function nativeCheckoutTheirs(basePath: string, paths: string[]): void; -export function nativeMergeSquash(basePath: string, branch: string): void; -export function nativeMergeAbort(basePath: string): void; -export function nativeRebaseAbort(basePath: string): void; -export function nativeResetHard(basePath: string): void; -export function nativeResetSoft(basePath: string, target?: string): void; -export function nativeCommitSubject(basePath: string, ref: string): string; -export function nativeBranchDelete(basePath: string, branch: string, force?: boolean): void; -export function nativeBranchForceReset(basePath: string, branch: string, target: string): void; -export function nativeRmCached(basePath: string, paths: string[], recursive?: boolean): void; -export function nativeRmForce(basePath: string, paths: string[]): void; -export function nativeWorktreeAdd(basePath: string, wtPath: string, branch: string, createBranch?: boolean, startPoint?: string): void; -export function nativeWorktreeRemove(basePath: string, wtPath: string, force?: boolean): void; -export function nativeWorktreePrune(basePath: string): void; -export function nativeRevertCommit(basePath: string, sha: string): void; -export function nativeRevertAbort(basePath: string): void; -export function nativeUpdateRef(basePath: string, refname: string, target: string): void; -export function isNativeGitAvailable(): boolean; -export function nativeIsAncestor(basePath: string, ancestor: string, descendant: string): boolean; -export function nativeLastCommitEpoch(basePath: string, ref?: string): number; -export function nativeUnpushedCount(basePath: string, branch: string): number; -export function getCommitsBehindMain(worktreePath: string, mainRef: string): number; diff --git a/src/resources/extensions/sf/paths.d.ts b/src/resources/extensions/sf/paths.d.ts deleted file mode 100644 index 60679822a..000000000 --- a/src/resources/extensions/sf/paths.d.ts +++ /dev/null @@ -1,30 +0,0 @@ -export function clearPathCache(): void; -export function buildMilestoneFileName(milestoneId: string, suffix: string): string; -export function buildSliceFileName(sliceId: string, suffix: string): string; -export function buildTaskFileName(taskId: string, suffix: string): string; -export function resolveDir(parentDir: string, idPrefix: string): string; -export function resolveFile(dir: string, idPrefix: string, suffix: string): string; -export function resolveTaskFiles(tasksDir: string, suffix: string): string[]; -export function resolveTaskJsonFiles(tasksDir: string, suffix: string): string[]; -export const SF_ROOT_FILES: Record; -export function _clearSfRootCache(): void; -export function sfRoot(basePath?: string): string; -export const projectRoot: typeof sfRoot; -export function isRunningOnSelf(basePath?: string): boolean; -export function _resetSelfDetectionCache(): void; -export function sfRuntimeRoot(basePath?: string): string; -export function milestonesDir(basePath?: string): string; -export function resolveRuntimeFile(basePath?: string): string; -export function resolveSfRootFile(basePath: string, key: string): string; -export function relSfRootFile(key: string): string; -export function resolveMilestonePath(basePath: string, milestoneId: string): string; -export function resolveMilestoneFile(basePath: string, milestoneId: string, suffix: string): string; -export function resolveSlicePath(basePath: string, milestoneId: string, sliceId: string): string; -export function resolveSliceFile(basePath: string, milestoneId: string, sliceId: string, suffix: string): string; -export function resolveTasksDir(basePath: string, milestoneId: string, sliceId: string): string; -export function resolveTaskFile(basePath: string, milestoneId: string, sliceId: string, taskId: string, suffix: string): string; -export function relMilestonePath(basePath: string, milestoneId: string): string; -export function relMilestoneFile(basePath: string, milestoneId: string, suffix: string): string; -export function relSlicePath(basePath: string, milestoneId: string, sliceId: string): string; -export function relSliceFile(basePath: string, milestoneId: string, sliceId: string, suffix: string): string; -export function relTaskFile(basePath: string, milestoneId: string, sliceId: string, taskId: string, suffix: string): string; diff --git a/src/resources/extensions/sf/preferences-models.d.ts b/src/resources/extensions/sf/preferences-models.d.ts deleted file mode 100644 index 65f63658b..000000000 --- a/src/resources/extensions/sf/preferences-models.d.ts +++ /dev/null @@ -1,20 +0,0 @@ -export function filterModelsByProviderModelAllow(models: unknown[], providerModelAllow: unknown, providerModelBlock: unknown): unknown[]; -export function isProviderAllowedByLists(provider: string, allowedProviders: string[], blockedProviders: string[]): boolean; -export function isProviderAllowedForAdvisor(providerKey: string, prefs: Record): boolean; -export function resolveModelForUnit(unitType: string): string; -export function resolveModelWithFallbacksForUnit(unitType: string, options?: Record): string; -export function resolveDefaultSessionModel(sessionProvider: string): string; -export function isCustomProvider(provider: string): boolean; -export function getNextFallbackModel(currentModelId: string, modelConfig: Record): string | null; -export function isTransientNetworkError(errorMsg: string): boolean; -export function validateModelId(modelId: string): boolean; -export function updatePreferencesModels(models: unknown[]): void; -export function updateSubscriptionTokensUsed(provider: string, tokensConsumed: number): void; -export function resolveDynamicRoutingConfig(): Record; -export function resolvePersistModelChanges(): boolean; -export function resolveAutoSupervisorConfig(): Record; -export function resolveProfileDefaults(profile: string): Record; -export function resolveEffectiveProfile(): string; -export function resolveInlineLevel(): string; -export function resolveContextSelection(): string; -export function resolveSearchProviderFromPreferences(): string; diff --git a/src/resources/extensions/sf/preferences.d.ts b/src/resources/extensions/sf/preferences.d.ts deleted file mode 100644 index 48fe18071..000000000 --- a/src/resources/extensions/sf/preferences.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -export function resolveSkillDiscoveryMode(): string; -export function resolveSkillStalenessDays(): number; -export function getGlobalSFPreferencesPath(): string; -export function getLegacyGlobalSFPreferencesPath(): string; -export function getProjectSFPreferencesPath(): string; -export function loadGlobalSFPreferences(): Record; -export function loadProjectSFPreferences(): Record; -export function loadEffectiveSFPreferences(): { - path: string; - preferences: Record; -} | null; -export function _resetParseWarningFlag(): void; -export function parsePreferencesMarkdown(content: string): Record; -export function applyModeDefaults(mode: string, prefs: Record): Record; -export function renderPreferencesForSystemPrompt(preferences: Record, resolutions: Record): string; -export function resolvePostUnitHooks(): string[]; -export function resolvePreDispatchHooks(): string[]; -export function getIsolationMode(): string; -export function resolveParallelConfig(prefs: Record): Record; diff --git a/src/resources/extensions/sf/prompts/discuss.md b/src/resources/extensions/sf/prompts/discuss.md index a85ce9b27..1c3633a0e 100644 --- a/src/resources/extensions/sf/prompts/discuss.md +++ b/src/resources/extensions/sf/prompts/discuss.md @@ -34,7 +34,7 @@ After reflection is confirmed, decide the approach based on the actual scope — Before asking your first question, do a mandatory investigation pass. This is not optional. -1. **Scout the codebase** — use in-process `grep`, `find`, `ls`, and `lsp` first; use `codebase_search` for Sift-backed hybrid retrieval; use `scout` for broad unfamiliar areas that need a separate explorer. Understand what already exists, what patterns are established, what constraints current code imposes. +1. **Scout the codebase** — use `codebase_search` for conceptual, behavioral, or architectural discovery (e.g. "how does X work?", "where is Y handled?"); use in-process `grep`, `find`, `ls`, and `lsp` for exact identifier matches or structural navigation. Use `scout` for broad unfamiliar areas that need a separate explorer. Understand what already exists, what patterns are established, what constraints current code imposes. 2. **Check library docs — DeepWiki first.** Use `ask_question` / `read_wiki_structure` / `read_wiki_contents` (DeepWiki) as the default for any GitHub-hosted library or framework the user mentioned. Fall back to `resolve_library` / `get_library_docs` (Context7) for npm/pypi/crates packages DeepWiki doesn't have. **Context7 free tier is capped at 1000 req/month — spend those on cases DeepWiki can't cover.** Get current facts about capabilities, constraints, API shapes, version-specific behavior. 3. **Web search** — `search-the-web` if the domain is unfamiliar, if you need current best practices, or if the user referenced external services/APIs you need facts about. Use `fetch_page` for full content when snippets aren't enough. diff --git a/src/resources/extensions/sf/prompts/guided-discuss-milestone.md b/src/resources/extensions/sf/prompts/guided-discuss-milestone.md index fa7c0411f..8af4e2475 100644 --- a/src/resources/extensions/sf/prompts/guided-discuss-milestone.md +++ b/src/resources/extensions/sf/prompts/guided-discuss-milestone.md @@ -15,7 +15,7 @@ Apply `pm-planning` skill thinking throughout: use Working Backwards to anchor o ### Before your first question round Do a lightweight targeted investigation so your questions are grounded in reality: -- Scout the codebase with in-process `grep`, `find`, `ls`, and `lsp` first; use `codebase_search` for Sift-backed hybrid retrieval; use `scout` for broad unfamiliar areas that need a separate explorer +- Scout the codebase: use `codebase_search` for conceptual, behavioral, or architectural discovery (e.g. "how does X work?", "where is Y handled?"); use in-process `grep`, `find`, `ls`, and `lsp` for exact identifier matches or structural navigation. Use `scout` for broad unfamiliar areas that need a separate explorer. - If the `PROJECT CODE INTELLIGENCE` block says Project RAG is configured, use its MCP search tools for broad concept, symbol, schema, and git-history lookup before manually reading files - Check the roadmap context above (if present) to understand what surrounds this milestone - **Library docs — DeepWiki first.** Use `ask_question` / `read_wiki_structure` / `read_wiki_contents` (DeepWiki) for any GitHub-hosted library. Fall back to `resolve_library` / `get_library_docs` (Context7) only when DeepWiki doesn't have it (Context7 is capped at 1000 req/month free tier). diff --git a/src/resources/extensions/sf/prompts/guided-discuss-slice.md b/src/resources/extensions/sf/prompts/guided-discuss-slice.md index 40d348a41..2a87dadcb 100644 --- a/src/resources/extensions/sf/prompts/guided-discuss-slice.md +++ b/src/resources/extensions/sf/prompts/guided-discuss-slice.md @@ -11,7 +11,7 @@ Your goal is **not** to center the discussion on tech stack trivia, naming conve ### Before your first question round Do a lightweight targeted investigation so your questions are grounded in reality: -- Scout the codebase with in-process `grep`, `find`, `ls`, and `lsp` first; use `codebase_search` for Sift-backed hybrid retrieval; use `scout` for broad unfamiliar areas that need a separate explorer +- Scout the codebase: use `codebase_search` for conceptual, behavioral, or architectural discovery (e.g. "how does X work?", "where is Y handled?"); use in-process `grep`, `find`, `ls`, and `lsp` for exact identifier matches or structural navigation. Use `scout` for broad unfamiliar areas that need a separate explorer. - Check the roadmap context above to understand what surrounds this slice — what comes before, what depends on it - **Library docs — DeepWiki first.** Use `ask_question` / `read_wiki_structure` / `read_wiki_contents` (DeepWiki) for any GitHub-hosted library. Fall back to `resolve_library` / `get_library_docs` (Context7) only when DeepWiki doesn't have it (Context7 is capped at 1000 req/month free tier). - Identify the 3–5 biggest behavioural unknowns: things where the user's answer will materially change what gets built diff --git a/src/resources/extensions/sf/prompts/queue.md b/src/resources/extensions/sf/prompts/queue.md index 4b0454319..834cbd5e6 100644 --- a/src/resources/extensions/sf/prompts/queue.md +++ b/src/resources/extensions/sf/prompts/queue.md @@ -26,7 +26,7 @@ Never fabricate or simulate user input during this discussion. Never generate fa - Check library docs **DeepWiki first** (`ask_question` / `read_wiki_structure` / `read_wiki_contents`) for any GitHub-hosted library or framework — AI-indexed, no free-tier cap. Fall back to Context7 (`resolve_library` / `get_library_docs`) for npm/pypi/crates packages DeepWiki doesn't cover. Context7 free tier is 1000 req/month — don't spend those on cases DeepWiki covers. - Do web searches (`search-the-web`) to verify the landscape — what solutions exist, what's changed recently, what's the current best practice. Use `freshness` for recency-sensitive queries, `domain` to target specific sites. Use `fetch_page` to read the full content of promising URLs when snippets aren't enough. **Budget:** You have a limited number of web searches per turn (typically 3-5). Prefer DeepWiki → Context7 → web search for docs; use `search_and_read` for one-shot topic research. Do NOT repeat the same or similar queries. Distribute searches across turns rather than clustering them. -- Scout the codebase with in-process `grep`, `find`, `ls`, and `lsp` first; use `codebase_search` for Sift-backed hybrid retrieval; use `scout` for broad unfamiliar areas that need a separate explorer. Understand what already exists, what patterns are established, what constraints current code imposes +- Scout the codebase: use `codebase_search` for conceptual, behavioral, or architectural discovery (e.g. "how does X work?", "where is Y handled?"); use in-process `grep`, `find`, `ls`, and `lsp` for exact identifier matches or structural navigation. Use `scout` for broad unfamiliar areas that need a separate explorer. Understand what already exists, what patterns are established, what constraints current code imposes. Don't go deep — just enough that your next question reflects what's actually true rather than what you assume. diff --git a/src/resources/extensions/sf/prompts/system.md b/src/resources/extensions/sf/prompts/system.md index a21815e98..abe2cccb8 100644 --- a/src/resources/extensions/sf/prompts/system.md +++ b/src/resources/extensions/sf/prompts/system.md @@ -161,7 +161,7 @@ Templates showing the expected format for each artifact type are in: **Code navigation:** Use `lsp` for definition, type_definition, implementation, references, incoming_calls, outgoing_calls, hover, signature, symbols, rename, code_actions, format, and diagnostics. Falls back gracefully if no server is available. Never `grep` for a symbol definition when `lsp` can resolve it semantically. Never shell out to prettier/rustfmt/gofmt when `lsp format` is available. After editing code, use `lsp diagnostics` to verify no type errors were introduced. -**Codebase exploration:** Prefer in-process SF tools first: `grep` for exact text search, `find`/`ls` for filesystem discovery, and `lsp` for structural navigation. These avoid shelling out and use SF's native backends where available. Use `.sf/CODEBASE.md` for durable orientation. If the `PROJECT CODE INTELLIGENCE` block says Project RAG is configured, use its MCP tools for broad hybrid semantic + BM25 code retrieval before manual file-by-file reading. Use `codebase_search` when Sift-backed hybrid retrieval is a better fit than exact search. Use `subagent` with `scout` for broad unfamiliar subsystem mapping that needs an explorer's judgment. Never read files one-by-one to "explore" — search first, then read what's relevant. +**Codebase exploration:** For conceptual, behavioral, or architectural discovery (e.g. "how does X work?", "where is Y handled?"), use `codebase_search` first. Its hybrid BM25+Vector retrieval is significantly more effective than grep for navigating unfamiliar logic. Use in-process SF tools like `grep` for exact text matches when you already have a specific identifier, and `find`/`ls` for literal filesystem discovery. Use `lsp` for structural navigation (definitions, references). Use `.sf/CODEBASE.md` for durable orientation. If the `PROJECT CODE INTELLIGENCE` block says Project RAG is configured, use its MCP tools for broad hybrid semantic + BM25 code retrieval before manual file-by-file reading. Never read files one-by-one to "explore" — search first, then read what's relevant. **Swarm dispatch:** Let the system decide whether swarming fits before dispatching multiple execution subagents. Use a 2-3 worker same-model swarm only when the work splits into independent shards with explicit file/directory ownership, shard-local verification, low conflict risk, and clear wall-clock savings. Do not swarm shared-interface edits, lockfiles, migrations, single-failure debugging, or sequence-dependent work. The parent agent remains coordinator: assign ownership, synthesize results, inspect dirty files, resolve conflicts, and run final verification. diff --git a/src/resources/extensions/sf/repo-identity.d.ts b/src/resources/extensions/sf/repo-identity.d.ts deleted file mode 100644 index c00dd0461..000000000 --- a/src/resources/extensions/sf/repo-identity.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -export function readRepoMeta(externalPath: string): Record; -export function isInheritedRepo(basePath?: string): boolean; -export function validateProjectId(id: string): boolean; -export function repoIdentity(basePath?: string): Record; -export function externalSfRoot(basePath?: string): string | null; -export function externalProjectsRoot(): string; -export function cleanNumberedSfVariants(projectPath: string): string; -export function hasExternalProjectState(externalPath: string): boolean; -export function ensureSfSymlink(projectPath: string): string; -export function isInsideWorktree(cwd: string): boolean; diff --git a/src/resources/extensions/sf/trace-collector.d.ts b/src/resources/extensions/sf/trace-collector.d.ts deleted file mode 100644 index 7aa53367e..000000000 --- a/src/resources/extensions/sf/trace-collector.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -export interface Span { - id: string; - name: string; - startTime: number; - endTime?: number; - attributes: Record; - children: Span[]; -} - -export interface Trace { - id: string; - rootSpan: Span; - startTime: number; - endTime?: number; - attributes: Record; -} - -export function isTraceEnabled(): boolean; -export function initTraceCollector(projectRoot: string, sessionId: string | null | undefined, command: string, model: string | null): Trace | null; -export function flushTrace(projectRoot: string): void; -export function getActiveTrace(): Trace | null; -export function startUnitSpan(unitType: string, unitId: string, attributes?: Record): Span | null; -export function startToolSpan(parentSpan: Span, toolName: string, toolCallId: string, attributes?: Record): Span; -export function completeSpan(span: Span, status?: string): void; -export function traceEvent(span: Span, name: string, attrs: Record): void; -export function traceError(span: Span, message: string, stack?: string): void; -export function findTraceSpan(id: string): Span | null; -export function setTraceCost(inputTokens: number, outputTokens: number, cacheReadTokens: number, cacheWriteTokens: number, costUsd: number): void; -export function setTraceExitCode(code: number): void; diff --git a/src/resources/extensions/sf/types.d.ts b/src/resources/extensions/sf/types.d.ts deleted file mode 100644 index dbd0d1c65..000000000 --- a/src/resources/extensions/sf/types.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -export interface MilestoneRef { - id: string; - title?: string; -} - -export interface SFState { - milestones: unknown[]; - slices: unknown[]; - tasks: unknown[]; - activeMilestone?: MilestoneRef; - lastCompletedMilestone?: MilestoneRef; - activeSlice?: MilestoneRef; - activeTask?: MilestoneRef; - phase?: string; - nextAction?: string; -}