foch_annotation/
source.rs1use foch::game::eu4::script::parser::SpanRange;
4use serde::{Deserialize, Serialize};
5use std::fmt;
6
7#[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 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#[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}