voltaic-driver 0.1.0

Incremental compilation driver for the Voltaic compiler.
Documentation
use voltaic_diag::Diagnostic;

use voltaic_diag::DiagnosticCode;

use voltaic_diag::SourceMap;

use voltaic_diag::Span;

pub fn remap(
  message: &RustcMessage,
  map: &SourceMap,
  sdx_path: &str,
  sdx_source: &str,
  xrs_path: &str,
  xrs_source: &str,
) -> Diagnostic {
  let code = DiagnosticCode::Foreign(
    message
      .code
      .clone()
      .unwrap_or_else(|| message.level.clone()),
  );

  let severity = if message.level == "warning" {
    voltaic_diag::Severity::Warning
  } else {
    voltaic_diag::Severity::Error
  };

  match map.origin(message.line) {
    Some(origin) => {
      let shifted = origin.map_col(message.col);
      Diagnostic {
        code,
        severity,
        message: message.message.clone(),
        span: narrow(origin.span, sdx_source, origin.src_line, shifted),
        help: None,
        path: sdx_path.to_string(),
        source: sdx_source.to_string(),
      }
    }
    None => {
      let offset = generated_offset(xrs_source, message.line, message.col);
      let note = format!(
        "this is on a line Voltaic generated, so it has no place in your source; see {}:{}:{}",
        xrs_path, message.line, message.col
      );
      Diagnostic {
        code,
        severity,
        message: message.message.clone(),
        span: (offset, offset + 1),
        help: Some(note),
        path: xrs_path.to_string(),
        source: xrs_source.to_string(),
      }
    }
  }
}

#[cfg(test)]
const SDX: &str = "add = (a: i32, b: i32): i32 ->\n  a + \"x\"\n";

#[cfg(test)]
const XRS: &str = "fn add(a: i32, b: i32) -> i32 {\n    a + \"x\"\n}\n";

#[cfg(test)]
fn map_of() -> SourceMap {
  SourceMap::new(
    "p.sdx",
    vec![
      Some(voltaic_diag::Origin {
        span: (0, 30),
        src_line: 1,
        src_col: 1,
        gen_col: 1,
      }),
      Some(voltaic_diag::Origin {
        span: (33, 40),
        src_line: 2,
        src_col: 3,
        gen_col: 5,
      }),
      None,
    ],
  )
}

#[cfg(test)]
fn message_at(line: usize, col: usize) -> RustcMessage {
  RustcMessage {
    path: "p.xrs".to_string(),
    line,
    col,
    level: "error".to_string(),
    code: Some("E0277".to_string()),
    message: "cannot add `&str` to `i32`".to_string(),
  }
}

#[cfg(test)]
fn run(m: &RustcMessage) -> voltaic_diag::Diagnostic {
  remap(m, &map_of(), "p.sdx", SDX, "p.xrs", XRS)
}

pub(crate) fn char_offset(source: &str, line: usize, col: usize) -> Option<usize> {
  if line == 0 || col == 0 {
    return None;
  }
  let mut offset = 0;
  for pair in source.lines().enumerate() {
    let n = pair.0;
    let text = pair.1;
    if n + 1 == line {
      if text.chars().count() < col - 1 {
        return None;
      }
      let within: usize = text.chars().take(col - 1).map(char::len_utf8).sum();
      return Some(offset + within);
    }
    offset += text.len() + 1;
  }
  None
}

#[cfg(test)]
const SRC_CHAR_OFFSET: &str = "ab\n  cd\n";

pub(crate) fn generated_offset(source: &str, line: usize, col: usize) -> usize {
  char_offset(source, line, col).unwrap_or_else(|| source.len().saturating_sub(1))
}

#[cfg(test)]
const SRC_GENERATED_OFFSET: &str = "fn main() {\n}\n";

pub(crate) fn narrow(span: Span, source: &str, line: usize, col: usize) -> Span {
  let Some(offset) = char_offset(source, line, col) else {
    return span;
  };
  if offset < span.0 || offset >= span.1 {
    return span;
  }
  let n = source.len();
  let width = source[offset..n].chars().next().map_or(1, char::len_utf8);
  (offset, offset + width)
}

