Skip to main content

cooklang_language_server/
state.rs

1use std::path::Path;
2use std::sync::RwLock;
3
4use dashmap::DashMap;
5use tower_lsp::lsp_types::Url;
6
7use crate::document::Document;
8
9/// An ingredient from the aisle configuration with its category
10#[derive(Debug, Clone)]
11pub struct AisleIngredient {
12    /// The ingredient name (or alias)
13    pub name: String,
14    /// The common/canonical name for this ingredient
15    pub common_name: String,
16    /// The category/aisle this ingredient belongs to
17    pub category: String,
18}
19
20/// Owned version of parsed aisle configuration for storage
21#[derive(Debug, Default)]
22pub struct AisleConfig {
23    /// All ingredients with their category info
24    pub ingredients: Vec<AisleIngredient>,
25}
26
27impl AisleConfig {
28    /// Parse an aisle.conf file content and create an owned AisleConfig
29    /// Uses lenient parsing to skip errors and continue with valid entries
30    pub fn parse(content: &str) -> Option<Self> {
31        let result = cooklang::aisle::parse_lenient(content);
32        let (aisle_conf, warnings) = result.into_result().ok()?;
33
34        // Log any warnings from lenient parsing
35        for warning in warnings.iter() {
36            tracing::warn!("aisle.conf warning: {}", warning);
37        }
38
39        let mut ingredients = Vec::new();
40        for category in &aisle_conf.categories {
41            for ingredient in &category.ingredients {
42                if let Some(common_name) = ingredient.names.first() {
43                    for name in &ingredient.names {
44                        ingredients.push(AisleIngredient {
45                            name: name.to_string(),
46                            common_name: common_name.to_string(),
47                            category: category.name.to_string(),
48                        });
49                    }
50                }
51            }
52        }
53        Some(AisleConfig { ingredients })
54    }
55
56    /// Load aisle.conf from a workspace path
57    pub fn load_from_workspace(workspace_path: &Path) -> Option<Self> {
58        // Check for config/aisle.conf (standard cooklang location)
59        let config_path = workspace_path.join("config").join("aisle.conf");
60        if config_path.exists() {
61            if let Ok(content) = std::fs::read_to_string(&config_path) {
62                tracing::info!("Loading aisle.conf from {:?}", config_path);
63                return Self::parse(&content);
64            }
65        }
66
67        // Also check root aisle.conf
68        let root_path = workspace_path.join("aisle.conf");
69        if root_path.exists() {
70            if let Ok(content) = std::fs::read_to_string(&root_path) {
71                tracing::info!("Loading aisle.conf from {:?}", root_path);
72                return Self::parse(&content);
73            }
74        }
75
76        None
77    }
78}
79
80/// Thread-safe server state
81pub struct ServerState {
82    pub documents: DashMap<Url, Document>,
83    /// Parsed aisle configuration for ingredient suggestions
84    pub aisle_config: RwLock<Option<AisleConfig>>,
85}
86
87impl ServerState {
88    pub fn new() -> Self {
89        Self {
90            documents: DashMap::new(),
91            aisle_config: RwLock::new(None),
92        }
93    }
94
95    /// Load aisle configuration from a workspace path
96    pub fn load_aisle_config(&self, workspace_path: &Path) {
97        if let Some(config) = AisleConfig::load_from_workspace(workspace_path) {
98            let count = config.ingredients.len();
99            if let Ok(mut guard) = self.aisle_config.write() {
100                *guard = Some(config);
101                tracing::info!("Loaded {} ingredients from aisle.conf", count);
102            }
103        }
104    }
105
106    /// Get a reference to the aisle config if loaded
107    pub fn get_aisle_ingredients(&self) -> Vec<AisleIngredient> {
108        if let Ok(guard) = self.aisle_config.read() {
109            if let Some(ref config) = *guard {
110                return config.ingredients.clone();
111            }
112        }
113        Vec::new()
114    }
115
116    pub fn open_document(&self, uri: Url, version: i32, content: String) {
117        let doc = Document::new(uri.clone(), version, content);
118        self.documents.insert(uri, doc);
119    }
120
121    pub fn update_document(&self, uri: &Url, version: i32, content: String) {
122        if let Some(mut doc) = self.documents.get_mut(uri) {
123            doc.update(version, content);
124        }
125    }
126
127    pub fn close_document(&self, uri: &Url) {
128        self.documents.remove(uri);
129    }
130
131    pub fn get_document(&self, uri: &Url) -> Option<dashmap::mapref::one::Ref<'_, Url, Document>> {
132        self.documents.get(uri)
133    }
134}
135
136impl Default for ServerState {
137    fn default() -> Self {
138        Self::new()
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn test_aisle_config_parse() {
148        let content = r#"
149[produce]
150potatoes
151carrots
152onions|yellow onion|white onion
153
154[dairy]
155milk
156butter
157cheese|cheddar|parmesan
158"#;
159        let config = AisleConfig::parse(content).unwrap();
160        assert!(!config.ingredients.is_empty());
161
162        // Check potatoes
163        let potatoes = config
164            .ingredients
165            .iter()
166            .find(|i| i.name == "potatoes")
167            .unwrap();
168        assert_eq!(potatoes.category, "produce");
169        assert_eq!(potatoes.common_name, "potatoes");
170
171        // Check onion aliases
172        let yellow_onion = config
173            .ingredients
174            .iter()
175            .find(|i| i.name == "yellow onion")
176            .unwrap();
177        assert_eq!(yellow_onion.category, "produce");
178        assert_eq!(yellow_onion.common_name, "onions");
179
180        // Check cheese aliases
181        let cheddar = config
182            .ingredients
183            .iter()
184            .find(|i| i.name == "cheddar")
185            .unwrap();
186        assert_eq!(cheddar.category, "dairy");
187        assert_eq!(cheddar.common_name, "cheese");
188    }
189
190    #[test]
191    fn test_aisle_config_lenient_parsing() {
192        // This content has errors: duplicate ingredient 'apple', orphan ingredient, duplicate category
193        let content = r#"
194orphan ingredient before any category
195[produce]
196apple
197banana
198apple
199
200[dairy]
201milk
202
203[produce]
204carrot
205"#;
206        // With lenient parsing, this should still succeed and return valid entries
207        let config = AisleConfig::parse(content).unwrap();
208
209        // Should have parsed the valid categories and ingredients
210        assert!(!config.ingredients.is_empty());
211
212        // Check that apple is present (first occurrence kept)
213        let apple = config.ingredients.iter().find(|i| i.name == "apple");
214        assert!(apple.is_some());
215        assert_eq!(apple.unwrap().category, "produce");
216
217        // Check that banana is present
218        let banana = config.ingredients.iter().find(|i| i.name == "banana");
219        assert!(banana.is_some());
220
221        // Check that milk is present
222        let milk = config.ingredients.iter().find(|i| i.name == "milk");
223        assert!(milk.is_some());
224        assert_eq!(milk.unwrap().category, "dairy");
225
226        // Duplicate apple entries should be skipped (only one apple)
227        let apple_count = config.ingredients.iter().filter(|i| i.name == "apple").count();
228        assert_eq!(apple_count, 1);
229    }
230}