#[cfg(feature = "lsp")]
use tower_lsp_server::ls_types::*;
#[cfg(feature = "lsp")]
pub use tower_lsp_server::ls_types::Uri;
#[cfg(feature = "lsp")]
use super::super::ast_index::AstIndex;
#[cfg(feature = "lsp")]
use super::super::utils::extract_task_ids;
#[cfg(feature = "lsp")]
#[derive(Debug, Clone, PartialEq)]
pub enum CompletionContext {
TopLevel,
TaskField,
VerbValue(String),
WithBinding,
McpServer,
Template,
Unknown,
}
#[cfg(feature = "lsp")]
pub fn compute_completions_with_ast(
ast_index: &AstIndex,
uri: &Uri,
text: &str,
position: Position,
) -> Vec<CompletionItem> {
let context = analyze_completion_context(text, position);
match context {
CompletionContext::TopLevel => top_level_completions(),
CompletionContext::TaskField => task_field_completions(),
CompletionContext::VerbValue(verb) => {
verb_value_completions_with_ast(&verb, ast_index, uri)
}
CompletionContext::WithBinding => binding_completions_with_ast(ast_index, uri, text),
CompletionContext::McpServer => mcp_server_completions(),
CompletionContext::Template => template_completions_with_ast(ast_index, uri, text),
CompletionContext::Unknown => vec![],
}
}
#[cfg(feature = "lsp")]
pub fn compute_completions(text: &str, position: Position) -> Vec<CompletionItem> {
let context = analyze_completion_context(text, position);
match context {
CompletionContext::TopLevel => top_level_completions(),
CompletionContext::TaskField => task_field_completions(),
CompletionContext::VerbValue(verb) => verb_value_completions(&verb),
CompletionContext::WithBinding => binding_completions(text),
CompletionContext::McpServer => mcp_server_completions(),
CompletionContext::Template => template_completions(text),
CompletionContext::Unknown => vec![],
}
}
#[cfg(feature = "lsp")]
fn analyze_completion_context(text: &str, position: Position) -> CompletionContext {
let lines: Vec<&str> = text.lines().collect();
if position.line as usize >= lines.len() {
return CompletionContext::TopLevel;
}
let current_line = lines[position.line as usize];
let char_pos = (position.character as usize).min(current_line.len());
let prefix_end = current_line
.char_indices()
.take_while(|(i, _)| *i < char_pos)
.last()
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(0);
let prefix = ¤t_line[..prefix_end];
if prefix.contains("{{") && !prefix.contains("}}") {
return CompletionContext::Template;
}
let indent = current_line.len() - current_line.trim_start().len();
if indent == 0 {
return CompletionContext::TopLevel;
}
for i in (0..position.line as usize).rev() {
let line = lines[i].trim();
if line.starts_with("- id:") || line.starts_with("-id:") {
let task_indent = lines[i].len() - lines[i].trim_start().len();
if indent > task_indent {
if prefix.trim().starts_with("with") {
return CompletionContext::WithBinding;
}
if line_contains_verb(prefix) {
return CompletionContext::VerbValue(extract_verb(prefix));
}
return CompletionContext::TaskField;
}
}
if line == "mcp:" {
return CompletionContext::McpServer;
}
if line == "tasks:" && indent == 2 && prefix.trim().starts_with('-') {
return CompletionContext::TaskField;
}
}
CompletionContext::Unknown
}
#[cfg(feature = "lsp")]
fn line_contains_verb(line: &str) -> bool {
let trimmed = line.trim();
trimmed.starts_with("infer:")
|| trimmed.starts_with("exec:")
|| trimmed.starts_with("fetch:")
|| trimmed.starts_with("invoke:")
|| trimmed.starts_with("agent:")
}
#[cfg(feature = "lsp")]
fn extract_verb(line: &str) -> String {
let trimmed = line.trim();
for verb in ["infer", "exec", "fetch", "invoke", "agent"] {
if trimmed.starts_with(&format!("{}:", verb)) {
return verb.to_string();
}
}
String::new()
}
#[cfg(feature = "lsp")]
fn top_level_completions() -> Vec<CompletionItem> {
vec![
CompletionItem {
label: "schema".to_string(),
kind: Some(CompletionItemKind::KEYWORD),
insert_text: Some("schema: nika/workflow@0.12".to_string()),
documentation: Some(Documentation::String(
"Required. Schema version for this workflow.".to_string(),
)),
..Default::default()
},
CompletionItem {
label: "workflow".to_string(),
kind: Some(CompletionItemKind::KEYWORD),
insert_text: Some("workflow: ${1:workflow-name}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Optional. Workflow name/identifier.".to_string(),
)),
..Default::default()
},
CompletionItem {
label: "tasks".to_string(),
kind: Some(CompletionItemKind::KEYWORD),
insert_text: Some(
"tasks:\n - id: ${1:task-id}\n ${2:infer}: ${3:prompt}".to_string(),
),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Required. List of tasks to execute.".to_string(),
)),
..Default::default()
},
CompletionItem {
label: "mcp".to_string(),
kind: Some(CompletionItemKind::KEYWORD),
insert_text: Some(
"mcp:\n ${1:server-name}:\n command: ${2:command}\n args: [${3}]"
.to_string(),
),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Optional. MCP server configurations.".to_string(),
)),
..Default::default()
},
CompletionItem {
label: "context".to_string(),
kind: Some(CompletionItemKind::KEYWORD),
insert_text: Some(
"context:\n files:\n ${1:alias}: ${2:./path/to/file}".to_string(),
),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Optional. Load files at workflow start.".to_string(),
)),
..Default::default()
},
CompletionItem {
label: "include".to_string(),
kind: Some(CompletionItemKind::KEYWORD),
insert_text: Some(
"include:\n - path: ${1:./partial.nika.yaml}\n prefix: ${2:partial_}"
.to_string(),
),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Optional. Include tasks from other workflows.".to_string(),
)),
..Default::default()
},
]
}
#[cfg(feature = "lsp")]
fn task_field_completions() -> Vec<CompletionItem> {
let mut items = vec![
CompletionItem {
label: "id".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("id: ${1:task-id}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Required. Unique task identifier.".to_string(),
)),
..Default::default()
},
CompletionItem {
label: "with".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("with:\n ${1:alias}: ${2:task-id}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Bind outputs from previous tasks.".to_string(),
)),
..Default::default()
},
CompletionItem {
label: "for_each".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("for_each: [${1}]\nas: ${2:item}\nconcurrency: ${3:3}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Parallel iteration over an array.".to_string(),
)),
..Default::default()
},
CompletionItem {
label: "retry".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("retry:\n max_attempts: ${1:3}\n delay: ${2:1s}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Retry configuration for failed tasks.".to_string(),
)),
..Default::default()
},
CompletionItem {
label: "timeout".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("timeout: ${1:30s}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Maximum execution time for this task.".to_string(),
)),
..Default::default()
},
];
items.push(CompletionItem {
label: "provider".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some(
"provider: ${1|claude,openai,mistral,groq,deepseek,gemini,xai|}".to_string(),
),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Override LLM provider for this task.".to_string(),
)),
..Default::default()
});
items.push(CompletionItem {
label: "model".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("model: ${1}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Override model for this task.".to_string(),
)),
..Default::default()
});
items.push(CompletionItem {
label: "depends_on".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("depends_on: [${1}]".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Task IDs that must complete before this task runs.".to_string(),
)),
..Default::default()
});
items.push(CompletionItem {
label: "description".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("description: \"${1}\"".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
..Default::default()
});
items.push(CompletionItem {
label: "artifact".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some(
"artifact:\n path: ${1:output.txt}\n format: ${2|text,json|}".to_string(),
),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Persist task output to a file.".to_string(),
)),
..Default::default()
});
items.push(CompletionItem {
label: "log".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("log:\n level: ${1|debug,info,warn|}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Task-level log configuration override.".to_string(),
)),
..Default::default()
});
items.push(CompletionItem {
label: "structured".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("structured:\n schema:\n type: object\n properties:\n ${1:field}:\n type: ${2:string}\n required: [${1:field}]".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Enforce JSON schema on task output.".to_string(),
)),
..Default::default()
});
items.extend(verb_completions());
items
}
#[cfg(feature = "lsp")]
fn verb_completions() -> Vec<CompletionItem> {
vec![
CompletionItem {
label: "infer".to_string(),
kind: Some(CompletionItemKind::KEYWORD),
insert_text: Some("infer:\n prompt: ${1:your prompt here}\n ${0}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"LLM text generation. Shorthand accepts a string.".to_string(),
)),
detail: Some("Verb".to_string()),
..Default::default()
},
CompletionItem {
label: "exec".to_string(),
kind: Some(CompletionItemKind::KEYWORD),
insert_text: Some("exec: ${1:command}\n${0}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Shell command execution. Defaults to shell: false for security.".to_string(),
)),
detail: Some("Verb".to_string()),
..Default::default()
},
CompletionItem {
label: "fetch".to_string(),
kind: Some(CompletionItemKind::KEYWORD),
insert_text: Some("fetch:\n url: ${1:https://}\n ${0}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String("HTTP request.".to_string())),
detail: Some("Verb".to_string()),
..Default::default()
},
CompletionItem {
label: "invoke".to_string(),
kind: Some(CompletionItemKind::KEYWORD),
insert_text: Some(
"invoke:\n tool: ${1:nika:tool}\n params:\n ${2:key}: ${3:value}\n${0}"
.to_string(),
),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String("MCP tool invocation.".to_string())),
detail: Some("Verb".to_string()),
..Default::default()
},
CompletionItem {
label: "agent".to_string(),
kind: Some(CompletionItemKind::KEYWORD),
insert_text: Some(
"agent:\n prompt: ${1:agent goal}\n mcp: [${2}]\n max_turns: ${3:10}\n${0}"
.to_string(),
),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Multi-turn agentic loop with tool calling.".to_string(),
)),
detail: Some("Verb".to_string()),
..Default::default()
},
]
}
#[cfg(feature = "lsp")]
fn verb_value_completions(verb: &str) -> Vec<CompletionItem> {
match verb {
"infer" => vec![
CompletionItem {
label: "prompt".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("prompt: ${1}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
..Default::default()
},
CompletionItem {
label: "model".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("model: ${1:claude-sonnet-4-6}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
..Default::default()
},
CompletionItem {
label: "temperature".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("temperature: ${1:0.7}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
..Default::default()
},
CompletionItem {
label: "system".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("system: ${1:You are a helpful assistant.}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
..Default::default()
},
CompletionItem {
label: "max_tokens".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("max_tokens: ${1:1000}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
..Default::default()
},
],
"exec" => vec![
CompletionItem {
label: "command".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("command: ${1}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
..Default::default()
},
CompletionItem {
label: "shell".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("shell: ${1|true,false|}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Enable shell mode for pipes/redirects. Default: false (secure).".to_string(),
)),
..Default::default()
},
],
"fetch" => vec![
CompletionItem {
label: "url".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("url: ${1:https://}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String("Required. Request URL.".to_string())),
..Default::default()
},
CompletionItem {
label: "method".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("method: ${1|GET,POST,PUT,DELETE,PATCH|}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"HTTP method. Default: GET.".to_string(),
)),
..Default::default()
},
CompletionItem {
label: "headers".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some(
"headers:\n ${1:Content-Type}: ${2:application/json}".to_string(),
),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String("HTTP request headers.".to_string())),
..Default::default()
},
CompletionItem {
label: "body".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("body: ${1}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Request body (string or object).".to_string(),
)),
..Default::default()
},
CompletionItem {
label: "retry".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("retry:\n max_attempts: ${1:3}\n delay: ${2:1s}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Retry configuration for failed requests.".to_string(),
)),
..Default::default()
},
],
"invoke" => vec![
CompletionItem {
label: "mcp".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("mcp: ${1:server}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Required. MCP server name (from mcp: block).".to_string(),
)),
..Default::default()
},
CompletionItem {
label: "tool".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("tool: ${1:tool-name}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Required. MCP tool name to invoke.".to_string(),
)),
..Default::default()
},
CompletionItem {
label: "params".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("params:\n ${1:key}: ${2:value}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Tool parameters (key-value pairs).".to_string(),
)),
..Default::default()
},
CompletionItem {
label: "resource".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("resource: ${1:resource-uri}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"MCP resource URI to read (alternative to tool).".to_string(),
)),
..Default::default()
},
],
"agent" => vec![
CompletionItem {
label: "prompt".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("prompt: ${1}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
..Default::default()
},
CompletionItem {
label: "mcp".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("mcp: [${1}]".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
..Default::default()
},
CompletionItem {
label: "max_turns".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("max_turns: ${1:10}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
..Default::default()
},
CompletionItem {
label: "depth_limit".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("depth_limit: ${1:3}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Max spawn_agent recursion depth. Default: 3.".to_string(),
)),
..Default::default()
},
CompletionItem {
label: "extended_thinking".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some(
"extended_thinking: true\nthinking_budget: ${1:8192}".to_string(),
),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Enable Claude's extended thinking mode.".to_string(),
)),
..Default::default()
},
CompletionItem {
label: "provider".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some(
"provider: ${1|claude,openai,mistral,groq,deepseek,gemini,xai|}".to_string(),
),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"LLM provider override for this agent.".to_string(),
)),
..Default::default()
},
CompletionItem {
label: "model".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("model: ${1}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Model override (e.g., gpt-4.1-mini, grok-3-fast).".to_string(),
)),
..Default::default()
},
CompletionItem {
label: "system".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("system: |\n ${1}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"System prompt defining the agent's persona.".to_string(),
)),
..Default::default()
},
CompletionItem {
label: "tools".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some(
"tools: [${1|builtin,nika:read,nika:write,nika:edit|}]".to_string(),
),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Builtin tools: [builtin] for all, or specific nika:* tools.".to_string(),
)),
..Default::default()
},
],
_ => vec![],
}
}
#[cfg(feature = "lsp")]
fn binding_completions(text: &str) -> Vec<CompletionItem> {
let task_ids = extract_task_ids(text);
task_ids
.into_iter()
.map(|id| CompletionItem {
label: id.clone(),
kind: Some(CompletionItemKind::REFERENCE),
insert_text: Some(id.clone()),
documentation: Some(Documentation::String(format!(
"Reference output from task '{}'",
id
))),
..Default::default()
})
.collect()
}
#[cfg(feature = "lsp")]
fn mcp_server_completions() -> Vec<CompletionItem> {
vec![
CompletionItem {
label: "command".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("command: ${1:npx}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Command to start the MCP server.".to_string(),
)),
..Default::default()
},
CompletionItem {
label: "args".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("args: [${1}]".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Arguments to pass to the command.".to_string(),
)),
..Default::default()
},
CompletionItem {
label: "env".to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some("env:\n ${1:KEY}: ${2:value}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Environment variables for the MCP server.".to_string(),
)),
..Default::default()
},
]
}
#[cfg(feature = "lsp")]
fn template_completions(text: &str) -> Vec<CompletionItem> {
let mut items = vec![
CompletionItem {
label: "with.".to_string(),
kind: Some(CompletionItemKind::VARIABLE),
insert_text: Some("with.${1:alias}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Reference bound task output.".to_string(),
)),
..Default::default()
},
CompletionItem {
label: "context.files.".to_string(),
kind: Some(CompletionItemKind::VARIABLE),
insert_text: Some("context.files.${1:alias}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Reference loaded context file.".to_string(),
)),
..Default::default()
},
CompletionItem {
label: "inputs.".to_string(),
kind: Some(CompletionItemKind::VARIABLE),
insert_text: Some("inputs.${1:name}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Reference workflow input parameter.".to_string(),
)),
..Default::default()
},
];
for id in extract_task_ids(text) {
items.push(CompletionItem {
label: format!("${}", id),
kind: Some(CompletionItemKind::REFERENCE),
insert_text: Some(format!("${}", id)),
documentation: Some(Documentation::String(format!(
"Implicit output from task '{}' (shorthand)",
id
))),
..Default::default()
});
}
items
}
#[cfg(feature = "lsp")]
fn verb_value_completions_with_ast(
verb: &str,
ast_index: &AstIndex,
uri: &Uri,
) -> Vec<CompletionItem> {
let mut items = verb_value_completions(verb);
if verb == "invoke" || verb == "agent" {
let servers = ast_index.get_mcp_server_names(uri);
for server in servers {
items.push(CompletionItem {
label: server.clone(),
kind: Some(CompletionItemKind::MODULE),
insert_text: Some(server.clone()),
documentation: Some(Documentation::String(format!(
"MCP server '{}' (from workflow)",
server
))),
..Default::default()
});
}
}
if verb == "infer" || verb == "agent" {
items.extend(model_catalog_completions());
}
items
}
#[cfg(feature = "lsp")]
fn model_catalog_completions() -> Vec<CompletionItem> {
use crate::lsp::model_intel::{LifecycleStatus, MODEL_CATALOG};
MODEL_CATALOG
.iter()
.map(|info| {
let deprecated_note = match &info.lifecycle {
LifecycleStatus::Deprecated { replacement, .. } => {
format!(" (deprecated, use {})", replacement)
}
LifecycleStatus::Preview => " (preview)".to_string(),
LifecycleStatus::Active => String::new(),
};
CompletionItem {
label: info.id.to_string(),
kind: Some(CompletionItemKind::VALUE),
detail: Some(format!(
"{} -- {} | {}{}",
info.display_name,
info.provider.name(),
info.tier.label(),
deprecated_note,
)),
documentation: Some(Documentation::String(info.description.to_string())),
sort_text: Some(format!("model_{}_{}", info.provider.name(), info.id,)),
deprecated: Some(matches!(info.lifecycle, LifecycleStatus::Deprecated { .. })),
..Default::default()
}
})
.collect()
}
#[cfg(feature = "lsp")]
fn binding_completions_with_ast(
ast_index: &AstIndex,
uri: &Uri,
text: &str,
) -> Vec<CompletionItem> {
let task_ids = ast_index.get_task_names(uri);
if !task_ids.is_empty() {
return task_ids
.into_iter()
.map(|id| CompletionItem {
label: id.clone(),
kind: Some(CompletionItemKind::REFERENCE),
insert_text: Some(id.clone()),
documentation: Some(Documentation::String(format!(
"Reference output from task '{}' (from AST)",
id
))),
..Default::default()
})
.collect();
}
binding_completions(text)
}
#[cfg(feature = "lsp")]
fn template_completions_with_ast(
ast_index: &AstIndex,
uri: &Uri,
text: &str,
) -> Vec<CompletionItem> {
let mut items = vec![
CompletionItem {
label: "with.".to_string(),
kind: Some(CompletionItemKind::VARIABLE),
insert_text: Some("with.${1:alias}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Reference bound task output.".to_string(),
)),
..Default::default()
},
CompletionItem {
label: "context.files.".to_string(),
kind: Some(CompletionItemKind::VARIABLE),
insert_text: Some("context.files.${1:alias}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Reference loaded context file.".to_string(),
)),
..Default::default()
},
CompletionItem {
label: "inputs.".to_string(),
kind: Some(CompletionItemKind::VARIABLE),
insert_text: Some("inputs.${1:name}".to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
documentation: Some(Documentation::String(
"Reference workflow input parameter.".to_string(),
)),
..Default::default()
},
];
let task_ids = {
let ast_task_ids = ast_index.get_task_names(uri);
if ast_task_ids.is_empty() {
extract_task_ids(text)
} else {
ast_task_ids
}
};
for id in task_ids {
items.push(CompletionItem {
label: format!("${}", id),
kind: Some(CompletionItemKind::REFERENCE),
insert_text: Some(format!("${}", id)),
documentation: Some(Documentation::String(format!(
"Implicit output from task '{}' (shorthand)",
id
))),
..Default::default()
});
}
let context_files = ast_index.get_context_file_names(uri);
for file_name in context_files {
items.push(CompletionItem {
label: format!("context.files.{}", file_name),
kind: Some(CompletionItemKind::VARIABLE),
insert_text: Some(format!("context.files.{}", file_name)),
documentation: Some(Documentation::String(format!(
"Context file '{}' (from AST)",
file_name
))),
..Default::default()
});
}
items
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(feature = "lsp")]
fn test_extract_task_ids() {
let text = r#"
tasks:
- id: step1
infer: "Hello"
- id: step2
exec: "echo hi"
"#;
let ids = extract_task_ids(text);
assert_eq!(ids, vec!["step1", "step2"]);
}
#[test]
#[cfg(feature = "lsp")]
fn test_top_level_completions() {
let items = top_level_completions();
assert!(items.iter().any(|i| i.label == "schema"));
assert!(items.iter().any(|i| i.label == "tasks"));
assert!(items.iter().any(|i| i.label == "mcp"));
}
#[test]
#[cfg(feature = "lsp")]
fn test_task_field_completions_include_verbs() {
let items = task_field_completions();
assert!(items.iter().any(|i| i.label == "infer"));
assert!(items.iter().any(|i| i.label == "exec"));
assert!(items.iter().any(|i| i.label == "agent"));
}
#[test]
#[cfg(feature = "lsp")]
fn test_analyze_context_top_level() {
let text = "";
let ctx = analyze_completion_context(
text,
Position {
line: 0,
character: 0,
},
);
assert_eq!(ctx, CompletionContext::TopLevel);
}
#[test]
#[cfg(feature = "lsp")]
fn test_binding_completions_with_ast() {
use super::super::super::ast_index::AstIndex;
let index = AstIndex::new();
let uri = "file:///test.nika.yaml".parse::<Uri>().unwrap();
let text = r#"schema: nika/workflow@0.12
workflow: test
tasks:
- id: step1
infer: "Hello"
- id: step2
exec: "echo hello"
"#;
index.parse_document(&uri, text, 1);
let items = binding_completions_with_ast(&index, &uri, text);
assert!(items.iter().any(|i| i.label == "step1"));
assert!(items.iter().any(|i| i.label == "step2"));
assert_eq!(items.len(), 2);
}
#[test]
#[cfg(feature = "lsp")]
fn test_binding_completions_with_ast_fallback() {
use super::super::super::ast_index::AstIndex;
let index = AstIndex::new();
let uri = "file:///test.nika.yaml".parse::<Uri>().unwrap();
let text = r#"
tasks:
- id: step1
infer: "Hello"
"#;
let items = binding_completions_with_ast(&index, &uri, text);
assert!(items.iter().any(|i| i.label == "step1"));
}
#[test]
#[cfg(feature = "lsp")]
fn test_template_completions_with_ast() {
use super::super::super::ast_index::AstIndex;
let index = AstIndex::new();
let uri = "file:///test.nika.yaml".parse::<Uri>().unwrap();
let text = r#"schema: nika/workflow@0.12
workflow: test
tasks:
- id: generate
infer: "Hello"
- id: process
exec: "echo"
"#;
index.parse_document(&uri, text, 1);
let items = template_completions_with_ast(&index, &uri, text);
assert!(items.iter().any(|i| i.label == "with."));
assert!(items.iter().any(|i| i.label == "context.files."));
assert!(items.iter().any(|i| i.label == "inputs."));
assert!(items.iter().any(|i| i.label == "$generate"));
assert!(items.iter().any(|i| i.label == "$process"));
}
#[test]
#[cfg(feature = "lsp")]
fn test_verb_value_completions_with_ast_agent() {
use super::super::super::ast_index::AstIndex;
let index = AstIndex::new();
let uri = "file:///test.nika.yaml".parse::<Uri>().unwrap();
let text = r#"schema: nika/workflow@0.12
workflow: test
mcp:
novanet:
command: "cargo run"
args: []
perplexity:
command: "npx"
args: ["-y", "@anthropic/mcp-server-perplexity"]
tasks:
- id: step1
agent:
prompt: "test"
mcp: [novanet]
"#;
index.parse_document(&uri, text, 1);
let items = verb_value_completions_with_ast("agent", &index, &uri);
assert!(items.iter().any(|i| i.label == "prompt"));
assert!(items.iter().any(|i| i.label == "mcp"));
assert!(items.iter().any(|i| i.label == "max_turns"));
assert!(items.len() >= 5);
}
#[test]
#[cfg(feature = "lsp")]
fn test_model_catalog_completions_not_empty() {
let items = model_catalog_completions();
assert!(!items.is_empty(), "Model catalog should have entries");
}
#[test]
#[cfg(feature = "lsp")]
fn test_model_catalog_completions_contain_known_models() {
let items = model_catalog_completions();
assert!(
items.iter().any(|i| i.label == "claude-sonnet-4-6"),
"Should contain Claude Sonnet 4"
);
assert!(
items.iter().any(|i| i.label == "gpt-4o"),
"Should contain GPT-4o"
);
assert!(
items.iter().any(|i| i.label == "gemini-2.0-flash"),
"Should contain Gemini 2.0 Flash"
);
}
#[test]
#[cfg(feature = "lsp")]
fn test_model_catalog_completions_have_provider_detail() {
let items = model_catalog_completions();
let sonnet = items
.iter()
.find(|i| i.label == "claude-sonnet-4-6")
.expect("Should have claude-sonnet-4-6");
let detail = sonnet.detail.as_ref().unwrap();
assert!(detail.contains("Claude"), "Detail should mention provider");
assert!(
detail.contains("Standard"),
"Detail should mention tier: {}",
detail
);
}
#[test]
#[cfg(feature = "lsp")]
fn test_model_catalog_completions_deprecated_flagged() {
let items = model_catalog_completions();
let gpt4_turbo = items
.iter()
.find(|i| i.label == "gpt-4-turbo")
.expect("Should have gpt-4-turbo");
assert_eq!(
gpt4_turbo.deprecated,
Some(true),
"gpt-4-turbo should be flagged as deprecated"
);
let detail = gpt4_turbo.detail.as_ref().unwrap();
assert!(
detail.contains("deprecated"),
"Detail should mention deprecation: {}",
detail
);
}
#[test]
#[cfg(feature = "lsp")]
fn test_model_catalog_completions_active_not_deprecated() {
let items = model_catalog_completions();
let gpt4o = items
.iter()
.find(|i| i.label == "gpt-4o")
.expect("Should have gpt-4o");
assert_eq!(
gpt4o.deprecated,
Some(false),
"gpt-4o should NOT be flagged as deprecated"
);
}
#[test]
#[cfg(feature = "lsp")]
fn test_verb_value_completions_with_ast_infer_includes_models() {
use super::super::super::ast_index::AstIndex;
let index = AstIndex::new();
let uri = "file:///test.nika.yaml".parse::<Uri>().unwrap();
let items = verb_value_completions_with_ast("infer", &index, &uri);
assert!(
items.iter().any(|i| i.label == "prompt"),
"Should have prompt field"
);
assert!(
items.iter().any(|i| i.label == "claude-sonnet-4-6"),
"Should have model catalog entries"
);
}
#[test]
#[cfg(feature = "lsp")]
fn test_verb_value_completions_with_ast_agent_includes_models() {
use super::super::super::ast_index::AstIndex;
let index = AstIndex::new();
let uri = "file:///test.nika.yaml".parse::<Uri>().unwrap();
let items = verb_value_completions_with_ast("agent", &index, &uri);
assert!(
items.iter().any(|i| i.label == "gpt-4o"),
"Agent completions should include model catalog"
);
}
#[test]
#[cfg(feature = "lsp")]
fn test_verb_value_completions_with_ast_exec_no_models() {
use super::super::super::ast_index::AstIndex;
let index = AstIndex::new();
let uri = "file:///test.nika.yaml".parse::<Uri>().unwrap();
let items = verb_value_completions_with_ast("exec", &index, &uri);
assert!(
!items.iter().any(|i| i.label == "claude-sonnet-4-6"),
"exec completions should NOT include model catalog"
);
}
#[test]
#[cfg(feature = "lsp")]
fn test_verb_value_completions_invoke_has_sub_fields() {
let items = verb_value_completions("invoke");
assert!(items.iter().any(|i| i.label == "mcp"));
assert!(items.iter().any(|i| i.label == "tool"));
assert!(items.iter().any(|i| i.label == "params"));
assert!(items.iter().any(|i| i.label == "resource"));
assert_eq!(items.len(), 4);
}
#[test]
#[cfg(feature = "lsp")]
fn test_verb_value_completions_fetch_has_sub_fields() {
let items = verb_value_completions("fetch");
assert!(items.iter().any(|i| i.label == "url"));
assert!(items.iter().any(|i| i.label == "method"));
assert!(items.iter().any(|i| i.label == "headers"));
assert!(items.iter().any(|i| i.label == "body"));
assert!(items.iter().any(|i| i.label == "retry"));
assert_eq!(items.len(), 5);
}
#[test]
#[cfg(feature = "lsp")]
fn test_compute_completions_with_ast_integration() {
use super::super::super::ast_index::AstIndex;
let index = AstIndex::new();
let uri = "file:///test.nika.yaml".parse::<Uri>().unwrap();
let text = r#"schema: nika/workflow@0.12
workflow: test
tasks:
- id: step1
infer: "Hello"
- id: step2
with:
result:
"#;
index.parse_document(&uri, text, 1);
let position = Position {
line: 7,
character: 14,
};
let items = compute_completions_with_ast(&index, &uri, text, position);
let _ = items; }
#[test]
#[cfg(feature = "lsp")]
fn test_verb_completions_are_multiline_snippets() {
let verbs = verb_completions();
for item in &verbs {
assert_eq!(
item.insert_text_format,
Some(InsertTextFormat::SNIPPET),
"Verb '{}' must use SNIPPET format",
item.label
);
}
let infer = verbs.iter().find(|i| i.label == "infer").unwrap();
let infer_text = infer.insert_text.as_deref().unwrap();
assert!(
infer_text.contains("prompt:"),
"infer scaffold must contain prompt: field"
);
assert!(
infer_text.contains('\n'),
"infer scaffold must be multi-line"
);
assert!(
infer_text.contains("${0}"),
"infer scaffold must contain final tab stop ${{0}}"
);
let exec = verbs.iter().find(|i| i.label == "exec").unwrap();
let exec_text = exec.insert_text.as_deref().unwrap();
assert!(
exec_text.starts_with("exec:"),
"exec scaffold must start with exec:"
);
assert!(
exec_text.contains("${0}"),
"exec scaffold must contain final tab stop ${{0}}"
);
let fetch = verbs.iter().find(|i| i.label == "fetch").unwrap();
let fetch_text = fetch.insert_text.as_deref().unwrap();
assert!(
fetch_text.contains("url:"),
"fetch scaffold must contain url: field"
);
assert!(
fetch_text.contains("${0}"),
"fetch scaffold must contain final tab stop ${{0}}"
);
let invoke = verbs.iter().find(|i| i.label == "invoke").unwrap();
let invoke_text = invoke.insert_text.as_deref().unwrap();
assert!(
invoke_text.contains("tool:"),
"invoke scaffold must contain tool: field"
);
assert!(
invoke_text.contains("params:"),
"invoke scaffold must contain params: block"
);
assert!(
invoke_text.contains("${0}"),
"invoke scaffold must contain final tab stop ${{0}}"
);
let agent = verbs.iter().find(|i| i.label == "agent").unwrap();
let agent_text = agent.insert_text.as_deref().unwrap();
assert!(
agent_text.contains("prompt:"),
"agent scaffold must contain prompt: field"
);
assert!(
agent_text.contains("mcp:"),
"agent scaffold must contain mcp: field"
);
assert!(
agent_text.contains("max_turns:"),
"agent scaffold must contain max_turns: field"
);
assert!(
agent_text.contains("${0}"),
"agent scaffold must contain final tab stop ${{0}}"
);
}
}