#[cfg(test)]
const SRC_NARROW: &str = "add = (a: i32, b: i32): i32 ->\n  a + \"x\"\n";

#[cfg(test)]
const SPAN: (usize, usize) = (33, 40);

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RustcMessage {
  pub path: String,
  pub line: usize,
  pub col: usize,
  pub level: String,
  pub code: Option<String>,
  pub message: String,
}

impl RustcMessage {
  pub fn parse(line: &str) -> Option<Self> {
    let head = line.split_once(": ")?;
    let position = head.0;
    let rest = head.1;

    let tail = position.rsplit_once(':')?;
    let col: usize = tail.1.parse().ok()?;
    let stem = tail.0.rsplit_once(':')?;
    let line_no: usize = stem.1.parse().ok()?;

    let labelled = rest.split_once(": ")?;
    let label = labelled.0;
    let message = labelled.1;
    let bracketed = label.split_once('[');
    let level = bracketed.as_ref().map_or(label, |parts| parts.0);
    let code = bracketed
      .as_ref()
      .map(|parts| parts.1.trim_end_matches(']').to_string());
    if level != "error" && level != "warning" {
      return None;
    }

    Some(RustcMessage {
      path: stem.0.to_string(),
      line: line_no,
      col,
      level: level.to_string(),
      code,
      message: message.to_string(),
    })
  }

  pub fn is_error(&self) -> bool {
    self.level == "error"
  }
}

#[cfg(test)]
mod __rdx_tests {
  use super::*;

  #[test]
  fn narrows_to_the_exact_character_on_a_verbatim_line() {
    let d = run(&message_at(2, 7));
    assert_eq!(d.path, "p.sdx");
    assert_eq!(&SDX[d.span.0..d.span.1], "+");
    assert_eq!(d.code.text(), "E0277");
    assert_eq!(
      d.message, "cannot add `&str` to `i32`",
      "rustc's message passes through"
    );
    assert!(d.help.is_none());
  }

  #[test]
  fn underlines_the_whole_span_when_the_column_misses() {
    let d = run(&message_at(2, 99));
    assert_eq!(d.span, (33, 40));
    assert_eq!(&SDX[d.span.0..d.span.1], "a + \"x\"");
  }

  #[test]
  fn reports_a_synthetic_line_against_the_generated_file() {
    let d = run(&message_at(3, 1));
    assert_eq!(d.path, "p.xrs");
    assert_eq!(d.source, XRS);
    let help = d.help.expect("a synthetic line should explain itself");
    assert!(help.contains("Voltaic generated"), "{}", help);
    assert!(help.contains("p.xrs:3:1"), "{}", help);
  }

  #[test]
  fn reports_an_unmapped_line_against_the_generated_file() {
    let d = run(&message_at(99, 1));
    assert_eq!(d.path, "p.xrs");
  }

  #[test]
  fn falls_back_to_the_level_when_rustc_gave_no_code() {
    let mut m = message_at(2, 7);
    m.code = None;
    m.level = "warning".to_string();
    assert_eq!(run(&m).code.text(), "warning");
  }

  #[test]
  fn resolves_a_position() {
    assert_eq!(char_offset(SRC_CHAR_OFFSET, 1, 1), Some(0));
    assert_eq!(char_offset(SRC_CHAR_OFFSET, 2, 3), Some(5));
    assert_eq!(&SRC_CHAR_OFFSET[5..7], "cd");
  }

  #[test]
  fn allows_the_position_just_past_a_line() {
    assert_eq!(char_offset(SRC_CHAR_OFFSET, 1, 3), Some(2));
  }

  #[test]
  fn declines_a_column_past_the_line() {
    assert_eq!(char_offset(SRC_CHAR_OFFSET, 1, 9), None);
  }

  #[test]
  fn declines_a_line_past_the_text() {
    assert_eq!(char_offset(SRC_CHAR_OFFSET, 9, 1), None);
  }

  #[test]
  fn declines_a_zero_line_or_column() {
    assert_eq!(char_offset(SRC_CHAR_OFFSET, 0, 1), None);
    assert_eq!(char_offset(SRC_CHAR_OFFSET, 1, 0), None);
  }

