Skip to main content

geam_core/runtime/
echo.rs

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}