#[cfg(feature = "lsp")]
use dashmap::DashMap;
#[cfg(feature = "lsp")]
use tower_lsp_server::ls_types::{Position, Uri};
#[cfg(feature = "lsp")]
use crate::ast::analyzed::{AnalyzedTask, AnalyzedTaskAction, AnalyzedWorkflow};
#[cfg(feature = "lsp")]
use crate::ast::analyzer::{analyze, AnalyzeError};
#[cfg(feature = "lsp")]
use crate::ast::raw::{self, ParseError, RawWorkflow};
#[cfg(feature = "lsp")]
use crate::source::{FileId, Span};
#[cfg(feature = "lsp")]
use super::conversion::position_to_offset;
#[cfg(feature = "lsp")]
#[derive(Debug, Default)]
pub struct CachedAst {
pub raw: Option<RawWorkflow>,
pub analyzed: Option<AnalyzedWorkflow>,
pub parse_error: Option<ParseError>,
pub errors: Vec<AnalyzeError>,
pub version: i32,
pub text: String,
}
#[cfg(feature = "lsp")]
pub struct AstIndex {
cache: DashMap<Uri, CachedAst>,
}
#[cfg(feature = "lsp")]
impl Default for AstIndex {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "lsp")]
impl AstIndex {
pub fn new() -> Self {
Self {
cache: DashMap::new(),
}
}
pub fn parse_document(&self, uri: &Uri, text: &str, version: i32) -> Vec<AnalyzeError> {
let file_id = FileId(0);
let (raw, analyzed, parse_error, errors) = match raw::parse(text, file_id) {
Ok(raw_workflow) => {
let result = analyze(raw_workflow.clone());
let analyzed = if result.is_ok() { result.value } else { None };
(Some(raw_workflow), analyzed, None, result.errors)
}
Err(parse_err) => {
(None, None, Some(parse_err), Vec::new())
}
};
self.cache.insert(
uri.clone(),
CachedAst {
raw,
analyzed,
parse_error,
errors: errors.clone(),
version,
text: text.to_string(),
},
);
errors
}
pub fn get_parse_error(&self, uri: &Uri) -> Option<ParseError> {
self.cache.get(uri).and_then(|c| c.parse_error.clone())
}
pub fn invalidate(&self, uri: &Uri) {
self.cache.remove(uri);
}
pub fn get(&self, uri: &Uri) -> Option<dashmap::mapref::one::Ref<'_, Uri, CachedAst>> {
self.cache.get(uri)
}
fn span_contains_offset(span: &Span, offset: usize) -> bool {
let start = span.start.as_usize();
let end = span.end.as_usize();
offset >= start && offset < end
}
pub fn get_node_at_position(&self, uri: &Uri, position: Position) -> Option<AstNode> {
let cached = self.cache.get(uri)?;
let offset = position_to_offset(position, &cached.text);
if let Some(ref analyzed) = cached.analyzed {
for task in &analyzed.tasks {
if Self::span_contains_offset(&task.span, offset) {
if let Some(node) = self.get_node_in_task(task, offset) {
return Some(node);
}
return Some(AstNode::Task(task.name.clone(), task.span));
}
}
for (name, server) in &analyzed.mcp_servers {
if Self::span_contains_offset(&server.span, offset) {
return Some(AstNode::McpServer(name.clone(), server.span));
}
}
}
if let Some(ref raw) = cached.raw {
if Self::span_contains_offset(&raw.schema.span, offset) {
return Some(AstNode::Schema(raw.schema.value.clone(), raw.schema.span));
}
if let Some(ref workflow) = raw.workflow {
if Self::span_contains_offset(&workflow.span, offset) {
return Some(AstNode::Workflow(workflow.value.clone(), workflow.span));
}
}
}
None
}
fn get_node_in_task(&self, task: &AnalyzedTask, offset: usize) -> Option<AstNode> {
let action_span = match &task.action {
AnalyzedTaskAction::Infer(a) => a.span,
AnalyzedTaskAction::Exec(a) => a.span,
AnalyzedTaskAction::Fetch(a) => a.span,
AnalyzedTaskAction::Invoke(a) => a.span,
AnalyzedTaskAction::Agent(a) => a.span,
};
if Self::span_contains_offset(&action_span, offset) {
return Some(AstNode::Verb(
task.action.verb_name().to_string(),
action_span,
));
}
if let Some(ref for_each) = task.for_each {
if Self::span_contains_offset(&for_each.span, offset) {
return Some(AstNode::ForEach(for_each.span));
}
}
None
}
pub fn get_task_at_position(&self, uri: &Uri, position: Position) -> Option<String> {
match self.get_node_at_position(uri, position)? {
AstNode::Task(name, _) => Some(name),
AstNode::Verb(_, _) => {
let cached = self.cache.get(uri)?;
let offset = position_to_offset(position, &cached.text);
if let Some(ref analyzed) = cached.analyzed {
for task in &analyzed.tasks {
if Self::span_contains_offset(&task.span, offset) {
return Some(task.name.clone());
}
}
}
None
}
AstNode::Binding(_, _) | AstNode::ForEach(_) => {
let cached = self.cache.get(uri)?;
let offset = position_to_offset(position, &cached.text);
if let Some(ref analyzed) = cached.analyzed {
for task in &analyzed.tasks {
if Self::span_contains_offset(&task.span, offset) {
return Some(task.name.clone());
}
}
}
None
}
_ => None,
}
}
pub fn get_task_names(&self, uri: &Uri) -> Vec<String> {
if let Some(cached) = self.cache.get(uri) {
if let Some(ref analyzed) = cached.analyzed {
return analyzed.tasks.iter().map(|t| t.name.clone()).collect();
}
}
Vec::new()
}
pub fn get_mcp_server_names(&self, uri: &Uri) -> Vec<String> {
if let Some(cached) = self.cache.get(uri) {
if let Some(ref analyzed) = cached.analyzed {
return analyzed.mcp_servers.keys().cloned().collect();
}
}
Vec::new()
}
pub fn get_context_file_names(&self, uri: &Uri) -> Vec<String> {
if let Some(cached) = self.cache.get(uri) {
if let Some(ref analyzed) = cached.analyzed {
return analyzed
.context_files
.iter()
.filter_map(|cf| cf.alias.clone())
.collect();
}
}
Vec::new()
}
}
#[cfg(feature = "lsp")]
#[derive(Debug, Clone)]
pub enum AstNode {
Schema(String, Span),
Workflow(String, Span),
Task(String, Span),
Verb(String, Span),
Binding(String, Span),
ForEach(Span),
McpServer(String, Span),
ContextFile(String, Span),
Include(String, Span),
Template(String, Span),
Unknown,
}
#[cfg(feature = "lsp")]
impl AstNode {
pub fn span(&self) -> Option<Span> {
match self {
AstNode::Schema(_, span) => Some(*span),
AstNode::Workflow(_, span) => Some(*span),
AstNode::Task(_, span) => Some(*span),
AstNode::Verb(_, span) => Some(*span),
AstNode::Binding(_, span) => Some(*span),
AstNode::ForEach(span) => Some(*span),
AstNode::McpServer(_, span) => Some(*span),
AstNode::ContextFile(_, span) => Some(*span),
AstNode::Include(_, span) => Some(*span),
AstNode::Template(_, span) => Some(*span),
AstNode::Unknown => None,
}
}
pub fn name(&self) -> Option<&str> {
match self {
AstNode::Schema(name, _) => Some(name),
AstNode::Workflow(name, _) => Some(name),
AstNode::Task(name, _) => Some(name),
AstNode::Verb(name, _) => Some(name),
AstNode::Binding(name, _) => Some(name),
AstNode::ForEach(_) => Some("for_each"),
AstNode::McpServer(name, _) => Some(name),
AstNode::ContextFile(name, _) => Some(name),
AstNode::Include(name, _) => Some(name),
AstNode::Template(expr, _) => Some(expr),
AstNode::Unknown => None,
}
}
}
#[cfg(test)]
mod tests {
#[cfg(feature = "lsp")]
use super::*;
#[test]
#[cfg(feature = "lsp")]
fn test_ast_index_creation() {
let index = AstIndex::new();
assert!(index.cache.is_empty());
}
#[test]
#[cfg(feature = "lsp")]
fn test_ast_index_parse_simple_workflow() {
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"
"#;
let errors = index.parse_document(&uri, text, 1);
assert!(errors.is_empty(), "Parse errors: {:?}", errors);
let cached = index.get(&uri).expect("Should have cached AST");
assert!(cached.raw.is_some());
assert!(cached.analyzed.is_some());
}
#[test]
#[cfg(feature = "lsp")]
fn test_ast_index_get_task_names() {
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 names = index.get_task_names(&uri);
assert_eq!(names.len(), 2);
assert!(names.contains(&"step1".to_string()));
assert!(names.contains(&"step2".to_string()));
}
#[test]
#[cfg(feature = "lsp")]
fn test_ast_index_invalidate() {
let index = AstIndex::new();
let uri = "file:///test.nika.yaml".parse::<Uri>().unwrap();
let text = "schema: nika/workflow@0.12\n";
index.parse_document(&uri, text, 1);
assert!(index.get(&uri).is_some());
index.invalidate(&uri);
assert!(index.get(&uri).is_none());
}
#[test]
#[cfg(feature = "lsp")]
fn test_ast_node_span() {
use crate::source::FileId;
let span = Span::new(FileId(0), 10, 20);
let node = AstNode::Task("test".to_string(), span);
assert_eq!(node.span(), Some(span));
assert_eq!(node.name(), Some("test"));
}
#[test]
#[cfg(feature = "lsp")]
fn test_span_contains_offset() {
use crate::source::FileId;
let span = Span::new(FileId(0), 10, 20);
assert!(!AstIndex::span_contains_offset(&span, 5));
assert!(AstIndex::span_contains_offset(&span, 10));
assert!(AstIndex::span_contains_offset(&span, 15));
assert!(!AstIndex::span_contains_offset(&span, 20));
assert!(!AstIndex::span_contains_offset(&span, 25));
}
#[test]
#[cfg(feature = "lsp")]
fn test_get_node_at_position_schema() {
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"
"#;
index.parse_document(&uri, text, 1);
let cached = index.get(&uri).expect("Should have cached AST");
assert!(cached.raw.is_some(), "Should have raw AST");
assert!(cached.analyzed.is_some(), "Should have analyzed AST");
let schema_value = &cached.raw.as_ref().unwrap().schema.value;
assert_eq!(schema_value, "nika/workflow@0.12");
}
#[test]
#[cfg(feature = "lsp")]
fn test_get_node_at_position_task() {
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"
"#;
index.parse_document(&uri, text, 1);
let node = index.get_node_at_position(
&uri,
Position {
line: 4,
character: 5,
},
);
if let Some(node) = node {
match node {
AstNode::Task(name, _) => assert_eq!(name, "step1"),
other => {
println!("Got node: {:?}", other);
}
}
}
}
}