  #[test]
  fn counts_characters_and_returns_bytes() {
    assert_eq!(char_offset("é x\n", 1, 2), Some(2));
    assert_eq!(&"é x\n"[2..3], " ");
  }

  #[test]
  fn resolves_a_real_position() {
    assert_eq!(generated_offset(SRC_GENERATED_OFFSET, 2, 1), 12);
    assert_eq!(SRC_GENERATED_OFFSET[12..13].chars().next(), Some('}'));
  }

  #[test]
  fn clamps_a_position_past_the_end() {
    assert_eq!(
      generated_offset(SRC_GENERATED_OFFSET, 99, 99),
      SRC_GENERATED_OFFSET.len() - 1
    );
  }

  #[test]
  fn survives_an_empty_source() {
    assert_eq!(generated_offset("", 1, 1), 0);
  }

  #[test]
  fn narrows_to_one_character_inside_the_span() {
    let got = narrow(SPAN, SRC_NARROW, 2, 5);
    assert_eq!(&SRC_NARROW[got.0..got.1], "+");
  }

  #[test]
  fn keeps_the_span_when_the_column_is_past_the_line() {
    assert_eq!(narrow(SPAN, SRC_NARROW, 2, 99), SPAN);
  }

  #[test]
  fn keeps_the_span_when_the_position_is_elsewhere() {
    assert_eq!(narrow(SPAN, SRC_NARROW, 1, 1), SPAN);
  }

  #[test]
  fn keeps_the_span_when_the_line_does_not_exist() {
    assert_eq!(narrow(SPAN, SRC_NARROW, 99, 1), SPAN);
  }

  #[test]
  fn narrows_to_a_whole_multibyte_character() {
    let src = "x : ()\nx =\n  é\n";
    let span = (13, 15);
    assert_eq!(&src[span.0..span.1], "é", "the premise of this test");
    let got = narrow(span, src, 3, 3);
    assert_eq!(&src[got.0..got.1], "é");
  }

  #[test]
  fn parses_a_coded_error() {
    let m = RustcMessage::parse(
      ".tmp/typeerr.xrs:6:24: error[E0308]: mismatched types: expected `i32`, found `&str`",
    )
    .expect("should parse");
    assert_eq!(m.path, ".tmp/typeerr.xrs");
    assert_eq!((m.line, m.col), (6, 24));
    assert_eq!(m.level, "error");
    assert_eq!(m.code.as_deref(), Some("E0308"));
    assert_eq!(
      m.message, "mismatched types: expected `i32`, found `&str`",
      "the message keeps rustc's own colons"
    );
    assert!(m.is_error());
  }

  #[test]
  fn keeps_a_message_containing_a_colon() {
    let m = RustcMessage::parse(".tmp/tail.xrs:2:7: error[E0277]: cannot add `&str` to `i32`: no implementation for `i32 + &str`").expect("should parse");
    assert_eq!(
      m.message,
      "cannot add `&str` to `i32`: no implementation for `i32 + &str`"
    );
  }

  #[test]
  fn parses_an_uncoded_warning() {
    let m =
      RustcMessage::parse("main.xrs:3:9: warning: unused variable: `x`").expect("should parse");
    assert_eq!(m.level, "warning");
    assert_eq!(m.code, None);
    assert_eq!(m.message, "unused variable: `x`");
    assert!(!m.is_error());
  }

  #[test]
  fn declines_the_summary_line() {
    assert_eq!(
      RustcMessage::parse("error: aborting due to 1 previous error"),
      None
    );
  }

  #[test]
  fn declines_an_unpositioned_note() {
    assert_eq!(
      RustcMessage::parse("note: run with `RUST_BACKTRACE=1`"),
      None
    );
    assert_eq!(RustcMessage::parse(""), None);
  }

  #[test]
  fn survives_a_colon_in_the_path() {
    let m = RustcMessage::parse("C:\\out\\main.xrs:6:24: error[E0308]: mismatched types")
      .expect("should parse");
    assert_eq!(m.path, "C:\\out\\main.xrs");
    assert_eq!((m.line, m.col), (6, 24));
  }

  #[test]
  fn declines_an_unknown_level() {
    assert_eq!(RustcMessage::parse("main.xrs:1:1: help: try this"), None);
  }
}