Skip to main content

foch_annotation/
source.rs

1//! Mod-relative paths and positions inside source text.
2
3use foch::game::eu4::script::parser::SpanRange;
4use serde::{Deserialize, Serialize};
5use std::fmt;
6
7/// A `/`-separated path relative to the mod root, without `.` or `..`
8/// segments. It never names a location outside the mod.
9#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
10#[serde(try_from = "String", into = "String")]
11pub struct RelPath(String);
12
13impl RelPath {
14	pub fn new(value: &str) -> Result<Self, String> {
15		let normalized = value.replace('\\', "/");
16		if normalized.is_empty()
17			|| normalized.starts_with('/')
18			|| normalized.contains(':')
19			|| normalized
20				.split('/')
21				.any(|segment| segment.is_empty() || segment == "." || segment == "..")
22			|| normalized.chars().any(char::is_control)
23		{
24			return Err(format!("invalid mod-relative path {value:?}"));
25		}
26		Ok(Self(normalized))
27	}
28
29	pub fn as_str(&self) -> &str {
30		&self.0
31	}
32
33	/// Whether the path lies below `directory` (given without a trailing `/`).
34	pub fn is_below(&self, directory: &str) -> bool {
35		self.0
36			.strip_prefix(directory)
37			.is_some_and(|rest| rest.starts_with('/'))
38	}
39
40	pub fn file_stem(&self) -> &str {
41		let name = self.0.rsplit('/').next().unwrap_or(&self.0);
42		name.rsplit_once('.').map_or(name, |(stem, _)| stem)
43	}
44}
45
46impl TryFrom<String> for RelPath {
47	type Error = String;
48
49	fn try_from(value: String) -> Result<Self, String> {
50		Self::new(&value)
51	}
52}
53
54impl From<RelPath> for String {
55	fn from(value: RelPath) -> Self {
56		value.0
57	}
58}
59
60impl fmt::Display for RelPath {
61	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
62		formatter.write_str(&self.0)
63	}
64}
65
66/// UTF-8 byte offset with 1-based line and column, as produced by the parser.
67/// Editors convert to their own encoding at their boundary.
68#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
69pub struct SourcePosition {
70	pub offset: usize,
71	pub line: usize,
72	pub column: usize,
73}
74
75#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
76pub struct SourceSpan {
77	pub path: RelPath,
78	pub start: SourcePosition,
79	pub end: SourcePosition,
80}
81
82impl SourceSpan {
83	pub fn from_parser(path: &RelPath, span: &SpanRange) -> Self {
84		let position = |point: &foch::game::eu4::script::parser::Span| SourcePosition {
85			offset: point.offset,
86			line: point.line,
87			column: point.column,
88		};
89		Self {
90			path: path.clone(),
91			start: position(&span.start),
92			end: position(&span.end),
93		}
94	}
95}
96
97impl fmt::Display for SourceSpan {
98	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
99		write!(
100			formatter,
101			"{}:{}:{}",
102			self.path, self.start.line, self.start.column
103		)
104	}
105}
106
107#[cfg(test)]
108mod tests {
109	use super::*;
110
111	#[test]
112	fn relative_paths_stay_inside_the_mod() {
113		assert_eq!(
114			RelPath::new("events\\a.txt").unwrap().as_str(),
115			"events/a.txt"
116		);
117		for bad in ["", "/abs", "a/../b", "./a", "a//b", "C:/x", "a\nb"] {
118			assert!(RelPath::new(bad).is_err(), "{bad:?}");
119		}
120		let path = RelPath::new("tests/events/helpers.txt").unwrap();
121		assert!(path.is_below("tests"));
122		assert!(path.is_below("tests/events"));
123		assert!(!RelPath::new("testsuite/a.txt").unwrap().is_below("tests"));
124		assert_eq!(path.file_stem(), "helpers");
125	}
126}