Skip to main content

foch_annotation/
extract.rs

1//! Find annotations in comments and attach them to the next definition.
2//!
3//! An annotation is `#name(arguments)`, possibly continued over several full
4//! comment lines that each start with `#`. Arguments are Clausewitz script,
5//! parsed at their original byte offsets so every span points into the file.
6//! Extraction never stops at the first problem; malformed annotations become
7//! diagnostics and are left out of the result.
8
9use crate::diagnostic::{Code, Diagnostic, Severity};
10use crate::schema::{AnnotationKind, AnnotationSchema, Registry};
11use crate::source::{RelPath, SourceSpan};
12use crate::value::{Value, game_path, identifier, parse_value, scalar};
13use foch::game::eu4::script::parser::{
14	AstStatement, AstValue, ScalarValue, Span, SpanRange, parse_clausewitz_content,
15};
16use serde::Serialize;
17
18#[derive(Clone, Debug, Serialize)]
19pub struct Argument {
20	pub name: String,
21	pub span: SourceSpan,
22	pub value: Value,
23}
24
25#[derive(Clone, Debug, Serialize)]
26pub struct Annotation {
27	pub schema: &'static AnnotationSchema,
28	pub span: SourceSpan,
29	pub arguments: Vec<Argument>,
30	/// Unnamed arguments, such as labels.
31	pub positional: Vec<(String, SourceSpan)>,
32}
33
34impl Annotation {
35	pub fn name(&self) -> &'static str {
36		self.schema.name
37	}
38
39	pub fn get(&self, name: &str) -> Option<&Value> {
40		self.arguments
41			.iter()
42			.find(|argument| argument.name == name)
43			.map(|argument| &argument.value)
44	}
45}
46
47/// A primary annotation with the modifiers written before it.
48#[derive(Clone, Debug, Serialize)]
49pub struct Applied {
50	pub annotation: Annotation,
51	pub modifiers: Vec<Annotation>,
52}
53
54#[derive(Clone, Debug, Serialize)]
55pub struct Target {
56	pub key: String,
57	/// The definition's scalar `id` field, if it has one.
58	pub id: Option<String>,
59	pub span: SourceSpan,
60}
61
62#[derive(Clone, Debug, Serialize)]
63pub struct Attachment {
64	pub target: Target,
65	pub annotations: Vec<Applied>,
66}
67
68#[derive(Clone, Debug, Default, Serialize)]
69pub struct Extraction {
70	pub attachments: Vec<Attachment>,
71	pub diagnostics: Vec<Diagnostic>,
72}
73
74pub fn extract(path: &RelPath, text: &str, registry: &Registry) -> Extraction {
75	let parsed = parse_clausewitz_content(game_path(path.as_str()), text);
76	if !parsed.diagnostics.is_empty() {
77		return Extraction {
78			attachments: Vec::new(),
79			diagnostics: parsed
80				.diagnostics
81				.into_iter()
82				.map(|diagnostic| {
83					Diagnostic::error(
84						Code::Parse,
85						SourceSpan::from_parser(path, &diagnostic.span),
86						diagnostic.message,
87					)
88				})
89				.collect(),
90		};
91	}
92	extract_parsed(path, text, &parsed.ast.statements, registry)
93}
94
95/// Like [`extract`], for callers that already parsed `text`.
96pub fn extract_parsed(
97	path: &RelPath,
98	text: &str,
99	statements: &[AstStatement],
100	registry: &Registry,
101) -> Extraction {
102	let mut out = Extraction::default();
103	let mut modifiers: Vec<Annotation> = Vec::new();
104	let mut primaries: Vec<Applied> = Vec::new();
105	let error = |code, span: &SpanRange, message: String| {
106		Diagnostic::error(code, SourceSpan::from_parser(path, span), message)
107	};
108	let mut index = 0;
109	while index < statements.len() {
110		match &statements[index] {
111			AstStatement::Comment {
112				text: comment,
113				span,
114			} => {
115				let Some(schema) = annotation_schema(comment, registry) else {
116					if let Some(name) = unknown_annotation(comment) {
117						out.diagnostics.push(Diagnostic {
118							severity: Severity::Warning,
119							..error(
120								Code::UnknownAnnotation,
121								span,
122								format!("#{name}(...) is not a known annotation"),
123							)
124						});
125					}
126					index += 1;
127					continue;
128				};
129				let start = span.clone();
130				let annotation = read_annotation(statements, &mut index, text, schema.name)
131					.map_err(|message| vec![error(Code::Malformed, &start, message)])
132					.and_then(|raw| parse_arguments(path, schema, raw));
133				match annotation {
134					Err(diagnostics) => out.diagnostics.extend(diagnostics),
135					Ok(annotation) => match schema.kind {
136						AnnotationKind::Modifier { .. } => modifiers.push(annotation),
137						AnnotationKind::Primary => {
138							let mut applied = Applied {
139								annotation,
140								modifiers: Vec::new(),
141							};
142							for modifier in modifiers.drain(..) {
143								attach_modifier(&mut applied, modifier, &mut out.diagnostics);
144							}
145							primaries.push(applied);
146						}
147					},
148				}
149			}
150			statement => {
151				for modifier in modifiers.drain(..) {
152					let of = match modifier.schema.kind {
153						AnnotationKind::Modifier { of } => of,
154						AnnotationKind::Primary => unreachable!("only modifiers wait"),
155					};
156					out.diagnostics.push(Diagnostic::error(
157						Code::Orphan,
158						modifier.span.clone(),
159						format!("#{} must precede a #{of}", modifier.name()),
160					));
161				}
162				if !primaries.is_empty() {
163					let target = target(path, statement);
164					let mut annotations = Vec::new();
165					for applied in primaries.drain(..) {
166						match check_target(statement, applied.annotation.schema) {
167							Ok(()) => annotations.push(applied),
168							Err(message) => out.diagnostics.push(Diagnostic::error(
169								Code::UnsupportedTarget,
170								applied.annotation.span.clone(),
171								message,
172							)),
173						}
174					}
175					if !annotations.is_empty() {
176						out.attachments.push(Attachment {
177							target,
178							annotations,
179						});
180					}
181				}
182				nested(path, statement, registry, &mut out.diagnostics);
183				index += 1;
184			}
185		}
186	}
187	for annotation in modifiers
188		.into_iter()
189		.chain(primaries.into_iter().map(|applied| applied.annotation))
190	{
191		out.diagnostics.push(Diagnostic::error(
192			Code::Orphan,
193			annotation.span.clone(),
194			format!("#{} has no following definition", annotation.name()),
195		));
196	}
197	out
198}
199
200fn attach_modifier(applied: &mut Applied, modifier: Annotation, diagnostics: &mut Vec<Diagnostic>) {
201	let primary = applied.annotation.name();
202	if modifier.schema.kind != (AnnotationKind::Modifier { of: primary }) {
203		diagnostics.push(Diagnostic::error(
204			Code::Orphan,
205			modifier.span.clone(),
206			format!("#{} does not apply to #{primary}", modifier.name()),
207		));
208	} else if applied
209		.modifiers
210		.iter()
211		.any(|existing| existing.name() == modifier.name())
212	{
213		diagnostics.push(Diagnostic::error(
214			Code::DuplicateModifier,
215			modifier.span.clone(),
216			format!(
217				"#{} is given twice for the same #{primary}",
218				modifier.name()
219			),
220		));
221	} else {
222		applied.modifiers.push(modifier);
223	}
224}
225
226/// The registered annotation a comment starts, if any. A bare primary name,
227/// such as `#test` without arguments, is still recognized so it is reported
228/// instead of silently ignored.
229pub fn annotation_schema(comment: &str, registry: &Registry) -> Option<&'static AnnotationSchema> {
230	let text = comment.trim_start();
231	registry.schemas.iter().find(|schema| {
232		text.strip_prefix(schema.name).is_some_and(|rest| {
233			rest.trim_start().starts_with('(')
234				|| (schema.kind == AnnotationKind::Primary && rest.trim().is_empty())
235		})
236	})
237}
238
239/// `#tset(...)`-like comments: a lowercase identifier immediately followed by `(`.
240fn unknown_annotation(comment: &str) -> Option<&str> {
241	let text = comment.trim_start();
242	let name = &text[..text.find('(')?];
243	(!name.is_empty()
244		&& name
245			.bytes()
246			.all(|byte| byte.is_ascii_lowercase() || byte == b'_'))
247	.then_some(name)
248}
249
250fn nested(
251	path: &RelPath,
252	statement: &AstStatement,
253	registry: &Registry,
254	out: &mut Vec<Diagnostic>,
255) {
256	let value = match statement {
257		AstStatement::Assignment { value, .. } | AstStatement::Item { value, .. } => value,
258		AstStatement::Comment { .. } => return,
259	};
260	if let AstValue::Block { items, .. } = value {
261		for item in items {
262			match item {
263				AstStatement::Comment { text, span } => {
264					if let Some(schema) = annotation_schema(text, registry) {
265						out.push(Diagnostic::error(
266							Code::Nested,
267							SourceSpan::from_parser(path, span),
268							format!("#{} must precede a top-level definition", schema.name),
269						));
270					}
271				}
272				_ => nested(path, item, registry, out),
273			}
274		}
275	}
276}
277
278fn statement_span(statement: &AstStatement) -> &SpanRange {
279	match statement {
280		AstStatement::Assignment { span, .. }
281		| AstStatement::Item { span, .. }
282		| AstStatement::Comment { span, .. } => span,
283	}
284}
285
286fn target(path: &RelPath, statement: &AstStatement) -> Target {
287	let (key, id) = match statement {
288		AstStatement::Assignment {
289			key,
290			value: AstValue::Block { items, .. },
291			..
292		} => (key.clone(), single_scalar(items, "id")),
293		AstStatement::Assignment { key, .. } => (key.clone(), None),
294		_ => (String::new(), None),
295	};
296	Target {
297		key,
298		id,
299		span: SourceSpan::from_parser(path, statement_span(statement)),
300	}
301}
302
303fn single_scalar(items: &[AstStatement], name: &str) -> Option<String> {
304	let mut values = items.iter().filter_map(|item| match item {
305		AstStatement::Assignment { key, value, .. } if key == name => Some(value),
306		_ => None,
307	});
308	let value = values.next()?;
309	values
310		.next()
311		.is_none()
312		.then(|| scalar(value).ok())
313		.flatten()
314}
315
316fn check_target(statement: &AstStatement, schema: &AnnotationSchema) -> Result<(), String> {
317	let Some(rule) = schema.target else {
318		return Ok(());
319	};
320	let AstStatement::Assignment {
321		key,
322		value: AstValue::Block { items, .. },
323		..
324	} = statement
325	else {
326		return Err(format!(
327			"#{} must be followed by a {} definition",
328			schema.name, rule.key
329		));
330	};
331	if key != rule.key {
332		return Err(format!("#{} supports {}, not {key}", schema.name, rule.key));
333	}
334	for field in rule.required_yes {
335		let set = items
336			.iter()
337			.filter(|item| matches!(item, AstStatement::Assignment { key, .. } if key == field));
338		let values: Vec<_> = set.collect();
339		let yes = matches!(
340			values.as_slice(),
341			[AstStatement::Assignment {
342				value: AstValue::Scalar {
343					value: ScalarValue::Bool(true),
344					..
345				},
346				..
347			}]
348		);
349		if !yes {
350			return Err(format!(
351				"#{} requires the {} to declare {field} = yes exactly once",
352				schema.name, rule.key
353			));
354		}
355	}
356	Ok(())
357}
358
359struct RawAnnotation {
360	span: SpanRange,
361	statements: Vec<AstStatement>,
362	/// Annotation text at the original byte positions, blanks elsewhere.
363	source: String,
364}
365
366fn parse_arguments(
367	path: &RelPath,
368	schema: &'static AnnotationSchema,
369	raw: RawAnnotation,
370) -> Result<Annotation, Vec<Diagnostic>> {
371	let span = SourceSpan::from_parser(path, &raw.span);
372	let mut diagnostics = Vec::new();
373	let mut annotation = Annotation {
374		schema,
375		span: span.clone(),
376		arguments: Vec::new(),
377		positional: Vec::new(),
378	};
379	let mut given = Vec::new();
380	for statement in &raw.statements {
381		match statement {
382			AstStatement::Comment { .. } => {}
383			AstStatement::Assignment {
384				key,
385				key_span,
386				value,
387				..
388			} => {
389				let key_span = SourceSpan::from_parser(path, key_span);
390				given.push(key.as_str());
391				let Some(param) = schema.param(key) else {
392					diagnostics.push(Diagnostic::error(
393						Code::UnknownParameter,
394						key_span,
395						format!("unknown #{} parameter {key}", schema.name),
396					));
397					continue;
398				};
399				if given[..given.len() - 1].contains(&key.as_str()) {
400					diagnostics.push(Diagnostic::error(
401						Code::DuplicateParameter,
402						key_span,
403						format!("duplicate #{} parameter {key}", schema.name),
404					));
405					continue;
406				}
407				match parse_value(path, &raw.source, value, param.value_type) {
408					Ok(parsed) => annotation.arguments.push(Argument {
409						name: key.clone(),
410						span: key_span,
411						value: parsed,
412					}),
413					Err(message) => diagnostics.push(Diagnostic::error(
414						Code::InvalidValue,
415						SourceSpan::from_parser(path, value.span()),
416						format!("{key}: {message}"),
417					)),
418				}
419			}
420			AstStatement::Item { value, span: item } => {
421				let item_span = SourceSpan::from_parser(path, item);
422				let label = scalar(value).ok().filter(|label| identifier(label));
423				match (schema.positional, label) {
424					(Some(_), Some(label)) => annotation.positional.push((label, item_span)),
425					(Some(_), None) => diagnostics.push(Diagnostic::error(
426						Code::InvalidValue,
427						item_span,
428						format!("#{} expects identifiers", schema.name),
429					)),
430					(None, _) => diagnostics.push(Diagnostic::error(
431						Code::Malformed,
432						item_span,
433						format!("#{} expects named key=value arguments", schema.name),
434					)),
435				}
436			}
437		}
438	}
439	for param in schema.params.iter().filter(|param| param.required) {
440		if !given.contains(&param.name) {
441			diagnostics.push(Diagnostic::error(
442				Code::MissingParameter,
443				span.clone(),
444				format!("missing required #{} parameter {}", schema.name, param.name),
445			));
446		}
447	}
448	if diagnostics.is_empty() {
449		Ok(annotation)
450	} else {
451		Err(diagnostics)
452	}
453}
454
455/// Reads one annotation, possibly continued over several full comment lines,
456/// and parses its arguments as Clausewitz script at the original offsets.
457fn read_annotation(
458	statements: &[AstStatement],
459	index: &mut usize,
460	source: &str,
461	name: &str,
462) -> Result<RawAnnotation, String> {
463	let AstStatement::Comment { span: start, .. } = &statements[*index] else {
464		unreachable!("annotations start at a comment")
465	};
466	let first = *index;
467	let start = start.clone();
468	let mut virtual_source: Vec<u8> = source
469		.bytes()
470		.map(|byte| if byte == b'\n' { b'\n' } else { b' ' })
471		.collect();
472	let mut quote = false;
473	let mut escape = false;
474	let mut braces = 0usize;
475	let mut end_offset = None;
476	while *index < statements.len() {
477		let AstStatement::Comment { span, .. } = &statements[*index] else {
478			break;
479		};
480		let line_start = source[..span.start.offset]
481			.rfind('\n')
482			.map_or(0, |offset| offset + 1);
483		if !source[line_start..span.start.offset].trim().is_empty() {
484			*index += 1;
485			return Err(format!(
486				"#{name} and its continuation must occupy full comment lines"
487			));
488		}
489		let mut content_start = span.start.offset + 1;
490		if *index == first {
491			let content = &source[content_start..span.end.offset];
492			let rest = content
493				.trim_start()
494				.strip_prefix(name)
495				.unwrap_or_default()
496				.trim_start();
497			if !rest.starts_with('(') {
498				*index += 1;
499				return Err(format!("expected #{name}(...)"));
500			}
501			content_start = span.end.offset - rest.len() + 1;
502		}
503		*index += 1;
504		if span.end.offset - start.start.offset > 65_536 {
505			return Err("annotation exceeds 64 KiB".into());
506		}
507		for offset in content_start..span.end.offset {
508			let byte = source.as_bytes()[offset];
509			if quote {
510				virtual_source[offset] = byte;
511				if escape {
512					escape = false;
513				} else if byte == b'\\' {
514					escape = true;
515				} else if byte == b'"' {
516					quote = false;
517				}
518				continue;
519			}
520			match byte {
521				b'#' => break,
522				b'"' => {
523					quote = true;
524					virtual_source[offset] = byte;
525				}
526				b'{' => {
527					braces += 1;
528					virtual_source[offset] = byte;
529				}
530				b'}' => {
531					braces = braces.checked_sub(1).ok_or("unmatched } in annotation")?;
532					virtual_source[offset] = byte;
533				}
534				b')' if braces == 0 => {
535					if !source[offset + 1..span.end.offset].trim().is_empty() {
536						return Err(format!("unexpected text after #{name}(...)"));
537					}
538					end_offset = Some(offset + 1);
539					break;
540				}
541				b'(' | b')' if braces == 0 => {
542					return Err("unexpected parenthesis in annotation arguments".into());
543				}
544				b',' if braces == 0 => {}
545				_ => virtual_source[offset] = byte,
546			}
547		}
548		if end_offset.is_some() {
549			break;
550		}
551	}
552	let end_offset = end_offset.ok_or_else(|| {
553		format!("unterminated #{name}(...); every continuation must be a comment")
554	})?;
555	virtual_source.truncate(end_offset);
556	let virtual_source =
557		String::from_utf8(virtual_source).map_err(|_| "invalid annotation encoding")?;
558	let parsed = parse_clausewitz_content(game_path("annotation.txt"), &virtual_source);
559	if let Some(error) = parsed.diagnostics.first() {
560		return Err(format!(
561			"invalid #{name} arguments at line {}: {}",
562			error.span.start.line, error.message
563		));
564	}
565	let end = Span {
566		line: source[..end_offset]
567			.bytes()
568			.filter(|&byte| byte == b'\n')
569			.count() + 1,
570		column: end_offset
571			- source[..end_offset]
572				.rfind('\n')
573				.map_or(0, |offset| offset + 1)
574			+ 1,
575		offset: end_offset,
576	};
577	Ok(RawAnnotation {
578		span: SpanRange {
579			start: start.start,
580			end,
581		},
582		statements: parsed.ast.statements,
583		source: virtual_source,
584	})
585}