1use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9#[serde(rename_all = "camelCase")]
10pub struct StackFrame {
11 pub id: i32,
13 pub name: String,
15 pub source: Source,
17 pub line: i32,
19 pub column: i32,
21 #[serde(skip_serializing_if = "Option::is_none")]
23 pub end_line: Option<i32>,
24 #[serde(skip_serializing_if = "Option::is_none")]
26 pub end_column: Option<i32>,
27}
28
29impl StackFrame {
30 #[must_use]
32 pub fn new(id: i32, name: impl Into<String>, source: Source, line: i32) -> Self {
33 Self { id, name: name.into(), source, line, column: 1, end_line: None, end_column: None }
34 }
35
36 #[must_use]
38 pub fn with_column(mut self, column: i32) -> Self {
39 self.column = column;
40 self
41 }
42
43 #[must_use]
45 pub fn with_end(mut self, end_line: i32, end_column: i32) -> Self {
46 self.end_line = Some(end_line);
47 self.end_column = Some(end_column);
48 self
49 }
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
56pub struct Source {
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub name: Option<String>,
60 pub path: String,
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub source_reference: Option<i32>,
65}
66
67impl Source {
68 #[must_use]
70 pub fn new(path: impl Into<String>) -> Self {
71 let path = path.into();
72 let path_name = std::path::Path::new(&path)
73 .file_name()
74 .and_then(|name| name.to_str())
75 .map(ToOwned::to_owned);
76 let name = if path_name.as_deref() == Some(path.as_str()) && path.contains('\\') {
77 path.rsplit('\\').find(|segment| !segment.is_empty()).map(ToOwned::to_owned)
78 } else {
79 path_name
80 };
81
82 Self { name, path, source_reference: None }
83 }
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
90#[serde(rename_all = "camelCase")]
91pub struct Variable {
92 pub name: String,
94 pub value: String,
96 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
98 pub type_: Option<String>,
99 pub variables_reference: i32,
101 #[serde(skip_serializing_if = "Option::is_none")]
103 pub named_variables: Option<i32>,
104 #[serde(skip_serializing_if = "Option::is_none")]
106 pub indexed_variables: Option<i32>,
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112
113 #[test]
114 fn stack_frame_new_defaults() {
115 let src = Source::new("/path/to/script.pl");
116 let frame = StackFrame::new(1, "main::foo", src, 42);
117 assert_eq!(frame.id, 1);
118 assert_eq!(frame.name, "main::foo");
119 assert_eq!(frame.line, 42);
120 assert_eq!(frame.column, 1);
121 assert!(frame.end_line.is_none());
122 assert!(frame.end_column.is_none());
123 }
124
125 #[test]
126 fn stack_frame_with_column_and_end() {
127 let src = Source::new("/a.pl");
128 let frame = StackFrame::new(2, "foo", src, 10).with_column(5).with_end(10, 20);
129 assert_eq!(frame.column, 5);
130 assert_eq!(frame.end_line, Some(10));
131 assert_eq!(frame.end_column, Some(20));
132 }
133
134 #[test]
135 fn source_new_extracts_filename() {
136 let src = Source::new("/path/to/Module.pm");
137 assert_eq!(src.path, "/path/to/Module.pm");
138 assert_eq!(src.name, Some("Module.pm".to_string()));
139 assert!(src.source_reference.is_none());
140 }
141
142 #[test]
143 fn stack_frame_serde_round_trip() -> serde_json::Result<()> {
144 let src = Source::new("/script.pl");
145 let frame = StackFrame::new(1, "run", src, 5);
146 let json = serde_json::to_string(&frame)?;
147 let back: StackFrame = serde_json::from_str(&json)?;
148 assert_eq!(back.id, 1);
149 assert_eq!(back.line, 5);
150 Ok(())
151 }
152
153 #[test]
154 fn stack_frame_optional_fields_omitted_in_json() -> serde_json::Result<()> {
155 let src = Source::new("/a.pl");
156 let frame = StackFrame::new(1, "foo", src, 1);
157 let json = serde_json::to_string(&frame)?;
158 assert!(!json.contains("endLine"), "endLine should be absent: {json}");
159 assert!(!json.contains("endColumn"), "endColumn should be absent: {json}");
160 Ok(())
161 }
162
163 #[test]
164 fn variable_type_field_serializes_as_type_not_type_underscore() -> serde_json::Result<()> {
165 let var = Variable {
166 name: "$x".to_string(),
167 value: "42".to_string(),
168 type_: Some("SCALAR".to_string()),
169 variables_reference: 0,
170 named_variables: None,
171 indexed_variables: None,
172 };
173 let json = serde_json::to_string(&var)?;
174 assert!(json.contains("\"type\":"), "must serialize as 'type' not 'type_': {json}");
175 assert!(!json.contains("type_"), "must not leak Rust field name: {json}");
176 Ok(())
177 }
178
179 #[test]
180 fn variable_optional_fields_omitted_when_none() -> serde_json::Result<()> {
181 let var = Variable {
182 name: "$x".to_string(),
183 value: "1".to_string(),
184 type_: None,
185 variables_reference: 0,
186 named_variables: None,
187 indexed_variables: None,
188 };
189 let json = serde_json::to_string(&var)?;
190 assert!(!json.contains("namedVariables"), "absent: {json}");
191 assert!(!json.contains("indexedVariables"), "absent: {json}");
192 Ok(())
193 }
194
195 #[test]
196 fn variable_serde_round_trip() -> serde_json::Result<()> {
197 let var = Variable {
198 name: "@arr".to_string(),
199 value: "(3 elements)".to_string(),
200 type_: Some("ARRAY".to_string()),
201 variables_reference: 7,
202 named_variables: None,
203 indexed_variables: Some(3),
204 };
205 let json = serde_json::to_string(&var)?;
206 let back: Variable = serde_json::from_str(&json)?;
207 assert_eq!(back.variables_reference, 7);
208 assert_eq!(back.indexed_variables, Some(3));
209 Ok(())
210 }
211}