geam 0.1.1

Experimental Rust-embedded execution runtime for typed Gleam programs
use std::fmt::{self, Display, Formatter};

use camino::Utf8PathBuf;
use ecow::EcoString;

use crate::plan::{EchoSite, SourceContext};
use crate::runtime::Value;

pub trait EchoSink {
    fn emit(&mut self, output: EchoOutput);
}

#[derive(Debug, Clone, PartialEq)]
pub struct EchoOutput {
    location: EchoLocation,
    message: Option<EcoString>,
    value: Value,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EchoLocation {
    Resolved {
        site: EchoSite,
        path: Utf8PathBuf,
        line: usize,
    },
    Site(EchoSite),
}

impl EchoOutput {
    pub fn new(location: EchoLocation, message: Option<EcoString>, value: Value) -> Self {
        Self {
            location,
            message,
            value,
        }
    }

    pub fn location(&self) -> &EchoLocation {
        &self.location
    }

    pub fn message(&self) -> Option<&EcoString> {
        self.message.as_ref()
    }

    pub fn value(&self) -> &Value {
        &self.value
    }
}

impl EchoLocation {
    pub fn resolved(site: EchoSite, path: impl Into<Utf8PathBuf>, line: usize) -> Self {
        Self::Resolved {
            site,
            path: path.into(),
            line,
        }
    }

    pub fn site(site: EchoSite) -> Self {
        Self::Site(site)
    }

    pub fn echo_site(&self) -> &EchoSite {
        match self {
            Self::Resolved { site, .. } | Self::Site(site) => site,
        }
    }

    pub fn path(&self) -> Option<&Utf8PathBuf> {
        match self {
            Self::Resolved { path, .. } => Some(path),
            Self::Site(_) => None,
        }
    }

    pub fn line(&self) -> Option<usize> {
        match self {
            Self::Resolved { line, .. } => Some(*line),
            Self::Site(_) => None,
        }
    }

    pub(crate) fn from_context(site: EchoSite, context: Option<&SourceContext>) -> Self {
        match context {
            Some(context) => {
                let line = context
                    .source()
                    .as_bytes()
                    .iter()
                    .take(site.span().start())
                    .filter(|byte| **byte == b'\n')
                    .count()
                    + 1;
                Self::resolved(site, context.path().clone(), line)
            }
            None => Self::site(site),
        }
    }
}

impl EchoSink for Vec<EchoOutput> {
    fn emit(&mut self, output: EchoOutput) {
        self.push(output);
    }
}

impl Display for EchoOutput {
    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
        let mut output = String::new();
        match &self.location {
            EchoLocation::Resolved { path, line, .. } => {
                output.push_str(path.as_str());
                output.push(':');
                output.push_str(&line.to_string());
            }
            EchoLocation::Site(site) => {
                output.push_str(site.module());
                output.push_str("::");
                output.push_str(site.function());
                output.push('@');
                output.push_str(&site.span().start().to_string());
                output.push_str("..");
                output.push_str(&site.span().end().to_string());
            }
        }
        if let Some(message) = &self.message {
            output.push(' ');
            output.push_str(message);
        }
        output.push('\n');
        self.value.inspect().write_to(&mut output);
        formatter.write_str(&output)
    }
}

#[cfg(test)]
mod tests {
    use super::{EchoLocation, EchoOutput, EchoSink};
    use crate::{EchoSite, SourceContext, SourceSpan, Value};

    #[test]
    fn resolved_location_preserves_site_path_and_one_based_line() {
        let site = EchoSite::new("main".into(), "run".into(), SourceSpan::new(13, 17));
        let context = SourceContext::new("src/main.gleam", "first\nsecond\nthird");
        let location = EchoLocation::from_context(site.clone(), Some(&context));

        assert_eq!(location.echo_site(), &site);
        assert_eq!(
            location.path().map(|path| path.as_str()),
            Some("src/main.gleam"),
        );
        assert_eq!(location.line(), Some(3));
    }

    #[test]
    fn site_location_preserves_unresolved_site() {
        let site = EchoSite::new("main".into(), "run".into(), SourceSpan::new(4, 8));
        let location = EchoLocation::from_context(site.clone(), None);

        assert_eq!(location.echo_site(), &site);
        assert_eq!(location.path(), None);
        assert_eq!(location.line(), None);
    }

    #[test]
    fn output_preserves_structured_fields_and_resolved_display() {
        let site = EchoSite::new("main".into(), "run".into(), SourceSpan::new(4, 8));
        let output = EchoOutput::new(
            EchoLocation::resolved(site.clone(), "src/main.gleam", 12),
            Some("selected".into()),
            Value::Bool(true),
        );

        assert_eq!(output.location().echo_site(), &site);
        assert_eq!(
            output.message().map(|message| message.as_str()),
            Some("selected")
        );
        assert_eq!(output.value(), &Value::Bool(true));
        assert_eq!(output.to_string(), "src/main.gleam:12 selected\nTrue",);
    }

    #[test]
    fn output_formats_site_fallback_without_message() {
        let output = EchoOutput::new(
            EchoLocation::site(EchoSite::new(
                "main".into(),
                "run".into(),
                SourceSpan::new(4, 8),
            )),
            None,
            Value::Int(1.into()),
        );

        assert_eq!(output.message(), None);
        assert_eq!(output.to_string(), "main::run@4..8\n1");
    }

    #[test]
    fn vector_collects_owned_echo_outputs() {
        let output = EchoOutput::new(
            EchoLocation::site(EchoSite::new(
                "main".into(),
                "run".into(),
                SourceSpan::new(0, 1),
            )),
            None,
            Value::Nil,
        );
        let mut outputs = Vec::new();

        outputs.emit(output.clone());

        assert_eq!(outputs, vec![output]);
    }

    #[test]
    fn run_without_source_context_emits_site_location() {
        let typed = crate::compile_typed_module(
            "main",
            "main.gleam",
            "pub fn main() { echo 1 as \"fallback\" }",
        )
        .expect("source should compile");
        let module = crate::plan_module(typed).expect("source should plan");
        let plan = crate::ExecutionPlan::from_module_plan(module);
        let mut outputs = Vec::new();

        assert_eq!(
            crate::run_main(&plan, &mut outputs),
            Ok(Value::Int(1.into()))
        );
        assert_eq!(
            outputs,
            vec![EchoOutput::new(
                EchoLocation::site(EchoSite::new(
                    "main".into(),
                    "main".into(),
                    SourceSpan::new(16, 36),
                )),
                Some("fallback".into()),
                Value::Int(1.into()),
            )],
        );
    }
}