1use std::fmt::{self, Display, Formatter};
2
3use camino::Utf8PathBuf;
4use ecow::EcoString;
5
6use crate::plan::{EchoSite, SourceContext};
7use crate::runtime::Value;
8
9pub trait EchoSink {
10 fn emit(&mut self, output: EchoOutput);
11}
12
13#[derive(Debug, Clone, PartialEq)]
14pub struct EchoOutput {
15 location: EchoLocation,
16 message: Option<EcoString>,
17 value: Value,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum EchoLocation {
22 Resolved {
23 site: EchoSite,
24 path: Utf8PathBuf,
25 line: usize,
26 },
27 Site(EchoSite),
28}
29
30impl EchoOutput {
31 pub fn new(location: EchoLocation, message: Option<EcoString>, value: Value) -> Self {
32 Self {
33 location,
34 message,
35 value,
36 }
37 }
38
39 pub fn location(&self) -> &EchoLocation {
40 &self.location
41 }
42
43 pub fn message(&self) -> Option<&EcoString> {
44 self.message.as_ref()
45 }
46
47 pub fn value(&self) -> &Value {
48 &self.value
49 }
50}
51
52impl EchoLocation {
53 pub fn resolved(site: EchoSite, path: impl Into<Utf8PathBuf>, line: usize) -> Self {
54 Self::Resolved {
55 site,
56 path: path.into(),
57 line,
58 }
59 }
60
61 pub fn site(site: EchoSite) -> Self {
62 Self::Site(site)
63 }
64
65 pub fn echo_site(&self) -> &EchoSite {
66 match self {
67 Self::Resolved { site, .. } | Self::Site(site) => site,
68 }
69 }
70
71 pub fn path(&self) -> Option<&Utf8PathBuf> {
72 match self {
73 Self::Resolved { path, .. } => Some(path),
74 Self::Site(_) => None,
75 }
76 }
77
78 pub fn line(&self) -> Option<usize> {
79 match self {
80 Self::Resolved { line, .. } => Some(*line),
81 Self::Site(_) => None,
82 }
83 }
84
85 pub(crate) fn from_context(site: EchoSite, context: Option<&SourceContext>) -> Self {
86 match context {
87 Some(context) => {
88 let line = context
89 .source()
90 .as_bytes()
91 .iter()
92 .take(site.span().start())
93 .filter(|byte| **byte == b'\n')
94 .count()
95 + 1;
96 Self::resolved(site, context.path().clone(), line)
97 }
98 None => Self::site(site),
99 }
100 }
101}
102
103impl EchoSink for Vec<EchoOutput> {
104 fn emit(&mut self, output: EchoOutput) {
105 self.push(output);
106 }
107}
108
109impl Display for EchoOutput {
110 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
111 let mut output = String::new();
112 match &self.location {
113 EchoLocation::Resolved { path, line, .. } => {
114 output.push_str(path.as_str());
115 output.push(':');
116 output.push_str(&line.to_string());
117 }
118 EchoLocation::Site(site) => {
119 output.push_str(site.module());
120 output.push_str("::");
121 output.push_str(site.function());
122 output.push('@');
123 output.push_str(&site.span().start().to_string());
124 output.push_str("..");
125 output.push_str(&site.span().end().to_string());
126 }
127 }
128 if let Some(message) = &self.message {
129 output.push(' ');
130 output.push_str(message);
131 }
132 output.push('\n');
133 self.value.inspect().write_to(&mut output);
134 formatter.write_str(&output)
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::{EchoLocation, EchoOutput, EchoSink};
141 use crate::{EchoSite, SourceContext, SourceSpan, Value};
142
143 #[test]
144 fn resolved_location_preserves_site_path_and_one_based_line() {
145 let site = EchoSite::new("main".into(), "run".into(), SourceSpan::new(13, 17));
146 let context = SourceContext::new("src/main.gleam", "first\nsecond\nthird");
147 let location = EchoLocation::from_context(site.clone(), Some(&context));
148
149 assert_eq!(location.echo_site(), &site);
150 assert_eq!(
151 location.path().map(|path| path.as_str()),
152 Some("src/main.gleam"),
153 );
154 assert_eq!(location.line(), Some(3));
155 }
156
157 #[test]
158 fn site_location_preserves_unresolved_site() {
159 let site = EchoSite::new("main".into(), "run".into(), SourceSpan::new(4, 8));
160 let location = EchoLocation::from_context(site.clone(), None);
161
162 assert_eq!(location.echo_site(), &site);
163 assert_eq!(location.path(), None);
164 assert_eq!(location.line(), None);
165 }
166
167 #[test]
168 fn output_preserves_structured_fields_and_resolved_display() {
169 let site = EchoSite::new("main".into(), "run".into(), SourceSpan::new(4, 8));
170 let output = EchoOutput::new(
171 EchoLocation::resolved(site.clone(), "src/main.gleam", 12),
172 Some("selected".into()),
173 Value::Bool(true),
174 );
175
176 assert_eq!(output.location().echo_site(), &site);
177 assert_eq!(
178 output.message().map(|message| message.as_str()),
179 Some("selected")
180 );
181 assert_eq!(output.value(), &Value::Bool(true));
182 assert_eq!(output.to_string(), "src/main.gleam:12 selected\nTrue",);
183 }
184
185 #[test]
186 fn output_formats_site_fallback_without_message() {
187 let output = EchoOutput::new(
188 EchoLocation::site(EchoSite::new(
189 "main".into(),
190 "run".into(),
191 SourceSpan::new(4, 8),
192 )),
193 None,
194 Value::Int(1.into()),
195 );
196
197 assert_eq!(output.message(), None);
198 assert_eq!(output.to_string(), "main::run@4..8\n1");
199 }
200
201 #[test]
202 fn vector_collects_owned_echo_outputs() {
203 let output = EchoOutput::new(
204 EchoLocation::site(EchoSite::new(
205 "main".into(),
206 "run".into(),
207 SourceSpan::new(0, 1),
208 )),
209 None,
210 Value::Nil,
211 );
212 let mut outputs = Vec::new();
213
214 outputs.emit(output.clone());
215
216 assert_eq!(outputs, vec![output]);
217 }
218
219 #[test]
220 fn run_without_source_context_emits_site_location() {
221 let typed = crate::compile_typed_module(
222 "main",
223 "main.gleam",
224 "pub fn main() { echo 1 as \"fallback\" }",
225 )
226 .expect("source should compile");
227 let module = crate::plan_module(typed).expect("source should plan");
228 let plan = crate::ExecutionPlan::from_module_plan(module);
229 let mut outputs = Vec::new();
230
231 assert_eq!(
232 crate::run_main(&plan, &mut outputs),
233 Ok(Value::Int(1.into()))
234 );
235 assert_eq!(
236 outputs,
237 vec![EchoOutput::new(
238 EchoLocation::site(EchoSite::new(
239 "main".into(),
240 "main".into(),
241 SourceSpan::new(16, 36),
242 )),
243 Some("fallback".into()),
244 Value::Int(1.into()),
245 )],
246 );
247 }
248}