1use crate::extract::annotation_schema;
7use crate::schema::{AnnotationSchema, Registry};
8use crate::value::game_path;
9use foch::game::eu4::script::parser::{AstStatement, parse_clausewitz_content};
10use serde::Serialize;
11use std::ops::Range;
12
13#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
14pub struct Completion {
15 pub label: String,
16 pub insert_text: String,
17 pub detail: String,
18}
19
20#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
21pub struct Hover {
22 pub markdown: String,
23 pub range: Range<usize>,
24}
25
26pub fn completions(source: &str, cursor: usize, registry: &Registry) -> Option<Vec<Completion>> {
29 let (schema, context) = annotation_context(source, cursor, registry)?;
30 let Some(prefix) = context.prefix else {
31 return Some(Vec::new());
32 };
33 Some(
34 schema
35 .params
36 .iter()
37 .filter(|param| {
38 param.name.starts_with(&prefix)
39 && !context.keys.iter().any(|range| {
40 &source[range.clone()] == param.name
41 && !(range.start..=range.end).contains(&cursor)
42 })
43 })
44 .map(|param| Completion {
45 label: param.name.to_owned(),
46 insert_text: format!("{} = {}", param.name, param.example),
47 detail: format!(
48 "{} ({}) โ {}",
49 param.value_type.describe(),
50 if param.required {
51 "required"
52 } else {
53 "optional"
54 },
55 param.description
56 ),
57 })
58 .collect(),
59 )
60}
61
62pub fn hover(source: &str, cursor: usize, registry: &Registry) -> Option<Hover> {
63 let (schema, context) = annotation_context(source, cursor, registry)?;
64 let range = context
65 .keys
66 .into_iter()
67 .find(|range| range.contains(&cursor))?;
68 let param = schema.param(&source[range.clone()])?;
69 Some(Hover {
70 markdown: format!(
71 "**{}** ยท `{}` ยท {}\n\n{}\n\n```text\n{} = {}\n```\n\n{}",
72 param.name,
73 param.value_type.describe(),
74 if param.required {
75 "required"
76 } else {
77 "optional"
78 },
79 param.description,
80 param.name,
81 param.example,
82 schema.description
83 ),
84 range,
85 })
86}
87
88#[derive(Default)]
89struct AnnotationContext {
90 keys: Vec<Range<usize>>,
91 prefix: Option<String>,
92}
93
94#[derive(Default)]
95enum HeaderState {
96 #[default]
97 Key,
98 KeyName(usize),
99 Equals,
100 Value,
101 Scalar,
102 AfterValue,
103}
104
105#[derive(Default)]
106struct HeaderScanner {
107 context: AnnotationContext,
108 state: HeaderState,
109 braces: usize,
110 quoted: bool,
111 escaped: bool,
112}
113
114impl HeaderScanner {
115 fn capture(&mut self, source: &str, offset: usize, cursor: usize) {
116 if offset != cursor || self.braces != 0 || self.quoted {
117 return;
118 }
119 self.context.prefix = match self.state {
120 HeaderState::Key => Some(String::new()),
121 HeaderState::KeyName(start) => Some(source[start..offset].to_owned()),
122 _ => None,
123 };
124 }
125
126 fn line(&mut self, source: &str, range: Range<usize>, cursor: usize) -> Option<usize> {
128 for offset in range.clone() {
129 self.capture(source, offset, cursor);
130 let byte = source.as_bytes()[offset];
131 if self.quoted {
132 if self.escaped {
133 self.escaped = false;
134 } else if byte == b'\\' {
135 self.escaped = true;
136 } else if byte == b'"' {
137 self.quoted = false;
138 }
139 continue;
140 }
141 match byte {
142 b'#' => {
143 self.end_line();
144 return None;
145 }
146 b'"' => {
147 self.quoted = true;
148 self.state = HeaderState::AfterValue;
149 continue;
150 }
151 b'{' => {
152 self.braces += 1;
153 self.state = HeaderState::AfterValue;
154 continue;
155 }
156 b'}' => {
157 self.braces = self.braces.saturating_sub(1);
158 continue;
159 }
160 b')' if self.braces == 0 => return Some(offset),
161 _ if self.braces > 0 => continue,
162 _ => {}
163 }
164 match self.state {
165 HeaderState::Key if byte.is_ascii_alphabetic() || byte == b'_' => {
166 self.context.keys.push(offset..offset + 1);
167 self.state = HeaderState::KeyName(offset);
168 }
169 HeaderState::KeyName(_) if byte.is_ascii_alphanumeric() || byte == b'_' => {
170 if let Some(key) = self.context.keys.last_mut() {
171 key.end = offset + 1;
172 }
173 }
174 HeaderState::KeyName(_) | HeaderState::Equals => {
175 self.state = if byte == b'=' {
176 HeaderState::Value
177 } else {
178 HeaderState::Equals
179 };
180 }
181 HeaderState::Value if !byte.is_ascii_whitespace() => {
182 self.state = HeaderState::Scalar
183 }
184 HeaderState::Scalar | HeaderState::AfterValue
185 if byte.is_ascii_whitespace() || byte == b',' =>
186 {
187 self.state = HeaderState::Key;
188 }
189 _ => {}
190 }
191 }
192 self.capture(source, range.end, cursor);
193 self.end_line();
194 None
195 }
196
197 fn end_line(&mut self) {
198 match self.state {
199 HeaderState::KeyName(_) => self.state = HeaderState::Equals,
200 HeaderState::Scalar | HeaderState::AfterValue => self.state = HeaderState::Key,
201 _ => {}
202 }
203 }
204}
205
206fn annotation_context(
207 source: &str,
208 cursor: usize,
209 registry: &Registry,
210) -> Option<(&'static AnnotationSchema, AnnotationContext)> {
211 let parsed = parse_clausewitz_content(game_path("editor.txt"), source);
214 let statements = &parsed.ast.statements;
215 let mut index = 0;
216 while index < statements.len() {
217 let AstStatement::Comment { text, span } = &statements[index] else {
218 index += 1;
219 continue;
220 };
221 let Some(schema) = annotation_schema(text, registry).filter(|schema| {
222 text.trim_start()
223 .strip_prefix(schema.name)
224 .is_some_and(|rest| rest.trim_start().starts_with('('))
225 }) else {
226 index += 1;
227 continue;
228 };
229 let mut scanner = HeaderScanner::default();
230 let first = index;
231 let mut contains_cursor = false;
232 while let Some(AstStatement::Comment { span: comment, .. }) = statements.get(index) {
233 let line_start = source[..comment.start.offset]
234 .rfind('\n')
235 .map_or(0, |offset| offset + 1);
236 if !source[line_start..comment.start.offset].trim().is_empty() {
237 break;
238 }
239 let start = if index == first {
240 comment.start.offset
241 + source[comment.start.offset..comment.end.offset].find('(')?
242 + 1
243 } else {
244 comment.start.offset + 1
245 };
246 index += 1;
247 let close = scanner.line(source, start..comment.end.offset, cursor);
248 let end = close.unwrap_or(comment.end.offset);
249 contains_cursor |= (start..=end).contains(&cursor);
250 if close.is_some() {
251 break;
252 }
253 }
254 if contains_cursor {
255 return Some((schema, scanner.context));
256 }
257 if index == first {
258 index += 1;
259 }
260 if span.start.offset > cursor {
261 break;
262 }
263 }
264 None
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use crate::builtin::REGISTRY;
271
272 fn cursor(marked: &str) -> (String, usize) {
273 let offset = marked.find('|').expect("cursor marker");
274 (marked.replacen('|', "", 1), offset)
275 }
276
277 #[test]
278 fn completes_parameters_of_the_annotation_under_the_cursor() {
279 let (source, offset) = cursor("#test(time=1444.11.11, ta|)");
280 let items = completions(&source, offset, ®ISTRY).expect("annotation completions");
281 assert_eq!(items.len(), 1);
282 assert_eq!(items[0].label, "tag");
283 assert!(items[0].insert_text.contains("SWE"));
284 assert!(items[0].detail.contains("country tag"));
285
286 let (source, offset) = cursor("#xfail(reason=\"x\", st|)");
287 let items = completions(&source, offset, ®ISTRY).expect("modifier completions");
288 assert_eq!(
289 items
290 .iter()
291 .map(|item| item.label.as_str())
292 .collect::<Vec<_>>(),
293 ["strict"]
294 );
295 }
296
297 #[test]
298 fn continues_after_multiline_blocks_without_repeating_parameters() {
299 let (source, offset) = cursor("#test(time=1444.11.11,\n# effect={ log=\"๐ )\" },\n# |)");
300 let items = completions(&source, offset, ®ISTRY).expect("continuation completions");
301 assert!(items.iter().any(|item| item.label == "tag"));
302 assert!(
303 !items
304 .iter()
305 .any(|item| item.label == "time" || item.label == "effect")
306 );
307 }
308
309 #[test]
310 fn continues_after_a_native_comment_inside_the_annotation() {
311 let (source, offset) = cursor("#test(time=1444.11.11# initial date\n#ta|)");
312 let items = completions(&source, offset, ®ISTRY).expect("annotation continuation");
313 assert_eq!(items.len(), 1);
314 assert_eq!(items[0].label, "tag");
315 }
316
317 #[test]
318 fn does_not_complete_inside_values_or_native_blocks() {
319 for marked in [
320 "#test(time=|)",
321 "#test(name=\"ta|\")",
322 "#test(expect={ ta| })",
323 "#test(effect={\n# ta|\n# })",
324 "#test(time=1444.11.11 # ta|\n# )",
325 ] {
326 let (source, offset) = cursor(marked);
327 assert!(
328 completions(&source, offset, ®ISTRY)
329 .expect("annotation context")
330 .is_empty(),
331 "{marked}"
332 );
333 assert!(hover(&source, offset, ®ISTRY).is_none(), "{marked}");
334 }
335 }
336
337 #[test]
338 fn ignores_ordinary_comments_strings_inline_comments_and_script() {
339 for marked in [
340 "# ordinary #test(ta|)",
341 "name=\"#test(ta|)\"",
342 "name=\"multiline\n#test(ta|)\n\"",
343 "name=yes #test(ta|)",
344 "#testing(ta|)",
345 "#test(time=1444.11.11)\nta|",
346 "#test(time=1444.11.11)\n# ta|",
347 ] {
348 let (source, offset) = cursor(marked);
349 assert!(
350 completions(&source, offset, ®ISTRY).is_none(),
351 "{marked}"
352 );
353 assert!(hover(&source, offset, ®ISTRY).is_none(), "{marked}");
354 }
355 }
356
357 #[test]
358 fn hover_uses_schema_metadata_and_byte_ranges() {
359 let (source, offset) = cursor("#test(name=\"๐\", ta|g=SWE)");
360 let info = hover(&source, offset, ®ISTRY).expect("parameter hover");
361 assert!(info.markdown.contains("country tag"));
362 assert!(info.markdown.contains("optional"));
365 assert!(info.markdown.contains("SWE"));
366 assert!(info.markdown.contains("hidden=yes"));
367 assert_eq!(&source[info.range], "tag");
368 }
369}