use std::path::Path;
use serde::Serialize;
use crate::cst;
use crate::diag::{OpyError, Position, Span};
use crate::hir;
use crate::hir::types::{
Declaration, Define, Expr as HirExpr, RuleEntry, SourceFile, Stmt as HirStmt,
};
use crate::preprocess::{FileRecord, PreprocessOutcome, PreprocessWarning, Preprocessed};
fn visible_warnings(preprocessed: &Preprocessed) -> impl Iterator<Item = &PreprocessWarning> {
preprocessed.warnings.iter().filter(|warning| {
!preprocessed
.preprocessing
.suppressed_warnings
.iter()
.any(|code| code == &warning.code)
})
}
#[derive(Debug, Clone)]
pub struct CheckOutcome {
pub diagnostics: Vec<Diagnostic>,
pub model: Option<SemanticModel>,
pub files: Vec<FileRecord>,
pub post_compile_hook: Option<crate::preprocess::PostCompileHook>,
pub display_root: std::path::PathBuf,
}
impl CheckOutcome {
fn failure(
diagnostics: Vec<Diagnostic>,
files: Vec<FileRecord>,
display_root: std::path::PathBuf,
) -> Self {
Self {
diagnostics,
model: None,
files,
post_compile_hook: None,
display_root,
}
}
pub fn is_clean(&self) -> bool {
self.diagnostics
.iter()
.all(|diagnostic| diagnostic.severity != DiagnosticSeverity::Error)
}
}
pub fn check(source: &str, main_path: &str, root: &Path) -> CheckOutcome {
check_with_overlay(source, main_path, root, &std::collections::BTreeMap::new())
}
pub fn check_with_overlay(
source: &str,
main_path: &str,
root: &Path,
overlay: &std::collections::BTreeMap<String, String>,
) -> CheckOutcome {
let PreprocessOutcome {
result,
files,
display_root,
warnings,
} = crate::preprocess::preprocess_with_overlay_outcome(source, main_path, root, overlay);
let preprocessed = match result {
Ok((preprocessed, _)) => preprocessed,
Err(error) => {
let mut diagnostics = warnings
.iter()
.map(|warning| Diagnostic::from_warning(warning, &files))
.collect::<Vec<_>>();
diagnostics.push(Diagnostic::from_error(error, &files));
return CheckOutcome::failure(diagnostics, files, display_root);
}
};
let parsed = crate::parser::parse_with_options(
&preprocessed.tokens,
preprocessed.preprocessing.allow_macro_redeclaration,
);
let Some(mut program) = parsed.program else {
let mut diagnostics = visible_warnings(&preprocessed)
.map(|warning| Diagnostic::from_warning(warning, &files))
.collect::<Vec<_>>();
diagnostics.extend(
parsed
.errors
.iter()
.map(|error| Diagnostic::from_error(error.clone(), &files)),
);
return CheckOutcome::failure(diagnostics, files, display_root);
};
if let Some(block) = &preprocessed.settings {
match crate::settings::parse_block(block) {
Ok(parsed_settings) => program.settings = Some(parsed_settings),
Err(error) => {
let mut diagnostics = visible_warnings(&preprocessed)
.map(|warning| Diagnostic::from_warning(warning, &files))
.collect::<Vec<_>>();
diagnostics.push(Diagnostic::from_error(error, &files));
return CheckOutcome::failure(diagnostics, files, display_root);
}
}
}
let defines = preprocessed
.defines
.iter()
.map(|define| Define {
name: define.name.clone(),
is_function: define.is_function,
is_member: define.is_member,
span: define.span.map(Into::into),
})
.collect();
let hir_files = files
.iter()
.map(|file| hir::types::SourceFile {
id: file.id,
path: file.path.clone(),
})
.collect();
match crate::lower::lower_with_preprocessing(
&program,
hir_files,
defines,
&preprocessed.preprocessing,
) {
Ok(mut hir) => {
let mut diagnostics = visible_warnings(&preprocessed)
.map(|warning| Diagnostic::from_warning(warning, &files))
.collect::<Vec<_>>();
hir.preprocessing = preprocessed.preprocessing;
if let Err(error) = crate::settings::resolve_hir_settings(&mut hir, &program) {
diagnostics.push(Diagnostic::from_error(error, &files));
return CheckOutcome::failure(diagnostics, files, display_root);
}
match crate::compiler::settings::workshop_settings(&hir) {
Ok((settings, unknown)) => {
diagnostics.extend(unknown.into_iter().map(|member| {
Diagnostic::from_unknown_setting(member, hir.settings.as_ref(), &files)
}));
if let Some(settings) = settings {
diagnostics.extend(
workshop_rs::settings::check_emission_diagnostics(&settings)
.into_iter()
.filter(|diagnostic| {
diagnostic.severity
== workshop_rs::settings::DiagnosticSeverity::Error
})
.map(|diagnostic| {
Diagnostic::from_settings_diagnostic(
diagnostic,
hir.settings.as_ref(),
&files,
)
}),
);
}
}
Err(error) => diagnostics.push(Diagnostic::from_integration_error(error, &files)),
}
if diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
{
return CheckOutcome::failure(diagnostics, files, display_root);
}
CheckOutcome {
diagnostics,
model: Some(SemanticModel::build(hir, &program)),
files,
post_compile_hook: preprocessed.post_compile_hook,
display_root,
}
}
Err(error) => {
let mut diagnostics = visible_warnings(&preprocessed)
.map(|warning| Diagnostic::from_warning(warning, &files))
.collect::<Vec<_>>();
diagnostics.push(Diagnostic::from_error(error, &files));
CheckOutcome::failure(diagnostics, files, display_root)
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Diagnostic {
pub severity: DiagnosticSeverity,
pub code: String,
pub message: String,
pub span: Option<SourceLocation>,
}
impl Diagnostic {
fn from_warning(warning: &PreprocessWarning, files: &[FileRecord]) -> Diagnostic {
Diagnostic {
severity: DiagnosticSeverity::Warning,
code: warning.code.clone(),
message: warning.message.clone(),
span: resolve_record_span(warning.span, files),
}
}
fn from_error(error: OpyError, files: &[FileRecord]) -> Diagnostic {
Diagnostic {
severity: DiagnosticSeverity::Error,
code: error.code,
message: error.message,
span: error.span.and_then(|span| resolve_record_span(span, files)),
}
}
fn from_settings_diagnostic(
diagnostic: workshop_rs::settings::SettingsDiagnostic,
hir_settings: Option<&crate::hir::types::Settings>,
files: &[FileRecord],
) -> Diagnostic {
let error = diagnostic.error;
let span = crate::compiler::workshop_error_span(&error).map(|span| {
Span::new(
span.file.index() as u32,
Position::new(span.start.line, span.start.col),
Position::new(span.end.line, span.end.col),
)
});
let candidates = crate::matcher::settings_member_candidates(
hir_settings,
&error,
diagnostic.suggestion.as_deref(),
);
Diagnostic {
severity: DiagnosticSeverity::Error,
code: "workshop-emission".to_string(),
message: crate::matcher::did_you_mean(error.to_string(), &candidates),
span: span.and_then(|span| resolve_record_span(span, files)),
}
}
fn from_unknown_setting(
member: crate::compiler::settings::UnknownSetting,
hir_settings: Option<&crate::hir::types::Settings>,
files: &[FileRecord],
) -> Diagnostic {
let anchor = workshop_rs::WorkshopError::malformed(String::new(), member.span);
let candidates = crate::matcher::settings_member_candidates(hir_settings, &anchor, None);
let message = match &member.value {
None => format!(
"unknown settings key '{}' is passed through unchanged",
member.name
),
Some(value) => format!(
"unknown value '{value}' for settings key '{}' is passed through unchanged",
member.name
),
};
let span = member.span.map(|span| {
Span::new(
span.file.index() as u32,
Position::new(span.start.line, span.start.col),
Position::new(span.end.line, span.end.col),
)
});
Diagnostic {
severity: DiagnosticSeverity::Warning,
code: "unknown-setting".to_string(),
message: crate::matcher::did_you_mean(message, &candidates),
span: span.and_then(|span| resolve_record_span(span, files)),
}
}
fn from_integration_error(
error: crate::compiler::IntegrationError,
files: &[FileRecord],
) -> Diagnostic {
Diagnostic {
severity: DiagnosticSeverity::Error,
code: error.diagnostic.code,
message: error.diagnostic.message,
span: error
.diagnostic
.span
.map(to_frontend_span)
.and_then(|span| resolve_record_span(span, files)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum DiagnosticSeverity {
Error,
Warning,
}
impl DiagnosticSeverity {
pub fn as_str(&self) -> &'static str {
match self {
DiagnosticSeverity::Error => "error",
DiagnosticSeverity::Warning => "warning",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SourceLocation {
pub file_id: u32,
pub path: String,
pub start: Position,
pub end: Position,
}
impl SourceLocation {
pub fn to_span(&self) -> Span {
Span::new(self.file_id, self.start, self.end)
}
}
fn resolve_span(span: Span, files: &[SourceFile]) -> Option<SourceLocation> {
let path = files.iter().find(|file| file.id == span.file)?.path.clone();
Some(SourceLocation {
file_id: span.file,
path,
start: span.start,
end: span.end,
})
}
fn resolve_record_span(span: Span, files: &[FileRecord]) -> Option<SourceLocation> {
let path = files.iter().find(|file| file.id == span.file)?.path.clone();
Some(SourceLocation {
file_id: span.file,
path,
start: span.start,
end: span.end,
})
}
fn to_frontend_span(span: hir::types::Span) -> Span {
Span::new(
span.file,
Position::new(span.start.line, span.start.col),
Position::new(span.end.line, span.end.col),
)
}
#[derive(Debug, Clone, Serialize)]
pub struct SemanticModel {
pub hir: hir::Program,
pub enums: Vec<EnumDecl>,
pub symbols: Vec<Symbol>,
}
impl SemanticModel {
pub fn build(hir: hir::Program, cst: &cst::Program) -> SemanticModel {
let enums = cst
.declarations
.iter()
.filter_map(|decl| match decl {
cst::Decl::Enum { name, members, .. } => Some(EnumDecl {
name: name.clone(),
members: members
.iter()
.map(|(member, span)| EnumMember {
name: member.clone(),
span: resolve_span(*span, &hir.files)
.expect("every token span resolves through the file registry"),
})
.collect(),
}),
_ => None,
})
.collect();
let mut model = SemanticModel {
hir,
enums,
symbols: Vec::new(),
};
model.index_symbols();
model
}
pub fn declarations(&self) -> &[Declaration] {
&self.hir.declarations
}
pub fn rules(&self) -> &[RuleEntry] {
&self.hir.rules
}
pub fn defines(&self) -> &[Define] {
&self.hir.defines
}
pub fn enums(&self) -> &[EnumDecl] {
&self.enums
}
pub fn symbols(&self) -> &[Symbol] {
&self.symbols
}
pub fn symbol(&self, name: &str) -> Option<&Symbol> {
self.symbols.iter().find(|symbol| symbol.name == name)
}
pub fn symbol_at(&self, span: Span) -> Option<&Symbol> {
self.symbols.iter().find(|symbol| {
span_contains(symbol.declaration.to_span(), span)
|| symbol
.references
.iter()
.any(|reference| span_contains(reference.to_span(), span))
})
}
pub fn provenance(&self, span: Span) -> Option<SourceLocation> {
resolve_span(span, &self.hir.files)
}
pub fn file(&self, id: u32) -> Option<&str> {
self.hir
.files
.iter()
.find(|file| file.id == id)
.map(|file| file.path.as_str())
}
fn index_symbols(&mut self) {
for decl in &self.hir.declarations {
let (kind, name, span) = match decl {
Declaration::GlobalVariable {
name,
name_span,
span,
..
} => (SymbolKind::Global, name, name_span.or(*span)),
Declaration::PlayerVariable {
name,
name_span,
span,
..
} => (SymbolKind::Player, name, name_span.or(*span)),
Declaration::Subroutine {
name,
name_span,
span,
..
} => (SymbolKind::Subroutine, name, name_span.or(*span)),
Declaration::Constant { name, span, .. } => (SymbolKind::Constant, name, *span),
Declaration::Macro { name, span, .. } => (SymbolKind::Macro, name, *span),
};
let Some(span) = span.map(to_frontend_span) else {
continue;
};
let Some(declaration) = resolve_span(span, &self.hir.files) else {
continue;
};
self.symbols.push(Symbol {
name: name.clone(),
kind,
declaration,
references: Vec::new(),
});
}
for entry in &self.hir.rules {
let RuleEntry::SubroutineDef {
name,
source_name,
name_span,
span,
..
} = entry
else {
continue;
};
let Some(span) = name_span.or(*span).map(to_frontend_span) else {
continue;
};
let Some(declaration) = resolve_span(span, &self.hir.files) else {
continue;
};
self.symbols.push(Symbol {
name: if source_name.is_empty() {
name.clone()
} else {
source_name.clone()
},
kind: SymbolKind::Def,
declaration,
references: Vec::new(),
});
}
let mut collector = ReferenceSiteCollector { sites: Vec::new() };
hir::visit::walk_program(&mut collector, &self.hir);
for (kind, name, span) in collector.sites {
self.attach_reference(kind, &name, span);
}
}
fn attach_reference(&mut self, kind: SymbolKind, name: &str, span: Span) {
let Some(location) = resolve_span(span, &self.hir.files) else {
return;
};
if let Some(index) = self
.symbols
.iter()
.position(|symbol| symbol.kind == kind && symbol.name == name)
{
self.symbols[index].references.push(location);
}
}
}
struct ReferenceSiteCollector {
sites: Vec<(SymbolKind, String, Span)>,
}
impl hir::visit::Visitor for ReferenceSiteCollector {
fn visit_expr(&mut self, expression: &HirExpr) {
match expression {
HirExpr::GlobalVar {
name,
span: Some(span),
} => {
self.sites
.push((SymbolKind::Global, name.clone(), to_frontend_span(*span)));
}
HirExpr::Constant {
name,
span: Some(span),
} => {
self.sites
.push((SymbolKind::Constant, name.clone(), to_frontend_span(*span)));
}
HirExpr::PlayerVar {
name,
member_span,
span,
..
} => {
if let Some(span) = member_span.as_ref().or(span.as_ref()) {
self.sites
.push((SymbolKind::Player, name.clone(), to_frontend_span(*span)));
}
}
HirExpr::Call {
name,
span: Some(span),
..
} => {
let span = to_frontend_span(*span);
self.sites
.push((SymbolKind::Subroutine, name.clone(), span));
self.sites.push((SymbolKind::Def, name.clone(), span));
}
HirExpr::MacroCall {
name,
span: Some(span),
..
} => {
self.sites
.push((SymbolKind::Macro, name.clone(), to_frontend_span(*span)));
}
_ => {}
}
hir::visit::walk_expr(self, expression);
}
fn visit_comprehension(
&mut self,
element: &HirExpr,
iterable: &HirExpr,
condition: Option<&HirExpr>,
) {
hir::visit::Visitor::visit_expr(self, iterable);
hir::visit::Visitor::visit_expr(self, element);
if let Some(condition) = condition {
hir::visit::Visitor::visit_expr(self, condition);
}
}
fn visit_stmt(&mut self, statement: &HirStmt) {
if let HirStmt::CallSubroutine {
name,
span: Some(span),
} = statement
{
let span = to_frontend_span(*span);
self.sites
.push((SymbolKind::Subroutine, name.clone(), span));
self.sites.push((SymbolKind::Def, name.clone(), span));
}
hir::visit::walk_stmt(self, statement);
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct EnumDecl {
pub name: String,
pub members: Vec<EnumMember>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct EnumMember {
pub name: String,
pub span: SourceLocation,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum SymbolKind {
Global,
Player,
Subroutine,
Def,
Constant,
Macro,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Symbol {
pub name: String,
pub kind: SymbolKind,
pub declaration: SourceLocation,
pub references: Vec<SourceLocation>,
}
fn span_contains(outer: Span, inner: Span) -> bool {
position_leq(outer.start, inner.start) && position_leq(inner.end, outer.end)
}
fn position_leq(a: Position, b: Position) -> bool {
a.line < b.line || (a.line == b.line && a.col <= b.col)
}
#[cfg(test)]
mod tests {
use super::*;
fn check_source(source: &str) -> CheckOutcome {
check(source, "main.opy", Path::new(""))
}
#[test]
fn clean_project_has_no_diagnostics_and_a_model() {
let outcome = check_source(
"globalvar total = 0\nrule \"r\":\n @Event global\n total += 1\n debug(total)\n",
);
assert!(
outcome.is_clean(),
"unexpected diagnostics: {:?}",
outcome.diagnostics
);
let model = outcome.model.expect("a clean project resolves");
assert_eq!(outcome.files.len(), 1);
assert_eq!(model.declarations().len(), 1);
assert_eq!(model.rules().len(), 1);
}
#[test]
fn symbols_index_declarations_and_references() {
let outcome = check_source(
"globalvar total\nplayervar P\nsubroutine reset\nmacro double(x):\n x + x\nrule \"r\":\n @Event eachPlayer\n total = 1\n eventPlayer.P = total\n reset()\n double(2)\n",
);
let model = outcome.model.expect("clean project");
let names: Vec<(&str, SymbolKind)> = model
.symbols()
.iter()
.map(|symbol| (symbol.name.as_str(), symbol.kind))
.collect();
assert_eq!(
names,
vec![
("total", SymbolKind::Global),
("P", SymbolKind::Player),
("reset", SymbolKind::Subroutine),
("double", SymbolKind::Macro),
]
);
assert_eq!(model.symbol("total").expect("symbol").references.len(), 2);
assert_eq!(model.symbol("P").expect("symbol").references.len(), 1);
let reset = model.symbol("reset").expect("symbol");
assert_eq!(reset.references.len(), 1);
assert_eq!(reset.references[0].path, "main.opy");
assert_eq!(model.symbol("double").expect("symbol").references.len(), 1);
}
#[test]
fn symbol_lookup_by_name_and_span() {
let outcome =
check_source("globalvar total\nrule \"r\":\n @Event global\n total = 1\n");
let model = outcome.model.expect("clean project");
let total = model.symbol("total").expect("symbol by name");
assert_eq!(total.kind, SymbolKind::Global);
let at_decl = model
.symbol_at(total.declaration.to_span())
.expect("symbol at declaration span");
assert_eq!(at_decl.name, "total");
let at_ref = model
.symbol_at(total.references[0].to_span())
.expect("symbol at reference span");
assert_eq!(at_ref.name, "total");
assert!(
model
.symbol_at(Span::new(99, Position::new(1, 1), Position::new(1, 1)))
.is_none()
);
}
#[test]
fn provenance_resolves_through_the_file_registry() {
let outcome =
check_source("globalvar total\nrule \"r\":\n @Event global\n total = 1\n");
let model = outcome.model.expect("clean project");
let total = model.symbol("total").expect("symbol");
let provenance = model
.provenance(total.references[0].to_span())
.expect("provenance");
assert_eq!(provenance.file_id, 0);
assert_eq!(provenance.path, "main.opy");
assert_eq!(provenance.start.line, 4);
assert_eq!(model.file(0), Some("main.opy"));
assert_eq!(model.file(1), None);
}
#[test]
fn custom_enums_are_queried_from_the_model() {
let outcome = check_source(
"globalvar x\nenum Direction:\n NORTH\n SOUTH\nrule \"r\":\n @Event global\n x = Direction.SOUTH\n",
);
let model = outcome.model.expect("clean project");
assert_eq!(model.enums().len(), 1);
let direction = &model.enums()[0];
assert_eq!(direction.name, "Direction");
let members: Vec<&str> = direction
.members
.iter()
.map(|member| member.name.as_str())
.collect();
assert_eq!(members, vec!["NORTH", "SOUTH"]);
assert!(direction.members[0].span.path.ends_with("main.opy"));
}
#[test]
fn unknown_setting_names_the_keys_valid_at_the_path() {
let outcome = check_source(
"settings {\n \"gamemodes\": {\"ffa\": {\"notASetting\": true}}\n}\nrule \"a\":\n @Event global\n wait(1)\n",
);
let diagnostic = outcome
.diagnostics
.iter()
.find(|d| d.code == "unknown-setting")
.expect("unknown setting diagnostic");
assert_eq!(diagnostic.severity, DiagnosticSeverity::Warning);
assert!(
diagnostic.message.contains("(did you mean "),
"message: {}",
diagnostic.message
);
assert!(
diagnostic.message.contains("'disabledMaps'"),
"message: {}",
diagnostic.message
);
}
#[test]
fn unknown_setting_preserves_template_paths_and_percent_suffixes() {
let outcome = check_source(
"settings {\n \"main\": {\"description\": \"t\"},\n \"gamemodes\": {},\n \"heroes\": {\"team1\": {\"general\": {\"damageReceiveed%\": 50}}}\n}\nrule \"a\":\n @Event global\n wait(1)\n",
);
let diagnostic = outcome
.diagnostics
.iter()
.find(|d| d.code == "unknown-setting")
.expect("unknown setting diagnostic");
assert_eq!(diagnostic.severity, DiagnosticSeverity::Warning);
assert!(
diagnostic.message.contains("'damageReceived%'"),
"message: {}",
diagnostic.message
);
}
#[test]
fn check_reports_every_parse_error() {
let outcome = check_source("rule \"a\"\n @Event global\nrule \"b\"\n");
assert!(!outcome.is_clean());
assert!(outcome.model.is_none());
assert_eq!(outcome.diagnostics.len(), 3);
assert!(
outcome
.diagnostics
.iter()
.all(|diagnostic| diagnostic.code == "parse-error")
);
}
#[test]
fn diagnostics_carry_severity_code_and_span() {
let outcome = check_source("rule \"r\":\n @Event global\n frobnicate()\n");
let diagnostic = &outcome.diagnostics[0];
assert_eq!(diagnostic.severity, DiagnosticSeverity::Error);
assert_eq!(diagnostic.code, "unknown-action");
let span = diagnostic.span.as_ref().expect("source-located");
assert_eq!(span.path, "main.opy");
assert_eq!(span.start.line, 3);
}
#[test]
fn canonical_workshop_ids_are_source_diagnostics() {
for (statement, code) in [
("g = allTankHeroes()", "unknown-value"),
("g = lastCreatedEntity()", "unknown-value"),
("g = evaluateOnce(1)", "unknown-value"),
("destroyAllHudText()", "unknown-action"),
] {
let outcome = check_source(&format!(
"globalvar g\nrule \"r\":\n @Event global\n {statement}\n"
));
assert!(!outcome.is_clean(), "{statement}");
let diagnostic = outcome
.diagnostics
.iter()
.find(|diagnostic| diagnostic.code == code)
.unwrap_or_else(|| panic!("{statement}: expected {code}"));
let span = diagnostic.span.as_ref().expect("source-located");
assert_eq!(span.start.line, 4, "{statement}");
}
let outcome = check_source(
"globalvar g\nrule \"r\":\n @Event eachPlayer\n g = eventPlayer.isButtonHeld(Button.PRIMARY_FIRE)\n",
);
assert!(!outcome.is_clean());
let diagnostic = outcome
.diagnostics
.iter()
.find(|diagnostic| diagnostic.code == "unknown-member")
.expect("unknown-member diagnostic");
assert_eq!(diagnostic.span.as_ref().unwrap().start.line, 4);
let outcome = check_source(
"globalvar g\nrule \"r\":\n @Event eachPlayer\n g = getTankHeroes()\n g = getLastCreatedEntity()\n g = evalOnce(1)\n g = eventPlayer.isHoldingButton(Button.PRIMARY_FIRE)\n destroyAllHudTexts()\n",
);
assert!(
outcome.is_clean(),
"unexpected diagnostics: {:?}",
outcome.diagnostics
);
}
}