Skip to main content

foch_annotation/
editor.rs

1//! Completion and hover inside annotation comments.
2//!
3//! Positions are UTF-8 byte offsets into the source; editors convert to their
4//! own encoding at their boundary.
5
6use 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
26/// `Some`, including an empty list, claims an annotation context: callers
27/// should not fall back to native-script completions inside these comments.
28pub 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	/// Scan only original comment contents; offsets always remain source offsets.
127	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	// The native AST prevents comment-looking text in multiline strings from
212	// becoming annotations, without changing the grammar or source text.
213	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, &REGISTRY).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, &REGISTRY).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, &REGISTRY).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, &REGISTRY).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, &REGISTRY)
329					.expect("annotation context")
330					.is_empty(),
331				"{marked}"
332			);
333			assert!(hover(&source, offset, &REGISTRY).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, &REGISTRY).is_none(),
351				"{marked}"
352			);
353			assert!(hover(&source, offset, &REGISTRY).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, &REGISTRY).expect("parameter hover");
361		assert!(info.markdown.contains("country tag"));
362		// tag is required unless #parametrize supplies it, so the schema marks
363		// it optional and the description states the real rule.
364		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}