use std::fmt::Write as _;
use std::rc::Rc;
use glacier_dialect::SourceMap;
use serde_json::Value;
pub struct Source {
pub path: String,
pub lines: Vec<String>,
pub map: SourceMap,
}
pub type Lookup<'a> = dyn FnMut(&str) -> Option<Rc<Source>> + 'a;
struct Style {
color: bool,
}
impl Style {
fn paint(&self, code: &str, text: &str) -> String {
if self.color {
format!("\x1b[{code}m{text}\x1b[0m")
} else {
text.to_owned()
}
}
fn level(&self, level: &str) -> String {
let code = match level {
"error" => "1;31",
"warning" => "1;33",
"help" => "1;36",
_ => "1;32",
};
self.paint(code, level)
}
fn gutter(&self, text: &str) -> String {
self.paint("1;34", text)
}
}
struct Located<'a> {
span: &'a Value,
path: String,
line: usize,
start: usize,
end: usize,
last_line: usize,
text: Vec<String>,
mapped: bool,
}
fn number(value: &Value) -> usize {
value.as_u64().unwrap_or(1) as usize
}
fn locate<'a>(span: &'a Value, lookup: &mut Lookup) -> Located<'a> {
let file = span["file_name"].as_str().unwrap_or_default();
let line = number(&span["line_start"]);
let last_line = number(&span["line_end"]).max(line);
let (col_start, col_end) = (
number(&span["column_start"]) - 1,
number(&span["column_end"]) - 1,
);
match lookup(file) {
Some(source) => {
let start = source.map.to_glacier(line - 1, col_start);
let end = if last_line == line {
source.map.to_glacier(line - 1, col_end)
} else {
source.map.to_glacier(last_line - 1, col_end)
};
let text = (line..=last_line)
.map(|n| source.lines.get(n - 1).cloned().unwrap_or_default())
.collect();
Located {
span,
path: source.path.clone(),
line,
start,
end: if last_line == line {
end.max(start + 1)
} else {
end
},
last_line,
text,
mapped: true,
}
}
None => Located {
span,
path: file.to_owned(),
line,
start: col_start,
end: if last_line == line {
col_end.max(col_start + 1)
} else {
col_end
},
last_line,
text: span["text"]
.as_array()
.into_iter()
.flatten()
.filter_map(|t| t["text"].as_str().map(str::to_owned))
.collect(),
mapped: false,
},
}
}
pub fn structured(diagnostic: &Value, lookup: &mut Lookup) -> Value {
let spans = diagnostic["spans"].as_array().cloned().unwrap_or_default();
let primary = spans
.iter()
.find(|s| s["is_primary"] == true)
.or(spans.first());
let mut out = serde_json::json!({
"source": "rustc",
"severity": diagnostic["level"],
"message": diagnostic["message"],
});
if let Some(code) = diagnostic["code"]["code"].as_str() {
out["code"] = code.into();
}
if let Some(span) = primary {
let at = locate(span, lookup);
out["file"] = at.path.clone().into();
out["line"] = at.line.into();
out["column"] = (at.start + 1).into();
out["end_line"] = at.last_line.into();
out["end_column"] = (at.end + 1).into();
if let Some(label) = span["label"].as_str() {
out["label"] = label.into();
}
}
let mut notes = Vec::new();
for child in diagnostic["children"].as_array().into_iter().flatten() {
if let Some(message) = child["message"].as_str().filter(|m| !m.is_empty()) {
notes.push(format!(
"{}: {message}",
child["level"].as_str().unwrap_or("note")
));
}
let suggestion = child["spans"]
.as_array()
.into_iter()
.flatten()
.find(|s| s["suggested_replacement"].is_string());
if out.get("fix").is_none()
&& let Some(span) = suggestion
{
let at = locate(span, lookup);
out["fix"] = serde_json::json!({
"file": at.path,
"line": at.line,
"column": at.start + 1,
"end_line": at.last_line,
"end_column": at.end + 1,
"replacement": span["suggested_replacement"],
});
}
}
if !notes.is_empty() {
out["notes"] = notes.into();
}
out
}
pub fn render(diagnostic: &Value, lookup: &mut Lookup, color: bool) -> Option<String> {
let style = Style { color };
let spans: Vec<Located> = diagnostic["spans"]
.as_array()
.into_iter()
.flatten()
.map(|span| locate(span, lookup))
.collect();
if !spans.iter().any(|s| s.mapped) {
return None;
}
let level = diagnostic["level"].as_str().unwrap_or("error");
let message = diagnostic["message"].as_str().unwrap_or_default();
let code = diagnostic["code"]["code"]
.as_str()
.map(|c| format!("[{c}]"))
.unwrap_or_default();
let mut out = format!(
"{}{}: {}\n",
style.level(level),
style.paint(if level == "error" { "1;31" } else { "1;33" }, &code),
style.paint("1", message)
);
let children: Vec<(&Value, Vec<Located>)> = diagnostic["children"]
.as_array()
.into_iter()
.flatten()
.map(|child| {
let spans = child["spans"]
.as_array()
.into_iter()
.flatten()
.map(|span| locate(span, lookup))
.collect();
(child, spans)
})
.collect();
let width = spans
.iter()
.chain(children.iter().flat_map(|(_, s)| s))
.map(|s| s.last_line.to_string().len())
.max()
.unwrap_or(1);
let pad = " ".repeat(width);
let bar = style.gutter("|");
let mut files: Vec<&str> = Vec::new();
let primary_first = spans
.iter()
.filter(|s| s.span["is_primary"].as_bool() == Some(true))
.chain(spans.iter());
for located in primary_first {
if !files.contains(&located.path.as_str()) {
files.push(&located.path);
}
}
for (i, file) in files.iter().enumerate() {
let mut group: Vec<&Located> = spans.iter().filter(|s| s.path == *file).collect();
group.sort_by_key(|s| (s.line, s.span["is_primary"].as_bool() != Some(true)));
let first = group
.iter()
.find(|s| s.span["is_primary"].as_bool() == Some(true))
.unwrap_or(&group[0]);
let arrow = if i == 0 { "-->" } else { ":::" };
let _ = writeln!(
out,
"{pad}{} {file}:{}:{}",
style.gutter(arrow),
first.line,
first.start + 1
);
let _ = writeln!(out, "{pad} {bar}");
let mut shown_line = None;
for located in group {
let primary = located.span["is_primary"].as_bool() == Some(true);
if shown_line != Some(located.line) {
for (offset, text) in located.text.iter().enumerate() {
let n = located.line + offset;
let _ = writeln!(
out,
"{} {bar} {text}",
style.gutter(&format!("{n:>width$}"))
);
}
shown_line = Some(located.line);
}
let label = located.span["label"].as_str().unwrap_or_default();
let (mark, paint) = if primary {
("^", if level == "error" { "1;31" } else { "1;33" })
} else {
("-", "1;34")
};
let marks = if located.last_line == located.line {
format!(
"{}{}",
" ".repeat(located.start),
mark.repeat(located.end - located.start)
)
} else {
format!("{}{mark}", " ".repeat(located.end.saturating_sub(1)))
};
let label = if label.is_empty() {
String::new()
} else {
format!(" {label}")
};
let _ = writeln!(
out,
"{pad} {bar} {}",
style.paint(paint, &format!("{marks}{label}"))
);
}
if i + 1 < files.len() {
let _ = writeln!(out, "{pad} {bar}");
}
}
for (i, (child, located)) in children.iter().enumerate() {
let level = child["level"].as_str().unwrap_or("note");
let message = child["message"].as_str().unwrap_or_default();
match suggestion(located) {
Some((line, text)) => {
let _ = writeln!(out, "{pad} {bar}");
let _ = writeln!(out, "{}: {}", style.level(level), style.paint("1", message));
let _ = writeln!(out, "{pad} {bar}");
let _ = writeln!(
out,
"{} {bar} {text}",
style.gutter(&format!("{line:>width$}"))
);
}
None => {
let after_suggestion = i > 0 && suggestion(&children[i - 1].1).is_some();
if i == 0 || after_suggestion {
let _ = writeln!(out, "{pad} {bar}");
}
let indent = format!("{pad} {}", " ".repeat(level.len() + 2));
let message = message.replace('\n', &format!("\n{indent}"));
let _ = writeln!(
out,
"{pad} {} {}: {message}",
style.gutter("="),
style.paint("1", level)
);
}
}
}
out.push('\n');
Some(out)
}
fn suggestion(spans: &[Located]) -> Option<(usize, String)> {
let first = spans.first()?;
let all_here = spans.iter().all(|s| {
s.mapped
&& s.path == first.path
&& s.line == first.line
&& s.last_line == s.line
&& s.span["suggested_replacement"].is_string()
});
if !all_here {
return None;
}
let mut line: Vec<char> = first.text.first()?.chars().collect();
let mut edits: Vec<&Located> = spans.iter().collect();
edits.sort_by_key(|s| std::cmp::Reverse(s.start));
for edit in edits {
let replacement = edit.span["suggested_replacement"].as_str()?;
if replacement.contains('\n') || edit.end > line.len() {
return None;
}
let end = if edit.span["column_start"] == edit.span["column_end"] {
edit.start
} else {
edit.end
};
line.splice(edit.start..end, replacement.chars());
}
Some((first.line, line.into_iter().collect()))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const GLACIER: &str = "alias Models.Post\n\ncontroller Posts do\n def show(ctx) do\n post = Post.fnd(ctx.id()?)!\n render post, title: 5\n end\nend\n";
fn lookup() -> impl FnMut(&str) -> Option<Rc<Source>> {
let source = Rc::new(Source {
path: "src/controllers/posts.glacier".into(),
lines: GLACIER.lines().map(str::to_owned).collect(),
map: glacier_dialect::transpile(GLACIER, "posts.glacier")
.unwrap()
.map,
});
move |file: &str| {
file.ends_with(".glacier/controllers/posts.rs")
.then(|| source.clone())
}
}
fn rust_line(n: usize) -> String {
glacier_dialect::transpile(GLACIER, "posts.glacier")
.unwrap()
.rust
.lines()
.nth(n - 1)
.unwrap()
.to_owned()
}
fn span(
file: &str,
line: usize,
start: usize,
end: usize,
primary: bool,
label: &str,
) -> Value {
json!({
"file_name": file, "line_start": line, "line_end": line,
"column_start": start, "column_end": end, "is_primary": primary,
"label": label, "suggested_replacement": null,
"text": [{"text": "pub struct Post {"}],
})
}
#[test]
fn shows_the_glacier_line_and_maps_suggestions() {
let rust = rust_line(5);
let col = rust.find("fnd").unwrap() + 1;
let generated = "/app/.glacier/controllers/posts.rs";
let mut suggestion = span(generated, 5, col, col + 3, true, "");
suggestion["suggested_replacement"] = json!("find");
let diagnostic = json!({
"level": "error",
"message": "no associated function named `fnd` found for struct `Post`",
"code": {"code": "E0599"},
"spans": [
span(generated, 5, col, col + 3, true, "not found in `Post`"),
span("src/models/post.rs", 4, 1, 16, false, "`fnd` not found for this struct"),
],
"children": [
{"level": "help", "message": "there is an associated function `find` with a similar name", "spans": [suggestion]},
{"level": "note", "message": "a note\nover two lines", "spans": []},
],
});
let out = render(&diagnostic, &mut lookup(), false).unwrap();
assert_eq!(
out,
"error[E0599]: no associated function named `fnd` found for struct `Post`
--> src/controllers/posts.glacier:5:17
|
5 | post = Post.fnd(ctx.id()?)!
| ^^^ not found in `Post`
|
::: src/models/post.rs:4:1
|
4 | pub struct Post {
| --------------- `fnd` not found for this struct
|
help: there is an associated function `find` with a similar name
|
5 | post = Post.find(ctx.id()?)!
|
= note: a note
over two lines
"
);
}
#[test]
fn leaves_other_diagnostics_to_rustc() {
let diagnostic = json!({
"level": "warning", "message": "unused", "code": null,
"spans": [span("src/lib.rs", 1, 1, 2, true, "")], "children": [],
});
assert!(render(&diagnostic, &mut lookup(), false).is_none());
}
#[test]
fn maps_carets_through_translated_code() {
let rust = rust_line(6);
let col = rust.find("5").unwrap() + 1;
let diagnostic = json!({
"level": "error", "message": "mismatched types", "code": {"code": "E0308"},
"spans": [span("/app/.glacier/controllers/posts.rs", 6, col, col + 1, true, "expected `String`")],
"children": [],
});
let out = render(&diagnostic, &mut lookup(), false).unwrap();
assert!(
out.contains(
"6 | render post, title: 5\n | ^ expected `String`\n"
),
"{out}"
);
}
}