use crate::{FileType, parsers::frontmatter::split_frontmatter};
use serde::Deserialize;
use std::sync::OnceLock;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum CompletionKind {
Key,
Value,
Snippet,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompletionCandidate {
pub label: String,
pub insert_text: String,
pub detail: Option<String>,
pub documentation: Option<String>,
pub kind: CompletionKind,
pub rule_links: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HoverDoc {
pub markdown: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct AuthoringCatalog {
#[serde(default)]
families: Vec<AuthoringFamily>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct AuthoringFamily {
id: String,
#[serde(default)]
keys: Vec<AuthoringKey>,
#[serde(default)]
snippets: Vec<AuthoringSnippet>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct AuthoringKey {
key: String,
#[serde(default)]
docs: String,
#[serde(default)]
rules: Vec<String>,
#[serde(default)]
values: Vec<String>,
#[serde(default)]
snippet: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct AuthoringSnippet {
label: String,
body: String,
#[serde(default)]
docs: Option<String>,
#[serde(default)]
rules: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum CursorContext {
Key,
Value(String),
Body,
}
fn catalog() -> &'static AuthoringCatalog {
static CATALOG: OnceLock<AuthoringCatalog> = OnceLock::new();
CATALOG.get_or_init(|| {
serde_json::from_str::<AuthoringCatalog>(agnix_rules::authoring_catalog_json())
.unwrap_or_default()
})
}
fn family_id_for_file_type(file_type: FileType) -> Option<&'static str> {
match file_type {
FileType::Skill => Some("skill"),
FileType::Agent | FileType::KiroAgent => Some("agent"),
FileType::Hooks | FileType::KiroHook => Some("hooks"),
FileType::Plugin => Some("plugin"),
FileType::Mcp | FileType::KiroMcp => Some("mcp"),
FileType::Copilot | FileType::CopilotScoped => Some("copilot"),
FileType::CursorRule | FileType::CursorRulesLegacy => Some("cursor"),
FileType::CursorHooks | FileType::CursorAgent | FileType::CursorEnvironment => None,
FileType::ClaudeMd => Some("claude-agents"),
FileType::ClaudeRule => None, FileType::GeminiMd => None, _ => None,
}
}
fn family_for_file_type(file_type: FileType) -> Option<&'static AuthoringFamily> {
let family_id = family_id_for_file_type(file_type)?;
catalog()
.families
.iter()
.find(|family| family.id == family_id)
}
fn is_yaml_family(file_type: FileType) -> bool {
matches!(
file_type,
FileType::Skill
| FileType::Agent
| FileType::CopilotScoped
| FileType::CursorRule
| FileType::CursorAgent
| FileType::ClaudeRule
)
}
fn is_json_family(file_type: FileType) -> bool {
matches!(
file_type,
FileType::Hooks
| FileType::KiroHook
| FileType::Plugin
| FileType::Mcp
| FileType::KiroMcp
| FileType::KiroAgent
)
}
fn line_bounds_at(content: &str, cursor_byte: usize) -> (usize, usize) {
let clamped = cursor_byte.min(content.len());
let line_start = content[..clamped]
.rfind('\n')
.map(|idx| idx + 1)
.unwrap_or(0);
let line_end = content[clamped..]
.find('\n')
.map(|idx| clamped + idx)
.unwrap_or(content.len());
(line_start, line_end)
}
fn parse_context_from_line(line: &str, cursor_col: usize) -> CursorContext {
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
return CursorContext::Key;
}
if let Some(colon_idx) = trimmed.find(':') {
let key_raw = trimmed[..colon_idx].trim();
let key = key_raw.trim_matches('"').trim_matches('\'');
let leading_ws = line.len().saturating_sub(trimmed.len());
let key_end_col = leading_ws + colon_idx;
if key.is_empty() || cursor_col <= key_end_col {
CursorContext::Key
} else {
CursorContext::Value(key.to_string())
}
} else {
CursorContext::Key
}
}
fn detect_cursor_context(file_type: FileType, content: &str, cursor_byte: usize) -> CursorContext {
if content.is_empty() {
return CursorContext::Key;
}
if is_yaml_family(file_type) {
let parts = split_frontmatter(content);
let within_frontmatter = parts.has_frontmatter
&& parts.has_closing
&& cursor_byte >= parts.frontmatter_start
&& cursor_byte < parts.body_start;
if !within_frontmatter {
return CursorContext::Body;
}
} else if matches!(file_type, FileType::ClaudeMd) {
return CursorContext::Body;
}
let (line_start, line_end) = line_bounds_at(content, cursor_byte);
let line = &content[line_start..line_end];
let cursor_col = cursor_byte.saturating_sub(line_start);
parse_context_from_line(line, cursor_col)
}
fn key_insert_text(file_type: FileType, key: &str) -> String {
if is_json_family(file_type) {
format!("\"{}\": ", key)
} else {
format!("{}: ", key)
}
}
fn value_insert_text(file_type: FileType, value: &str) -> String {
if !is_json_family(file_type) {
return value.to_string();
}
let looks_structured =
value.starts_with('{') || value.starts_with('[') || value == "true" || value == "false";
let looks_numeric = !value.is_empty() && value.chars().all(|ch| ch.is_ascii_digit());
if looks_structured || looks_numeric {
value.to_string()
} else {
format!("\"{}\"", value)
}
}
fn dedupe_candidates(candidates: &mut Vec<CompletionCandidate>) {
let mut seen = std::collections::HashSet::new();
candidates.retain(|candidate| {
let key = (candidate.kind.clone(), candidate.label.clone());
seen.insert(key)
});
}
pub fn completion_candidates(
file_type: FileType,
content: &str,
cursor_byte: usize,
) -> Vec<CompletionCandidate> {
let Some(family) = family_for_file_type(file_type) else {
return Vec::new();
};
let cursor = cursor_byte.min(content.len());
let context = detect_cursor_context(file_type, content, cursor);
let mut out = Vec::new();
match context {
CursorContext::Key => {
for key in &family.keys {
out.push(CompletionCandidate {
label: key.key.clone(),
insert_text: key_insert_text(file_type, &key.key),
detail: Some("Field".to_string()),
documentation: Some(key.docs.clone()),
kind: CompletionKind::Key,
rule_links: key.rules.clone(),
});
}
for snippet in &family.snippets {
out.push(CompletionCandidate {
label: snippet.label.clone(),
insert_text: snippet.body.clone(),
detail: Some("Snippet".to_string()),
documentation: snippet.docs.clone(),
kind: CompletionKind::Snippet,
rule_links: snippet.rules.clone(),
});
}
}
CursorContext::Value(key_name) => {
if let Some(key) = family.keys.iter().find(|key| key.key == key_name) {
for value in &key.values {
out.push(CompletionCandidate {
label: value.clone(),
insert_text: value_insert_text(file_type, value),
detail: Some("Allowed value".to_string()),
documentation: Some(key.docs.clone()),
kind: CompletionKind::Value,
rule_links: key.rules.clone(),
});
}
if let Some(snippet) = &key.snippet {
out.push(CompletionCandidate {
label: format!("{} example", key.key),
insert_text: snippet.clone(),
detail: Some("Snippet".to_string()),
documentation: Some(key.docs.clone()),
kind: CompletionKind::Snippet,
rule_links: key.rules.clone(),
});
}
}
}
CursorContext::Body => {
for snippet in &family.snippets {
out.push(CompletionCandidate {
label: snippet.label.clone(),
insert_text: snippet.body.clone(),
detail: Some("Snippet".to_string()),
documentation: snippet.docs.clone(),
kind: CompletionKind::Snippet,
rule_links: snippet.rules.clone(),
});
}
}
}
dedupe_candidates(&mut out);
out
}
pub fn hover_doc(file_type: FileType, key: &str) -> Option<HoverDoc> {
let family = family_for_file_type(file_type)?;
let entry = family.keys.iter().find(|entry| entry.key == key)?;
let rules = if entry.rules.is_empty() {
String::new()
} else {
format!("\n\nRules: {}", entry.rules.join(", "))
};
Some(HoverDoc {
markdown: format!("**{}**\n\n{}{}", entry.key, entry.docs, rules),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_catalog_contains_core_families() {
let family_ids: Vec<&str> = catalog().families.iter().map(|f| f.id.as_str()).collect();
assert!(family_ids.contains(&"skill"));
assert!(family_ids.contains(&"agent"));
assert!(family_ids.contains(&"mcp"));
}
#[test]
fn test_completion_skill_key_context() {
let content = "---\nna\n---\n";
let byte = content.find("na").expect("test content must contain 'na'");
let candidates = completion_candidates(FileType::Skill, content, byte);
assert!(candidates.iter().any(|c| c.label == "name"));
assert!(
candidates
.iter()
.any(|c| c.kind == CompletionKind::Snippet && c.label == "SKILL frontmatter")
);
}
#[test]
fn test_completion_skill_value_context() {
let content = "---\nmodel: \n---\n";
let byte = content
.find("model: ")
.expect("test content must contain 'model: '")
+ "model: ".len();
let candidates = completion_candidates(FileType::Skill, content, byte);
assert!(candidates.iter().any(|c| c.label == "sonnet"));
assert!(candidates.iter().any(|c| c.label == "opus"));
}
#[test]
fn test_completion_mcp_json_value_context_quotes_strings() {
let content = "{\n \"jsonrpc\": \n}";
let byte = content
.find("\"jsonrpc\": ")
.expect("test content must contain '\"jsonrpc\": '")
+ "\"jsonrpc\": ".len();
let candidates = completion_candidates(FileType::Mcp, content, byte);
let version = candidates
.iter()
.find(|c| c.label == "2.0")
.expect("2.0 completion should exist");
assert_eq!(version.insert_text, "\"2.0\"");
}
#[test]
fn test_completion_claude_md_body_snippets() {
let content = "# Instructions\n";
let candidates = completion_candidates(FileType::ClaudeMd, content, content.len());
assert!(
candidates
.iter()
.any(|c| c.label == "Project Context section")
);
}
#[test]
fn test_hover_doc_for_skill_model() {
let hover = hover_doc(FileType::Skill, "model");
assert!(hover.is_some());
let markdown = hover.unwrap().markdown;
assert!(markdown.contains("model"));
assert!(markdown.contains("CC-SK-001"));
}
#[test]
fn test_completion_agent_key_context() {
let content = "---\nmod\n---\n";
let byte = content.find("mod").unwrap();
let candidates = completion_candidates(FileType::Agent, content, byte);
assert!(
candidates.iter().any(|c| c.label == "model"),
"Agent key completions should include 'model', got: {:?}",
candidates.iter().map(|c| &c.label).collect::<Vec<_>>()
);
}
#[test]
fn test_completion_agent_value_context() {
let content = "---\nmodel: \n---\n";
let byte = content
.find("model: ")
.expect("test content must contain 'model: '")
+ "model: ".len();
let candidates = completion_candidates(FileType::Agent, content, byte);
assert!(
candidates.iter().any(|c| c.label == "sonnet"),
"Agent model values should include 'sonnet', got: {:?}",
candidates.iter().map(|c| &c.label).collect::<Vec<_>>()
);
assert!(
candidates.iter().any(|c| c.label == "opus"),
"Agent model values should include 'opus'"
);
}
#[test]
fn test_completion_kiro_agent_value_context() {
let content = "{\n \"model\": \n}";
let byte = content
.find("\"model\": ")
.expect("test content must contain '\"model\": '")
+ "\"model\": ".len();
let candidates = completion_candidates(FileType::KiroAgent, content, byte);
let sonnet = candidates
.iter()
.find(|c| c.label == "sonnet")
.expect("KiroAgent model values should include 'sonnet'");
let opus = candidates
.iter()
.find(|c| c.label == "opus")
.expect("KiroAgent model values should include 'opus'");
assert_eq!(
sonnet.insert_text, "\"sonnet\"",
"KiroAgent value insert text should be JSON-quoted"
);
assert_eq!(
opus.insert_text, "\"opus\"",
"KiroAgent value insert text should be JSON-quoted"
);
}
#[test]
fn test_completion_kiro_agent_key_context_uses_json_key_insert() {
let content = "{\n \"mod\n}";
let byte = content.find("\"mod").unwrap() + 1;
let candidates = completion_candidates(FileType::KiroAgent, content, byte);
let model = candidates
.iter()
.find(|c| c.label == "model")
.expect("KiroAgent key completions should include 'model'");
assert_eq!(
model.insert_text, "\"model\": ",
"KiroAgent key insert text should use JSON syntax"
);
}
#[test]
fn test_completion_hooks_key_context() {
let content = "{\n \"mat\n}";
let byte = content.find("\"mat").unwrap() + 1; let candidates = completion_candidates(FileType::Hooks, content, byte);
assert!(
candidates.iter().any(|c| c.label == "matcher"),
"Hooks key completions should include 'matcher', got: {:?}",
candidates.iter().map(|c| &c.label).collect::<Vec<_>>()
);
}
#[test]
fn test_completion_hooks_value_context() {
let content = "{\n \"type\": \n}";
let byte = content.find("\"type\": ").unwrap() + "\"type\": ".len();
let candidates = completion_candidates(FileType::Hooks, content, byte);
assert!(
candidates.iter().any(|c| c.label == "command"),
"Hooks type values should include 'command', got: {:?}",
candidates.iter().map(|c| &c.label).collect::<Vec<_>>()
);
}
#[test]
fn test_completion_kiro_hooks_value_context() {
let content = "{\n \"type\": \n}";
let byte = content.find("\"type\": ").unwrap() + "\"type\": ".len();
let candidates = completion_candidates(FileType::KiroHook, content, byte);
assert!(
candidates.iter().any(|c| c.label == "command"),
"KiroHook type values should include 'command', got: {:?}",
candidates.iter().map(|c| &c.label).collect::<Vec<_>>()
);
}
#[test]
fn test_completion_mcp_key_context() {
let content = "{\n \"json\n}";
let byte = content.find("\"json").unwrap() + 1;
let candidates = completion_candidates(FileType::Mcp, content, byte);
assert!(
candidates.iter().any(|c| c.label == "jsonrpc"),
"MCP key completions should include 'jsonrpc', got: {:?}",
candidates.iter().map(|c| &c.label).collect::<Vec<_>>()
);
}
#[test]
fn test_completion_kiro_mcp_key_context() {
let content = "{\n \"json\n}";
let byte = content.find("\"json").unwrap() + 1;
let candidates = completion_candidates(FileType::KiroMcp, content, byte);
assert!(
candidates.iter().any(|c| c.label == "jsonrpc"),
"Kiro MCP key completions should include 'jsonrpc', got: {:?}",
candidates.iter().map(|c| &c.label).collect::<Vec<_>>()
);
}
#[test]
fn test_completion_copilot_scoped_key_context() {
let content = "---\napp\n---\n";
let byte = content.find("app").unwrap();
let candidates = completion_candidates(FileType::CopilotScoped, content, byte);
assert!(
candidates.iter().any(|c| c.label == "applyTo"),
"Copilot scoped key completions should include 'applyTo', got: {:?}",
candidates.iter().map(|c| &c.label).collect::<Vec<_>>()
);
}
#[test]
fn test_completion_cursor_rule_key_context() {
let content = "---\ndesc\n---\n";
let byte = content.find("desc").unwrap();
let candidates = completion_candidates(FileType::CursorRule, content, byte);
assert!(
candidates.iter().any(|c| c.label == "description"),
"Cursor rule key completions should include 'description', got: {:?}",
candidates.iter().map(|c| &c.label).collect::<Vec<_>>()
);
}
#[test]
fn test_cursor_auxiliary_file_types_have_no_authoring_catalog_entries() {
let content = "---\nname\n---\n";
let cursor_byte = content.find("name").unwrap();
assert!(
completion_candidates(FileType::CursorHooks, content, cursor_byte).is_empty(),
"CursorHooks should not expose catalog completions",
);
assert!(
completion_candidates(FileType::CursorAgent, content, cursor_byte).is_empty(),
"CursorAgent should not expose catalog completions",
);
assert!(
completion_candidates(FileType::CursorEnvironment, content, cursor_byte).is_empty(),
"CursorEnvironment should not expose catalog completions",
);
assert!(
hover_doc(FileType::CursorHooks, "version").is_none(),
"CursorHooks should not expose catalog hover docs",
);
assert!(
hover_doc(FileType::CursorAgent, "name").is_none(),
"CursorAgent should not expose catalog hover docs",
);
assert!(
hover_doc(FileType::CursorEnvironment, "snapshot").is_none(),
"CursorEnvironment should not expose catalog hover docs",
);
}
#[test]
fn test_hover_doc_for_agent_model() {
let hover = hover_doc(FileType::Agent, "model");
assert!(hover.is_some(), "Agent should have hover for 'model'");
let markdown = hover.unwrap().markdown;
assert!(markdown.contains("model"));
assert!(markdown.contains("CC-AG-003"));
}
#[test]
fn test_hover_doc_for_hooks_type() {
let hover = hover_doc(FileType::Hooks, "type");
assert!(hover.is_some(), "Hooks should have hover for 'type'");
let markdown = hover.unwrap().markdown;
assert!(markdown.contains("type"));
assert!(markdown.contains("CC-HK-005"));
}
#[test]
fn test_hover_doc_for_mcp_jsonrpc() {
let hover = hover_doc(FileType::Mcp, "jsonrpc");
assert!(hover.is_some(), "MCP should have hover for 'jsonrpc'");
let markdown = hover.unwrap().markdown;
assert!(markdown.contains("jsonrpc"));
assert!(markdown.contains("MCP-001"));
}
#[test]
fn test_hover_doc_for_kiro_mcp_jsonrpc() {
let hover = hover_doc(FileType::KiroMcp, "jsonrpc");
assert!(hover.is_some(), "KiroMcp should have hover for 'jsonrpc'");
let markdown = hover.unwrap().markdown;
assert!(markdown.contains("jsonrpc"));
assert!(markdown.contains("MCP-001"));
}
#[test]
fn test_hover_doc_for_copilot_applyto() {
let hover = hover_doc(FileType::CopilotScoped, "applyTo");
assert!(
hover.is_some(),
"CopilotScoped should have hover for 'applyTo'"
);
let markdown = hover.unwrap().markdown;
assert!(markdown.contains("applyTo"));
}
#[test]
fn test_hover_doc_for_cursor_description() {
let hover = hover_doc(FileType::CursorRule, "description");
assert!(
hover.is_some(),
"CursorRule should have hover for 'description'"
);
let markdown = hover.unwrap().markdown;
assert!(markdown.contains("description"));
assert!(markdown.contains("CUR-003"));
}
#[test]
fn test_invalid_partial_content_falls_back_without_panic() {
let content = "---\nmodel\n";
let candidates = completion_candidates(FileType::Skill, content, content.len());
assert!(
!candidates.is_empty(),
"partial/invalid content should still return fallback completions"
);
}
}