use std::path::Path;
use std::sync::LazyLock;
use tower_lsp::lsp_types::{
CompletionItem, CompletionItemKind, CompletionList, CompletionParams, CompletionResponse,
Documentation, InsertTextFormat,
};
use crate::document::Document;
use crate::state::ServerState;
use crate::utils::position::position_to_offset;
fn parse_unit_pairs(data: &'static str) -> Vec<(&'static str, &'static str)> {
data.lines()
.filter_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
return None;
}
let mut parts = trimmed.split('=').map(|s| s.trim());
match (parts.next(), parts.next()) {
(Some(short), Some(long)) if parts.next().is_none() => Some((short, long)),
_ => None,
}
})
.collect()
}
fn parse_simple_list(data: &'static str) -> Vec<&'static str> {
data.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.collect()
}
static UNITS: LazyLock<Vec<(&'static str, &'static str)>> =
LazyLock::new(|| parse_unit_pairs(include_str!("../data/units.txt")));
static TIME_UNITS: LazyLock<Vec<(&'static str, &'static str)>> =
LazyLock::new(|| parse_unit_pairs(include_str!("../data/time_units.txt")));
static COMMON_COOKWARE: LazyLock<Vec<&'static str>> =
LazyLock::new(|| parse_simple_list(include_str!("../data/cookware.txt")));
static COMMON_INGREDIENTS: LazyLock<Vec<&'static str>> =
LazyLock::new(|| parse_simple_list(include_str!("../data/ingredients.txt")));
pub fn get_completions(
doc: &Document,
params: &CompletionParams,
state: &ServerState,
workspace_root: Option<&Path>,
) -> Option<CompletionResponse> {
let offset = position_to_offset(params.text_document_position.position, &doc.line_index);
let text_before = &doc.content[..offset.min(doc.content.len())];
let context = find_completion_context(text_before)?;
let items = match context {
CompletionContext::Ingredient(prefix) => complete_ingredients(&prefix, doc, state),
CompletionContext::Cookware(prefix) => complete_cookware(&prefix, doc),
CompletionContext::Timer => complete_timer_units(),
CompletionContext::Unit(prefix) => complete_units(&prefix),
CompletionContext::Quantity => complete_quantity_snippets(),
CompletionContext::RecipeReference(prefix) => {
if let Some(root) = workspace_root {
complete_recipe_references(&prefix, root)
} else {
vec![]
}
}
};
Some(CompletionResponse::List(CompletionList {
is_incomplete: false,
items,
}))
}
#[derive(Debug)]
enum CompletionContext {
Ingredient(String), Cookware(String), Timer, Unit(String), Quantity, RecipeReference(String), }
fn find_completion_context(text: &str) -> Option<CompletionContext> {
const MAX_SCAN: usize = 200;
let byte_start = text.len().saturating_sub(MAX_SCAN);
let scan_start = text.ceil_char_boundary(byte_start);
let scan_text = &text[scan_start..];
let chars: Vec<char> = scan_text.chars().collect();
let len = chars.len();
for i in (0..len).rev() {
match chars[i] {
'@' => {
let prefix: String = chars[i + 1..].iter().collect();
if !prefix.contains('}') {
if prefix.contains('{') {
return Some(CompletionContext::Quantity);
}
let name_prefix = prefix.split('{').next().unwrap_or("").to_string();
if name_prefix.starts_with('.') {
return Some(CompletionContext::RecipeReference(name_prefix));
}
return Some(CompletionContext::Ingredient(name_prefix));
}
return None;
}
'#' => {
let prefix: String = chars[i + 1..].iter().collect();
if !prefix.contains('}') {
return Some(CompletionContext::Cookware(
prefix.split('{').next().unwrap_or("").to_string(),
));
}
return None;
}
'~' => {
let rest: String = chars[i + 1..].iter().collect();
if !rest.contains('}') {
return Some(CompletionContext::Timer);
}
return None;
}
'%' => {
let prefix: String = chars[i + 1..].iter().collect();
if !prefix.contains('}') {
return Some(CompletionContext::Unit(prefix.trim().to_string()));
}
return None;
}
'{' => {
for j in (0..i).rev() {
match chars[j] {
'@' | '#' | '~' => {
let inside: String = chars[i + 1..].iter().collect();
if inside.contains('%') {
let after_percent: String =
inside.split('%').next_back().unwrap_or("").to_string();
return Some(CompletionContext::Unit(
after_percent.trim().to_string(),
));
}
return Some(CompletionContext::Quantity);
}
'\n' | '\r' => break,
_ => continue,
}
}
}
'\n' | '\r' => break,
_ => {}
}
}
None
}
fn complete_ingredients(prefix: &str, doc: &Document, state: &ServerState) -> Vec<CompletionItem> {
let mut items = Vec::new();
let prefix_lower = prefix.to_lowercase();
if let Some(ref result) = doc.parse_result {
for ingredient in &result.recipe.ingredients {
let name = &ingredient.name;
if name.to_lowercase().starts_with(&prefix_lower) {
items.push(CompletionItem {
label: name.clone(),
kind: Some(CompletionItemKind::VARIABLE),
detail: Some("Ingredient (from recipe)".into()),
insert_text: Some(format!("{}{{$0}}", name)),
insert_text_format: Some(InsertTextFormat::SNIPPET),
..Default::default()
});
}
}
}
for entry in state.documents.iter() {
if entry.key() == &doc.uri {
continue;
}
if let Some(ref result) = entry.value().parse_result {
for ingredient in &result.recipe.ingredients {
let name = &ingredient.name;
if name.to_lowercase().starts_with(&prefix_lower)
&& !items.iter().any(|i| &i.label == name)
{
items.push(CompletionItem {
label: name.clone(),
kind: Some(CompletionItemKind::VARIABLE),
detail: Some("Ingredient (from workspace)".into()),
insert_text: Some(format!("{}{{$0}}", name)),
insert_text_format: Some(InsertTextFormat::SNIPPET),
..Default::default()
});
}
}
}
}
for aisle_ingredient in state.get_aisle_ingredients() {
if aisle_ingredient
.name
.to_lowercase()
.starts_with(&prefix_lower)
&& !items.iter().any(|i| i.label == aisle_ingredient.name)
{
let detail = if aisle_ingredient.name != aisle_ingredient.common_name {
format!(
"{} (alias for {})",
aisle_ingredient.category, aisle_ingredient.common_name
)
} else {
aisle_ingredient.category.clone()
};
items.push(CompletionItem {
label: aisle_ingredient.name.clone(),
kind: Some(CompletionItemKind::VARIABLE),
detail: Some(detail),
documentation: Some(Documentation::String(format!(
"From aisle.conf - {}",
aisle_ingredient.category
))),
insert_text: Some(format!("{}{{$0}}", aisle_ingredient.name)),
insert_text_format: Some(InsertTextFormat::SNIPPET),
..Default::default()
});
}
}
for &ingredient in COMMON_INGREDIENTS.iter() {
if ingredient.to_lowercase().starts_with(&prefix_lower)
&& !items.iter().any(|i| i.label == ingredient)
{
items.push(CompletionItem {
label: ingredient.into(),
kind: Some(CompletionItemKind::VARIABLE),
detail: Some("Common ingredient".into()),
insert_text: Some(format!("{}{{$0}}", ingredient)),
insert_text_format: Some(InsertTextFormat::SNIPPET),
..Default::default()
});
}
}
items
}
fn scan_recipe_files(root: &Path) -> Vec<(String, &'static str)> {
let mut files = Vec::new();
scan_dir_recursive(root, root, &mut files);
files.sort_by(|a, b| a.0.cmp(&b.0));
files
}
fn scan_dir_recursive(root: &Path, dir: &Path, files: &mut Vec<(String, &'static str)>) {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if !name.starts_with('.') {
scan_dir_recursive(root, &path, files);
}
}
} else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
let kind = match ext {
"cook" => "Recipe",
"menu" => "Menu",
_ => continue,
};
if let Ok(rel) = path.strip_prefix(root) {
let rel_str = rel.to_string_lossy();
let without_ext = rel_str
.strip_suffix(&format!(".{}", ext))
.unwrap_or(&rel_str);
let normalized = without_ext.replace('\\', "/");
files.push((format!("./{}", normalized), kind));
}
}
}
}
fn complete_recipe_references(prefix: &str, workspace_root: &Path) -> Vec<CompletionItem> {
let files = scan_recipe_files(workspace_root);
let prefix_lower = prefix.to_lowercase();
files
.into_iter()
.filter(|(path, _)| path.to_lowercase().starts_with(&prefix_lower))
.map(|(path, kind)| {
let display_name = path.rsplit('/').next().unwrap_or(&path);
CompletionItem {
label: path.clone(),
kind: Some(CompletionItemKind::FILE),
detail: Some(format!("{} reference", kind)),
documentation: Some(Documentation::String(display_name.to_string())),
insert_text: Some(format!("{}{{$0}}", path)),
insert_text_format: Some(InsertTextFormat::SNIPPET),
..Default::default()
}
})
.collect()
}
fn complete_cookware(prefix: &str, doc: &Document) -> Vec<CompletionItem> {
let mut items = Vec::new();
let prefix_lower = prefix.to_lowercase();
if let Some(ref result) = doc.parse_result {
for cookware in &result.recipe.cookware {
let name = &cookware.name;
if name.to_lowercase().starts_with(&prefix_lower) {
items.push(CompletionItem {
label: name.clone(),
kind: Some(CompletionItemKind::CLASS),
detail: Some("Cookware (from recipe)".into()),
insert_text: Some(format!("{}{{$0}}", name)),
insert_text_format: Some(InsertTextFormat::SNIPPET),
..Default::default()
});
}
}
}
for &cookware in COMMON_COOKWARE.iter() {
if cookware.to_lowercase().starts_with(&prefix_lower)
&& !items.iter().any(|i| i.label == cookware)
{
items.push(CompletionItem {
label: cookware.into(),
kind: Some(CompletionItemKind::CLASS),
detail: Some("Common cookware".into()),
insert_text: Some(format!("{}{{$0}}", cookware)),
insert_text_format: Some(InsertTextFormat::SNIPPET),
..Default::default()
});
}
}
items
}
fn complete_timer_units() -> Vec<CompletionItem> {
TIME_UNITS
.iter()
.map(|(short, long)| CompletionItem {
label: short.to_string(),
kind: Some(CompletionItemKind::UNIT),
detail: Some(long.to_string()),
documentation: Some(Documentation::String(format!("Time unit: {}", long))),
..Default::default()
})
.collect()
}
fn complete_units(prefix: &str) -> Vec<CompletionItem> {
let prefix_lower = prefix.to_lowercase();
let mut items: Vec<_> = UNITS
.iter()
.filter(|(short, _)| short.to_lowercase().starts_with(&prefix_lower))
.map(|(short, long)| CompletionItem {
label: short.to_string(),
kind: Some(CompletionItemKind::UNIT),
detail: Some(long.to_string()),
..Default::default()
})
.collect();
items.extend(
TIME_UNITS
.iter()
.filter(|(short, _)| short.to_lowercase().starts_with(&prefix_lower))
.map(|(short, long)| CompletionItem {
label: short.to_string(),
kind: Some(CompletionItemKind::UNIT),
detail: Some(format!("{} (time)", long)),
..Default::default()
}),
);
items
}
fn complete_quantity_snippets() -> Vec<CompletionItem> {
vec![
CompletionItem {
label: "quantity with unit".into(),
kind: Some(CompletionItemKind::SNIPPET),
insert_text: Some("${1:amount}%${2:unit}".into()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
detail: Some("Insert quantity with unit".into()),
..Default::default()
},
CompletionItem {
label: "quantity only".into(),
kind: Some(CompletionItemKind::SNIPPET),
insert_text: Some("${1:amount}".into()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
detail: Some("Insert quantity without unit".into()),
..Default::default()
},
]
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_context_recipe_reference_dot() {
let ctx = find_completion_context("Pour over with @.").unwrap();
assert!(matches!(ctx, CompletionContext::RecipeReference(ref p) if p == "."));
}
#[test]
fn test_context_recipe_reference_dot_slash() {
let ctx = find_completion_context("Pour over with @./").unwrap();
assert!(matches!(ctx, CompletionContext::RecipeReference(ref p) if p == "./"));
}
#[test]
fn test_context_recipe_reference_path() {
let ctx = find_completion_context("Pour over with @./sauces/Hol").unwrap();
assert!(matches!(ctx, CompletionContext::RecipeReference(ref p) if p == "./sauces/Hol"));
}
#[test]
fn test_context_recipe_reference_parent() {
let ctx = find_completion_context("@../other/Recipe").unwrap();
assert!(matches!(ctx, CompletionContext::RecipeReference(ref p) if p == "../other/Recipe"));
}
#[test]
fn test_context_recipe_reference_with_brace_is_quantity() {
let ctx = find_completion_context("@./sauces/Hollandaise{").unwrap();
assert!(matches!(ctx, CompletionContext::Quantity));
}
#[test]
fn test_context_regular_ingredient_unchanged() {
let ctx = find_completion_context("@sal").unwrap();
assert!(matches!(ctx, CompletionContext::Ingredient(ref p) if p == "sal"));
}
#[test]
fn test_scan_recipe_files() {
let dir = TempDir::new().unwrap();
let root = dir.path();
fs::create_dir_all(root.join("sauces")).unwrap();
fs::create_dir_all(root.join(".hidden")).unwrap();
fs::write(root.join("Pancakes.cook"), "").unwrap();
fs::write(root.join("sauces/Hollandaise.cook"), "").unwrap();
fs::write(root.join("sauces/Bechamel.cook"), "").unwrap();
fs::write(root.join("WeeklyMenu.menu"), "").unwrap();
fs::write(root.join("notes.txt"), "").unwrap();
fs::write(root.join(".hidden/Secret.cook"), "").unwrap();
let files = scan_recipe_files(root);
let paths: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect();
assert!(paths.contains(&"./Pancakes"));
assert!(paths.contains(&"./sauces/Hollandaise"));
assert!(paths.contains(&"./sauces/Bechamel"));
assert!(paths.contains(&"./WeeklyMenu"));
assert!(!paths.iter().any(|p| p.contains("notes")));
assert!(!paths.iter().any(|p| p.contains("Secret")));
let menu = files.iter().find(|(p, _)| p == "./WeeklyMenu").unwrap();
assert_eq!(menu.1, "Menu");
let recipe = files.iter().find(|(p, _)| p == "./Pancakes").unwrap();
assert_eq!(recipe.1, "Recipe");
}
#[test]
fn test_complete_recipe_references_filtering() {
let dir = TempDir::new().unwrap();
let root = dir.path();
fs::create_dir_all(root.join("sauces")).unwrap();
fs::write(root.join("sauces/Hollandaise.cook"), "").unwrap();
fs::write(root.join("sauces/Bechamel.cook"), "").unwrap();
fs::write(root.join("Pancakes.cook"), "").unwrap();
let items = complete_recipe_references("./sauces/H", root);
assert_eq!(items.len(), 1);
assert_eq!(items[0].label, "./sauces/Hollandaise");
assert_eq!(
items[0].insert_text.as_deref(),
Some("./sauces/Hollandaise{$0}")
);
let items = complete_recipe_references("./sauces/", root);
assert_eq!(items.len(), 2);
let items = complete_recipe_references("./", root);
assert_eq!(items.len(), 3);
let items = complete_recipe_references(".", root);
assert_eq!(items.len(), 3);
}
}