use i_slint_compiler::ComponentSelection;
use i_slint_compiler::diagnostics::{
self, BuildDiagnostics, ByteFormat, Diagnostic, DiagnosticLevel,
};
use std::ops::Range;
use std::path::{Path, PathBuf};
#[test]
fn syntax_tests() -> std::io::Result<()> {
use rayon::prelude::*;
let update = std::env::args().any(|arg| arg == "--update")
|| std::env::var("SLINT_SYNTAX_TEST_UPDATE").is_ok_and(|v| v == "1");
if let Some(specific_test) =
std::env::args().skip(1).find(|arg| !(arg.starts_with("--") || arg == "syntax_tests"))
{
let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
path.push("tests");
path.push(specific_test);
assert!(process_file(&path, update)?);
return Ok(());
}
let pattern = std::env::var("SLINT_TEST_FILTER").ok().map(|p| regex::Regex::new(&p).unwrap());
let syntax_dir = format!("{}/tests/syntax", env!("CARGO_MANIFEST_DIR"));
let mut test_entries = Vec::new();
for entry in std::fs::read_dir(&syntax_dir)? {
let entry = entry?;
if entry.file_type().is_ok_and(|f| f.is_dir()) {
let path = entry.path();
#[cfg(not(feature = "slint-sc"))]
if path.file_name().is_some_and(|n| n == "slint-sc") {
continue;
}
for test_entry in path.read_dir()? {
let test_entry = test_entry?;
let path = test_entry.path();
if let Some(ext) = path.extension()
&& (ext == "60" || ext == "slint")
&& pattern.as_ref().map(|p| p.is_match(&path.to_string_lossy())).unwrap_or(true)
{
test_entries.push(path);
}
}
}
}
let results: Vec<(String, bool)> = test_entries
.par_iter()
.map(|path| {
let ok = process_file(path, update)?;
let name = path
.strip_prefix(&syntax_dir)
.unwrap_or(path)
.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, "/");
Ok::<_, std::io::Error>((name, ok))
})
.collect::<Result<_, _>>()?;
if let Some(report) = std::env::var_os("SLINT_TEST_REPORT") {
let entries: Vec<(String, String, bool)> = results
.iter()
.map(|(name, ok)| (name.clone(), format!("internal/compiler/tests/syntax/{name}"), *ok))
.collect();
write_report(&entries, "syntax-tests", std::path::Path::new(&report))?;
}
assert!(results.iter().all(|(_, ok)| *ok));
Ok(())
}
fn write_report(
results: &[(String, String, bool)],
tool: &str,
path: &std::path::Path,
) -> std::io::Result<()> {
let tests: Vec<_> = results
.iter()
.map(|(name, file_path, ok)| {
serde_json::json!({
"name": name,
"filePath": file_path,
"status": if *ok { "passed" } else { "failed" },
})
})
.collect();
let failed = results.iter().filter(|(_, _, ok)| !ok).count();
let report = serde_json::json!({
"results": {
"tool": { "name": tool },
"summary": {
"tests": results.len(),
"passed": results.len() - failed,
"failed": failed,
},
"tests": tests,
}
});
std::fs::write(path, serde_json::to_string_pretty(&report).unwrap())
}
fn process_file(path: &std::path::Path, update: bool) -> std::io::Result<bool> {
let source = std::fs::read_to_string(path)?;
if path.to_str().unwrap_or("").contains("bom-") && !source.starts_with("\u{FEFF}") {
return Err(std::io::Error::other(format!(
"{path:?} does not contains BOM while it should"
)));
}
if path.to_str().unwrap_or("").contains("crlf-") && !source.contains("\r\n") {
return Err(std::io::Error::other(format!(
"{path:?} does not contain CRLF line terminators while it should"
)));
}
std::panic::catch_unwind(|| process_file_source(path, source, false, update)).unwrap_or_else(
|err| {
println!("Panic while processing {}: {:?}", path.display(), err);
Ok(false)
},
)
}
struct ExpectedDiagnostic {
start: Option<usize>,
end: Option<usize>,
level: DiagnosticLevel,
message: String,
comment_range: Range<usize>,
}
fn extract_expected_diags(source: &str) -> Vec<ExpectedDiagnostic> {
let mut expected = Vec::new();
let re = regex::Regex::new(
r"\n *//[^\n\^\|<>]*((\^)|(\|)|((>)?( *<)?))(\^*)(<*)(error|warning|note)\{([^\n]*)\}",
)
.unwrap();
for m in re.captures_iter(source) {
let line_begin_offset = m.get(0).unwrap().start();
let start_column = m.get(1).unwrap().start()
- line_begin_offset
- m.get(8).map(|group| group.as_str().len()).unwrap_or_default();
let lines_to_source = m.get(7).map(|group| group.as_str().len()).unwrap_or_default() + 1;
let warning_or_error = m.get(9).unwrap().as_str();
let expected_message = m
.get(10)
.unwrap()
.as_str()
.replace('↵', "\n")
.replace('📂', env!("CARGO_MANIFEST_DIR"));
let comment_range = m.get(0).unwrap().range();
let mut line_counter = 0;
let mut line_offset = source[..line_begin_offset].rfind('\n').unwrap_or(0);
let mut offset = loop {
line_counter += 1;
if line_counter >= lines_to_source {
break line_offset + start_column;
}
if let Some(o) = source[..line_offset].rfind('\n') {
line_offset = o;
} else {
break 1;
};
};
let mut start = None;
let mut end = None;
if m.get(2).is_some() {
start = Some(offset);
end = Some(offset);
} else if m.get(3).is_some() {
start = Some(offset);
end = Some(offset - 1);
} else {
if m.get(5).is_some() {
start = Some(offset);
offset += 1;
}
if let Some(range_length) = m.get(6).map(|group| group.as_str().len()) {
end = Some(offset + range_length - 1);
}
}
if let Some(end_offset) = end
&& source.get(end_offset..=(end_offset + 1)) == Some("\r\n")
{
end = Some(end_offset + 1);
}
let expected_diag_level = match warning_or_error {
"warning" => DiagnosticLevel::Warning,
"error" => DiagnosticLevel::Error,
"note" => DiagnosticLevel::Note,
_ => panic!("Unsupported diagnostic level {warning_or_error}"),
};
expected.push(ExpectedDiagnostic {
start,
end,
level: expected_diag_level,
message: expected_message,
comment_range,
});
}
expected
}
fn process_diagnostics(
compile_diagnostics: &BuildDiagnostics,
path: &Path,
source: &str,
_silent: bool,
update: bool,
) -> std::io::Result<bool> {
let mut success = true;
let path = canonical(path);
let diags = compile_diagnostics
.iter()
.filter(|d| {
canonical(
d.source_file()
.unwrap_or_else(|| panic!("{path:?}: Error without a source file {d:?}",)),
) == path
})
.collect::<Vec<_>>();
let lines = source
.bytes()
.enumerate()
.filter_map(|(i, c)| if c == b'\n' { Some(i) } else { None })
.collect::<Vec<usize>>();
let mut expected = extract_expected_diags(source);
let captures: Vec<_> =
expected.iter().map(|expected| &expected.comment_range).cloned().collect();
for diag in &diags {
fn compare_message(message: &str, expected_message: &str) -> bool {
if message == expected_message {
return true;
}
#[cfg(target_os = "windows")]
if message.replace('\\', "/") == expected_message.replace('\\', "/") {
return true;
}
false
}
let (l, c) = diag.line_column();
let diag_start = lines.get(l.wrapping_sub(2)).unwrap_or(&0) + c;
let (l, c) = diagnostics::diagnostic_end_line_column_with_format(diag, ByteFormat::Utf8);
let diag_end = lines.get(l.wrapping_sub(2)).unwrap_or(&0) + c - 1;
let expected_start = expected.iter().position(|expected| {
Some(diag_start) == expected.start
&& compare_message(diag.message(), &expected.message)
&& diag.level() == expected.level
&& (expected.end.is_none() || Some(diag_end) == expected.end)
});
let expected_end = expected.iter().position(|expected| {
Some(diag_end) == expected.end
&& compare_message(diag.message(), &expected.message)
&& diag.level() == expected.level
&& (expected.start.is_none() || Some(diag_start) == expected.start)
});
let found_match = match (expected_start, expected_end) {
(Some(start), Some(end)) => {
expected.remove(start.max(end));
if start != end {
expected.remove(start.min(end));
}
true
}
(Some(start), None) => {
println!("{path:?}: Could not find end of error/warning: {diag:#?}");
expected.remove(start);
false
}
(None, Some(end)) => {
println!("{path:?}: Could not find start of error/warning: {diag:#?}");
expected.remove(end);
false
}
(None, None) => {
println!("{path:?}: Unexpected error/warning: {diag:#?}, {diag_start}, {diag_end}",);
false
}
};
if !found_match {
success = false;
#[cfg(feature = "display-diagnostics")]
if !_silent {
let mut to_report = BuildDiagnostics::default();
to_report.push_compiler_error((*diag).clone());
to_report.print();
}
}
}
for expected in expected {
success = false;
println!(
"{path:?}: {level:?} not found at offset {start:?}-{end:?}: {message:?}",
level = expected.level,
start = expected.start,
end = expected.end,
message = expected.message
);
}
if !success && update {
let mut source = source.to_string();
self::update(&diags, &mut source, lines, &captures);
std::fs::write(path, source).unwrap();
}
Ok(success)
}
fn update(
diags: &[&Diagnostic],
source: &mut String,
mut lines: Vec<usize>,
to_remove: &[std::ops::Range<usize>],
) {
for to_remove in to_remove.iter().rev() {
source.drain(to_remove.clone());
for l in &mut lines {
if *l > to_remove.start {
*l -= to_remove.end - to_remove.start;
}
}
}
let mut last_line_adjust = Vec::from_iter(std::iter::repeat_n(0, lines.len()));
for d in diags {
let mut insert_range_at = |range: &str, l, c: usize| {
let column_adjust = if c < 3 { "<".repeat(3 - c) } else { "".to_string() };
let byte_offset = lines[l - 1] + 1;
let level = match d.level() {
DiagnosticLevel::Error => "error",
DiagnosticLevel::Warning => "warning",
DiagnosticLevel::Note => "note",
_ => todo!(),
};
let to_insert = format!(
"//{indent}{range}{adjust}{column_adjust}{level}{{{message}}}\n",
indent = " ".repeat(c.max(3) - 3),
adjust = "^".repeat(last_line_adjust[l - 1]),
message = d.message().replace('\n', "↵").replace(env!("CARGO_MANIFEST_DIR"), "📂")
);
if byte_offset > source.len() {
source.push('\n');
}
source.insert_str(byte_offset, &to_insert);
for line_offset in lines.iter_mut().skip(l - 1) {
*line_offset += to_insert.len();
}
last_line_adjust[l - 1] += 1;
};
let (line_start, column_start) = d.line_column();
let (line_end, column_end) =
diagnostics::diagnostic_end_line_column_with_format(d, ByteFormat::Utf8);
let range = if d.length() <= 1 {
if d.length() == 0 { "|" } else { "^" }.to_owned()
} else {
let end = if line_start == line_end {
" ".repeat(column_end - column_start - 2) + "<"
} else {
"".to_owned()
};
format!(">{end}")
};
insert_range_at(&range, line_start, column_start);
if line_start != line_end && d.length() > 1 {
insert_range_at("<", line_end, column_end - 1);
}
}
}
fn canonical(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_owned())
}
fn process_file_source(
path: &std::path::Path,
source: String,
silent: bool,
update: bool,
) -> std::io::Result<bool> {
let mut parse_diagnostics = BuildDiagnostics::default();
let syntax_node =
i_slint_compiler::parser::parse(source.clone(), Some(path), &mut parse_diagnostics);
let has_parse_error = parse_diagnostics.has_errors();
#[cfg(feature = "slint-sc")]
let output_format =
if path.parent().and_then(|p| p.file_name()).is_some_and(|n| n == "slint-sc") {
i_slint_compiler::generator::OutputFormat::SlintSc
} else {
i_slint_compiler::generator::OutputFormat::Interpreter
};
#[cfg(not(feature = "slint-sc"))]
let output_format = i_slint_compiler::generator::OutputFormat::Interpreter;
#[cfg(feature = "slint-sc")]
let is_slint_sc = matches!(output_format, i_slint_compiler::generator::OutputFormat::SlintSc);
#[cfg(not(feature = "slint-sc"))]
let is_slint_sc = false;
let mut compiler_config = i_slint_compiler::CompilerConfiguration::new(output_format);
compiler_config.library_paths = [(
"test-lib".into(),
concat!(env!("CARGO_MANIFEST_DIR"), "/tests/typeloader/library").into(),
)]
.into_iter()
.collect();
compiler_config.embed_resources = i_slint_compiler::EmbedResourcesKind::OnlyBuiltinResources;
compiler_config.enable_experimental = true;
compiler_config.style = Some("fluent".into());
compiler_config.components_to_generate =
if is_slint_sc || source.contains("config:generate_all_exported_windows") {
ComponentSelection::ExportedWindows
} else {
ComponentSelection::LastExported
};
let compile_diagnostics = if !parse_diagnostics.has_errors() {
let (_, build_diags, _) = spin_on::spin_on(i_slint_compiler::compile_syntax_node(
syntax_node.clone(),
parse_diagnostics,
compiler_config.clone(),
));
build_diags
} else {
parse_diagnostics
};
let mut success = true;
success &= process_diagnostics(&compile_diagnostics, path, &source, silent, update)?;
for p in &compile_diagnostics.all_loaded_files {
let source = if p.is_absolute() {
std::fs::read_to_string(p)?
} else {
String::new()
};
success &= process_diagnostics(&compile_diagnostics, p, &source, silent, update)?;
}
if has_parse_error {
spin_on::spin_on(i_slint_compiler::compile_syntax_node(
syntax_node,
compile_diagnostics,
compiler_config,
));
}
Ok(success)
}
#[test]
fn self_test() -> std::io::Result<()> {
let fake_path = std::path::Path::new("fake.slint");
let process = |str: &str| process_file_source(fake_path, str.into(), true, false);
assert!(process(
r#"
export component Foo inherits Window { width: 10px; }
"#
)?);
assert!(!process(
r#"
export component Foo inherits Window { width: 10px; }
// ^error{i want an error}
"#
)?);
assert!(!process(
r#"
export component Foo inherits Window foo { width: 10px; }
"#
)?);
assert!(process(
r#"
export component Foo inherits Window foo { width: 10px; }
// > <error{Syntax error: expected '{'}
"#
)?);
assert!(process(
r#"
export component Foo inherits Window foo { width: 10px; }
// > <^error{Syntax error: expected '{'}
"#
)?);
assert!(process(
r#"
export component Foo inherits Window foo { width: 10px; }
// > <<<<error{Syntax error: expected '{'}
"#
)?);
assert!(process(
r#"
export component Foo inherits Window foo { width: 10px; }
// >error{Syntax error: expected '{'}
// <^error{Syntax error: expected '{'}
"#
)?);
assert!(!process(
r#"
export component Foo inherits Window foo { width: 10px; }
// > <error{Syntax error: expected '{'}
"#
)?);
assert!(!process(
r#"
export component Foo inherits Window foo { width: 10px; }
// > <error{Syntax error: expected '{'}
"#
)?);
assert!(!process(
r#"
export component Foo inherits Window foo { width: 10px; }
// > <error{foo_bar}
"#
)?);
assert!(!process(
r#"
export component Foo inherits Window foo { width: 10px; }
// > <^^error{Syntax error: expected '{'}
"#
)?);
assert!(process(
"\r\n\
export component Foo inherits Window foo { width: 10px; }\r\n\
// > <error{Syntax error: expected '{'}\r\n\
"
)?);
Ok(())
}