diff --git a/Cargo.toml b/Cargo.toml index 046704e0..4051074c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,6 @@ serde_json = "1.0" [dev-dependencies] criterion = "0.3" rayon = "1.0.0" -regex = "1.0" getopts = "0.2" pretty_assertions = "0.6" diff --git a/examples/syntest.rs b/examples/syntest.rs index aa4abc30..c120ea99 100644 --- a/examples/syntest.rs +++ b/examples/syntest.rs @@ -7,22 +7,20 @@ // you can tell it where to parse them from - the following will execute only 1 syntax test after // parsing the sublime-syntax files in the JavaScript folder: // cargo run --example syntest testdata/Packages/JavaScript/syntax_test_json.json testdata/Packages/JavaScript/ -#[macro_use] -extern crate lazy_static; +extern crate syntect; +extern crate walkdir; +extern crate getopts; -use syntect::parsing::{SyntaxSet, SyntaxSetBuilder, ParseState, ScopeStack, Scope}; -use syntect::highlighting::ScopeSelectors; -use syntect::easy::{ScopeRegionIterator}; +use syntect::parsing::{SyntaxSet, SyntaxSetBuilder}; +use syntect::syntax_tests::{SyntaxTestFileResult, SyntaxTestOutputOptions, process_syntax_test_assertions, parse_syntax_test_header_line, SyntaxTestHeader}; use std::path::Path; +use std::io::prelude::*; use std::io::{BufRead, BufReader}; use std::fs::File; -use std::cmp::{min, max}; use std::time::Instant; -use std::str::FromStr; use getopts::Options; -use regex::Regex; use walkdir::{DirEntry, WalkDir}; #[derive(Debug, Clone, PartialEq, Eq)] @@ -31,230 +29,36 @@ pub enum SyntaxTestHeaderError { SyntaxDefinitionNotFound, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SyntaxTestFileResult { - FailedAssertions(usize, usize), - Success(usize), -} - -lazy_static! { - pub static ref SYNTAX_TEST_HEADER_PATTERN: Regex = Regex::new(r#"(?xm) - ^(?P\s*\S+) - \s+SYNTAX\sTEST\s+ - "(?P[^"]+)" - \s*(?P\S+)?$ - "#).unwrap(); - pub static ref SYNTAX_TEST_ASSERTION_PATTERN: Regex = Regex::new(r#"(?xm) - \s*(?: - (?P<-)|(?P\^+) - )(.*)$"#).unwrap(); -} - -#[derive(Clone, Copy)] -struct OutputOptions { - time: bool, - debug: bool, - summary: bool, -} - -#[derive(Debug)] -struct AssertionRange<'a> { - begin_char: usize, - end_char: usize, - scope_selector_text: &'a str, - is_pure_assertion_line: bool, -} - -#[derive(Debug)] -struct ScopedText { - scope: Vec, - char_start: usize, - text_len: usize, -} - -#[derive(Debug)] -struct RangeTestResult { - column_begin: usize, - column_end: usize, - success: bool, -} -fn get_line_assertion_details<'a>(testtoken_start: &str, testtoken_end: Option<&str>, line: &'a str) -> Option> { - // if the test start token specified in the test file's header is on the line - if let Some(index) = line.find(testtoken_start) { - let (before_token_start, token_and_rest_of_line) = line.split_at(index); - - if let Some(captures) = SYNTAX_TEST_ASSERTION_PATTERN.captures(&token_and_rest_of_line[testtoken_start.len()..]) { - let mut sst = captures.get(3).unwrap().as_str(); // get the scope selector text - let mut only_whitespace_after_token_end = true; - - if let Some(token) = testtoken_end { // if there is an end token defined in the test file header - if let Some(end_token_pos) = sst.find(token) { // and there is an end token in the line - let (ss, after_token_end) = sst.split_at(end_token_pos); // the scope selector text ends at the end token - sst = &ss; - only_whitespace_after_token_end = after_token_end.trim_end().is_empty(); - } - } - return Some(AssertionRange { - begin_char: index + if captures.get(2).is_some() { testtoken_start.len() + captures.get(2).unwrap().start() } else { 0 }, - end_char: index + if captures.get(2).is_some() { testtoken_start.len() + captures.get(2).unwrap().end() } else { 1 }, - scope_selector_text: sst, - is_pure_assertion_line: before_token_start.trim_start().is_empty() && only_whitespace_after_token_end, // if only whitespace surrounds the test tokens on the line, then it is a pure assertion line - }); - } - } - None -} - -fn process_assertions(assertion: &AssertionRange<'_>, test_against_line_scopes: &Vec) -> Vec { - // format the scope selector to include a space at the beginning, because, currently, ScopeSelector expects excludes to begin with " -" - // and they are sometimes in the syntax test as ^^^-comment, for example - let selector = ScopeSelectors::from_str(&format!(" {}", &assertion.scope_selector_text)).unwrap(); - // find the scope at the specified start column, and start matching the selector through the rest of the tokens on the line from there until the end column is reached - let mut results = Vec::new(); - for scoped_text in test_against_line_scopes.iter().skip_while(|s|s.char_start + s.text_len <= assertion.begin_char).take_while(|s|s.char_start < assertion.end_char) { - let match_value = selector.does_match(scoped_text.scope.as_slice()); - let result = RangeTestResult { - column_begin: max(scoped_text.char_start, assertion.begin_char), - column_end: min(scoped_text.char_start + scoped_text.text_len, assertion.end_char), - success: match_value.is_some() - }; - results.push(result); - } - // don't ignore assertions after the newline, they should be treated as though they are asserting against the newline - let last = test_against_line_scopes.last().unwrap(); - if last.char_start + last.text_len < assertion.end_char { - let match_value = selector.does_match(last.scope.as_slice()); - let result = RangeTestResult { - column_begin: max(last.char_start + last.text_len, assertion.begin_char), - column_end: assertion.end_char, - success: match_value.is_some() - }; - results.push(result); - } - results -} - -/// If `parse_test_lines` is `false` then lines that only contain assertions are not parsed -fn test_file(ss: &SyntaxSet, path: &Path, parse_test_lines: bool, out_opts: OutputOptions) -> Result { - use syntect::util::debug_print_ops; +fn test_file(ss: &SyntaxSet, path: &Path, out_opts: SyntaxTestOutputOptions) -> Result { let f = File::open(path).unwrap(); let mut reader = BufReader::new(f); - let mut line = String::new(); + let mut header_line = String::new(); // read the first line from the file - if we have reached EOF already, it's an invalid file - if reader.read_line(&mut line).unwrap() == 0 { + if reader.read_line(&mut header_line).unwrap() == 0 { return Err(SyntaxTestHeaderError::MalformedHeader); } - - line = line.replace("\r", &""); + header_line = header_line.replace("\r", &""); // parse the syntax test header in the first line of the file - let header_line = line.clone(); - let search_result = SYNTAX_TEST_HEADER_PATTERN.captures(&header_line); - let captures = search_result.ok_or(SyntaxTestHeaderError::MalformedHeader)?; - - let testtoken_start = captures.name("testtoken_start").unwrap().as_str(); - let testtoken_end = captures.name("testtoken_end").map_or(None, |c|Some(c.as_str())); - let syntax_file = captures.name("syntax_file").unwrap().as_str(); + let SyntaxTestHeader { testtoken_start, testtoken_end, syntax_file, reindent_text: _ } = parse_syntax_test_header_line(&header_line).ok_or(SyntaxTestHeaderError::MalformedHeader)?; // find the relevant syntax definition to parse the file with - case is important! if !out_opts.summary { - println!("The test file references syntax definition file: {}", syntax_file); + println!("The test file references syntax definition file: {}", syntax_file); //" and the start test token is {} and the end token is {:?}", testtoken_start, testtoken_end); } let syntax = ss.find_syntax_by_path(syntax_file).ok_or(SyntaxTestHeaderError::SyntaxDefinitionNotFound)?; - // iterate over the lines of the file, testing them - let mut state = ParseState::new(syntax); - let mut stack = ScopeStack::new(); - - let mut current_line_number = 1; - let mut test_against_line_number = 1; - let mut scopes_on_line_being_tested = Vec::new(); - let mut previous_non_assertion_line = line.to_string(); - - let mut assertion_failures: usize = 0; - let mut total_assertions: usize = 0; - - loop { // over lines of file, starting with the header line - let mut line_only_has_assertion = false; - let mut line_has_assertion = false; - if let Some(assertion) = get_line_assertion_details(testtoken_start, testtoken_end, &line) { - let result = process_assertions(&assertion, &scopes_on_line_being_tested); - total_assertions += &assertion.end_char - &assertion.begin_char; - for failure in result.iter().filter(|r|!r.success) { - let length = failure.column_end - failure.column_begin; - let text: String = previous_non_assertion_line.chars().skip(failure.column_begin).take(length).collect(); - if !out_opts.summary { - println!(" Assertion selector {:?} \ - from line {:?} failed against line {:?}, column range {:?}-{:?} \ - (with text {:?}) \ - has scope {:?}", - assertion.scope_selector_text.trim(), - current_line_number, test_against_line_number, failure.column_begin, failure.column_end, - text, - scopes_on_line_being_tested.iter().skip_while(|s|s.char_start + s.text_len <= failure.column_begin).next().unwrap_or(scopes_on_line_being_tested.last().unwrap()).scope - ); - } - assertion_failures += failure.column_end - failure.column_begin; - } - line_only_has_assertion = assertion.is_pure_assertion_line; - line_has_assertion = true; - } - if !line_only_has_assertion || parse_test_lines { - if !line_has_assertion { // ST seems to ignore lines that have assertions when calculating which line the assertion tests against - scopes_on_line_being_tested.clear(); - test_against_line_number = current_line_number; - previous_non_assertion_line = line.to_string(); - } - if out_opts.debug && !line_only_has_assertion { - println!("-- debugging line {} -- scope stack: {:?}", current_line_number, stack); - } - let ops = state.parse_line(&line, &ss); - if out_opts.debug && !line_only_has_assertion { - if ops.is_empty() && !line.is_empty() { - println!("no operations for this line..."); - } else { - debug_print_ops(&line, &ops); - } - } - let mut col: usize = 0; - for (s, op) in ScopeRegionIterator::new(&ops, &line) { - stack.apply(op); - if s.is_empty() { // in this case we don't care about blank tokens - continue; - } - if !line_has_assertion { - // if the line has no assertions on it, remember the scopes on the line so we can test against them later - let len = s.chars().count(); - scopes_on_line_being_tested.push( - ScopedText { - char_start: col, - text_len: len, - scope: stack.as_slice().to_vec() - } - ); - // TODO: warn when there are duplicate adjacent (non-meta?) scopes, as it is almost always undesired - col += len; - } - } - } + let mut contents = String::new(); + contents.push_str(&header_line); + reader.read_to_string(&mut contents).expect("Unable to read file"); + contents = contents.replace("\r", &""); - line.clear(); - current_line_number += 1; - if reader.read_line(&mut line).unwrap() == 0 { - break; - } - line = line.replace("\r", &""); - } - let res = if assertion_failures > 0 { - Ok(SyntaxTestFileResult::FailedAssertions(assertion_failures, total_assertions)) - } else { - Ok(SyntaxTestFileResult::Success(total_assertions)) - }; + let res = process_syntax_test_assertions(&ss, &syntax, &contents, testtoken_start, testtoken_end, &out_opts); if out_opts.summary { - if let Ok(SyntaxTestFileResult::FailedAssertions(failures, _)) = res { + if let SyntaxTestFileResult::FailedAssertions(failures, _) = res { // Don't print total assertion count so that diffs don't pick up new succeeding tests println!("FAILED {}: {}", path.display(), failures); } @@ -262,7 +66,7 @@ fn test_file(ss: &SyntaxSet, path: &Path, parse_test_lines: bool, out_opts: Outp println!("{:?}", res); } - res + Ok(res) } fn main() { @@ -271,6 +75,7 @@ fn main() { opts.optflag("d", "debug", "Show parsing results for each test line"); opts.optflag("t", "time", "Time execution as a more broad-ranging benchmark"); opts.optflag("s", "summary", "Print only summary of test failures"); + opts.optflag("f", "failfast", "Stop at first failure"); let matches = match opts.parse(&args[1..]) { Ok(m) => { m } @@ -305,10 +110,11 @@ fn main() { ss = builder.build(); } - let out_opts = OutputOptions { + let out_opts = SyntaxTestOutputOptions { debug: matches.opt_present("debug"), time: matches.opt_present("time"), summary: matches.opt_present("summary"), + failfast: matches.opt_present("failfast"), }; let exit_code = recursive_walk(&ss, &tests_path, out_opts); @@ -318,7 +124,7 @@ fn main() { } -fn recursive_walk(ss: &SyntaxSet, path: &str, out_opts: OutputOptions) -> i32 { +fn recursive_walk(ss: &SyntaxSet, path: &str, out_opts: SyntaxTestOutputOptions) -> i32 { let mut exit_code: i32 = 0; // exit with code 0 by default, if all tests pass let walker = WalkDir::new(path).into_iter(); @@ -337,7 +143,7 @@ fn recursive_walk(ss: &SyntaxSet, path: &str, out_opts: OutputOptions) -> i32 { println!("Testing file {}", path.display()); } let start = Instant::now(); - let result = test_file(&ss, path, true, out_opts); + let result = test_file(&ss, path, out_opts); let elapsed = start.elapsed(); if out_opts.time { let ms = (elapsed.as_secs() * 1_000) + (elapsed.subsec_nanos() / 1_000_000) as u64; @@ -345,10 +151,17 @@ fn recursive_walk(ss: &SyntaxSet, path: &str, out_opts: OutputOptions) -> i32 { } if exit_code != 2 { // leave exit code 2 if there was an error if let Err(_) = result { // set exit code 2 if there was an error + println!("{:?}", result); exit_code = 2; + if out_opts.failfast { + break; + } } else if let Ok(ok) = result { if let SyntaxTestFileResult::FailedAssertions(_, _) = ok { exit_code = 1; // otherwise, if there were failures, exit with code 1 + if out_opts.failfast { + break; + } } } } diff --git a/src/lib.rs b/src/lib.rs index 34ac5c38..dc44df63 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,8 @@ extern crate pretty_assertions; pub mod dumps; #[cfg(feature = "parsing")] pub mod easy; +#[cfg(feature = "parsing")] +pub mod syntax_tests; #[cfg(feature = "html")] mod escape; pub mod highlighting; diff --git a/src/syntax_tests.rs b/src/syntax_tests.rs new file mode 100644 index 00000000..1a32ee65 --- /dev/null +++ b/src/syntax_tests.rs @@ -0,0 +1,444 @@ +//! API for running syntax tests. +//! See http://www.sublimetext.com/docs/3/syntax.html#testing + +//use std::io::Write; +use std::str::FromStr; +use crate::parsing::{ScopeStack, ParseState, SyntaxReference, SyntaxSet, Scope}; +use crate::util::debug_print_ops; +use crate::easy::ScopeRegionIterator; +use crate::highlighting::ScopeSelectors; + +#[derive(Clone, Copy)] +pub struct SyntaxTestOutputOptions { + pub time: bool, + pub debug: bool, + pub summary: bool, + pub failfast: bool, + //pub output: &'a Write, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SyntaxTestFileResult { + FailedAssertions(usize, usize), + Success(usize), +} + +#[derive(Debug)] +pub struct SyntaxTestAssertionRange { + pub test_line_offset: usize, + pub line_number: usize, + pub begin_char: usize, + pub end_char: usize, + pub scope_selector: ScopeSelectors, + pub scope_selector_text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SyntaxTestHeader<'a> { + pub testtoken_start: &'a str, + pub testtoken_end: Option<&'a str>, + pub syntax_file: &'a str, + pub reindent_text: Option<&'a str>, +} + +pub fn parse_syntax_test_header_line(header_line: &str) -> Option { // TODO: use a "impl<'a> From<&'a str> for SyntaxTestHeader<'a>" instead? + if let Some(pos) = &header_line.find(&" SYNTAX TEST ") { + let after_text_pos = *pos + &" SYNTAX TEST ".len(); + if let Some(quote_start_pos) = &header_line[after_text_pos..].find("\"") { + let reindent_text = if *quote_start_pos == 0 { None } else { Some(header_line[after_text_pos..after_text_pos + *quote_start_pos].trim()) }; + let filename_part = &header_line[after_text_pos + *quote_start_pos + 1..]; + if let Some(close_pos) = filename_part.find(&"\"") { + let end_token = filename_part[close_pos + 1..].trim(); + return Some(SyntaxTestHeader { + testtoken_start: &header_line[0..*pos], + testtoken_end: if end_token.len() == 0 { None } else { Some(end_token) }, + syntax_file: &filename_part[0..close_pos], + reindent_text: reindent_text, + }); + } + } + } + None +} + +/// Given a start token, option end token and text, parse the syntax tests in the text +/// that follow the format described at http://www.sublimetext.com/docs/3/syntax.html#testing +/// and return the scope selector assertions found, so that when the text is parsed, +/// the assertions can be checked +pub fn get_syntax_test_assertions(token_start: &str, token_end: Option<&str>, text: &str) -> Vec { + let mut assertions = Vec::new(); + let mut test_line_offset = 0; + let mut test_line_len = 0; + let mut line_number = 0; + let mut offset = 0; + + for line in text.lines() { + line_number += 1; + let mut line_has_assertions = false; + + // if the test start token specified is on the line + if let Some(index) = line.find(token_start) { + let token_and_rest_of_line = line.split_at(index).1; + + let rest_of_line = &token_and_rest_of_line[token_start.len()..]; + if let Some(assertion_index) = rest_of_line.find("<-").or_else(|| rest_of_line.find('^')) { + let mut assertion_range = 0; + while rest_of_line.chars().nth(assertion_index + assertion_range) == Some('^') { + assertion_range += 1; + } + let skip_assertion_chars = if assertion_range == 0 { 2 } else { assertion_range }; + + let mut selector_text : String = rest_of_line.chars().skip(assertion_index + skip_assertion_chars).collect(); // get the scope selector text + + if let Some(token) = token_end { // if there is an end token defined in the test file header + if let Some(end_token_pos) = selector_text.find(token) { // and there is an end token in the line + selector_text = selector_text.chars().take(end_token_pos).collect(); // the scope selector text ends at the end token + } + } + + let mut assertion = SyntaxTestAssertionRange { + test_line_offset: test_line_offset, + line_number: line_number, + begin_char: index + if assertion_range > 0 { token_start.len() + assertion_index } else { 0 }, + end_char: index + if assertion_range > 0 { token_start.len() + assertion_index + assertion_range } else { 1 }, + + // format the scope selector to include a space at the beginning, because, currently, ScopeSelector expects excludes to begin with " -" + // and they are sometimes in the syntax test as ^^^-comment, for example + scope_selector: ScopeSelectors::from_str(&format!(" {}", &selector_text)).expect(&format!("Scope selector invalid on line {}", line_number)), + scope_selector_text: selector_text, + }; + // if the assertion spans over the line being tested + if assertion.end_char > test_line_len { + // calculate where on the next line the assertions will occur + let remainder = SyntaxTestAssertionRange { + test_line_offset: test_line_offset + test_line_len, + line_number: line_number, + begin_char: 0, + end_char: assertion.end_char - test_line_len, + scope_selector: assertion.scope_selector.clone(), + scope_selector_text: assertion.scope_selector_text.clone(), + }; + assertion.end_char = test_line_len; + assertions.push(assertion); + assertions.push(remainder); + } else { + assertions.push(assertion); + } + + line_has_assertions = true; + } + } + if !line_has_assertions { // ST seems to ignore lines that have assertions when calculating which line the assertion tests against, regardless of whether they contain any other text + test_line_offset = offset; + test_line_len = line.len() + 1; + } + offset += line.len() + 1; // the +1 is for the `\n`. TODO: maybe better to loop over the lines including the newline chars, using https://stackoverflow.com/a/40457615/4473405 + } + assertions +} + +/// Process the syntax test assertions in the given text, for the given syntax definition, using the test token(s) specified. +/// It works by finding all the syntax test assertions, then parsing the text line by line. If the line has some assertions against it, those are checked. +/// Assertions are counted according to their status - succeeded or failed. Failures are also logged to stdout, depending on the output options. +/// When there are no assertions left to check, it returns those counts. +/// `text` is the code containing syntax test assertions to be parsed and checked. +/// `testtoken_start` is the token (normally a comment in the given syntax) that represents that assertions could be on the line. +/// `testtoken_end` is an optional token that will be stripped from the line when retrieving the scope selector. Useful for syntaxes when the start token represents a block comment, to make the tests easier to construct. +pub/*(crate)*/ fn process_syntax_test_assertions(syntax_set: &SyntaxSet, syntax: &SyntaxReference, text: &str, testtoken_start: &str, testtoken_end: Option<&str>, out_opts: &SyntaxTestOutputOptions) -> SyntaxTestFileResult { + #[derive(Debug)] + struct ScopedText { + scope: Vec, + char_start: usize, + text_len: usize, + } + + #[derive(Debug)] + struct RangeTestResult { + column_begin: usize, + column_end: usize, + success: bool, + actual_scope: String, + } + + fn process_assertions(assertion: &SyntaxTestAssertionRange, test_against_line_scopes: &Vec) -> Vec { + use std::cmp::{min, max}; + // find the scope at the specified start column, and start matching the selector through the rest of the tokens on the line from there until the end column is reached + let mut results = Vec::new(); + for scoped_text in test_against_line_scopes.iter().skip_while(|s|s.char_start + s.text_len <= assertion.begin_char).take_while(|s|s.char_start < assertion.end_char) { + let match_value = assertion.scope_selector.does_match(scoped_text.scope.as_slice()); + let result = RangeTestResult { + column_begin: max(scoped_text.char_start, assertion.begin_char), + column_end: min(scoped_text.char_start + scoped_text.text_len, assertion.end_char), + success: match_value.is_some(), + actual_scope: format!("{:?}", scoped_text.scope.as_slice()), + }; + results.push(result); + } + results + } + + let assertions = get_syntax_test_assertions(testtoken_start, testtoken_end, &text); + //println!("{:?}", assertions); + + // iterate over the lines of the file, testing them + let mut state = ParseState::new(syntax); + let mut stack = ScopeStack::new(); + + let mut offset = 0; + let mut scopes_on_line_being_tested = Vec::new(); + let mut line_number = 0; + let mut relevant_assertions = Vec::new(); + let mut assertion_index = 0; + + let mut assertion_failures: usize = 0; + let mut total_assertions: usize = 0; + + for line_without_char in text.lines() { + let line = &(line_without_char.to_owned() + "\n"); + line_number += 1; + + let eol_offset = offset + line.len(); + + // parse the line + let ops = state.parse_line(&line, &syntax_set); + // find all the assertions that relate to the current line + relevant_assertions.clear(); + while assertion_index < assertions.len() { + let assertion = &assertions[assertion_index]; + let pos = assertion.test_line_offset + assertion.begin_char; + if pos >= offset && pos < eol_offset { + relevant_assertions.push(assertion); + assertion_index += 1; + } else { + break; + } + } + if !relevant_assertions.is_empty() { + // if there are assertions for the line, show the operations for debugging purposes if specified in the output options + // (if there are no assertions, or the line contains assertions, debugging it would probably just add noise) + scopes_on_line_being_tested.clear(); + if out_opts.debug { + println!("-- debugging line {} -- scope stack: {:?}", line_number, stack); + if ops.is_empty() && !line.is_empty() { + println!("no operations for this line..."); + } else { + debug_print_ops(&line, &ops); + } + } + } + + { + let mut col: usize = 0; + for (s, op) in ScopeRegionIterator::new(&ops, &line) { + stack.apply(op); + if s.is_empty() { // in this case we don't care about blank tokens + continue; + } + // if there are assertions against this line, store the scopes for comparison with the assertions + if !relevant_assertions.is_empty() { + let len = s.chars().count(); + scopes_on_line_being_tested.push( + ScopedText { + char_start: col, + text_len: len, + scope: stack.as_slice().to_vec() + } + ); + col += len; + } + } + } + + for assertion in &relevant_assertions { + let results = process_assertions(&assertion, &scopes_on_line_being_tested); + + for result in results { + let length = result.column_end - result.column_begin; + total_assertions += length; + if !result.success { + assertion_failures += length; + if !out_opts.summary { + let text: String = line.chars().skip(result.column_begin).take(length).collect(); + println!(" Assertion selector {:?} \ + from line {:?} failed against line {:?}, column range {:?}-{:?} \ + (with text {:?}) \ + has scope {:?}", + &assertion.scope_selector_text.trim(), + &assertion.line_number, line_number, result.column_begin, result.column_end, + text, + result.actual_scope, + ); + } + } + } + } + + offset = eol_offset; + + // no point continuing to parse the file if there are no syntax test assertions left + // (unless we want to prove that no panics etc. occur while parsing the rest of the file ofc...) + if assertion_index == assertions.len() || (assertion_failures > 0 && out_opts.failfast) { + // NOTE: the total counts only really show how many assertions were checked when failing fast + // - they are not accurate total counts + break; + } + } + + let res = if assertion_failures > 0 { + SyntaxTestFileResult::FailedAssertions(assertion_failures, total_assertions) + } else { + SyntaxTestFileResult::Success(total_assertions) + }; + res +} + +// #[cfg(test)] +// mod tests { + #[test] + fn can_find_test_assertions() { + let text = "\ + hello world\n\ + // <- assertion1\n\ + // ^^ assertion2\n\ + \n\ + foobar\n\ + // ^ - assertion3\n\ + "; + let result = get_syntax_test_assertions(&"//", None, &text); + + assert_eq!(result.len(), 3); + assert_eq!(result[0].line_number, 2); + assert_eq!(result[1].line_number, 3); + assert_eq!(result[2].line_number, 6); + assert_eq!(result[0].test_line_offset, result[1].test_line_offset); + assert_eq!(result[2].test_line_offset, text.find("foobar").unwrap()); + assert_eq!(result[0].scope_selector_text, " assertion1"); + assert_eq!(result[1].scope_selector_text, " assertion2"); + assert_eq!(result[2].scope_selector_text, " - assertion3"); + assert_eq!(result[0].begin_char, 0); + assert_eq!(result[0].end_char, 1); + assert_eq!(result[1].begin_char, 3); + assert_eq!(result[1].end_char, 5); + assert_eq!(result[2].begin_char, 6); + assert_eq!(result[2].end_char, 7); + } + + #[test] + fn can_find_test_assertions_with_end_tokens() { + let text = " +hello world + + +"; + let result = get_syntax_test_assertions(&""), &text); + + assert_eq!(result.len(), 3); + assert_eq!(result[0].line_number, 3); + assert_eq!(result[1].line_number, 4); + assert_eq!(result[2].line_number, 7); + assert_eq!(result[0].test_line_offset, result[1].test_line_offset); + assert_eq!(result[2].test_line_offset, text.find("foobar").unwrap()); + assert_eq!(result[0].scope_selector_text, " assertion1 "); + assert_eq!(result[1].scope_selector_text, "assertion2"); + assert_eq!(result[2].scope_selector_text, " - assertion3 "); + assert_eq!(result[0].begin_char, 1); + assert_eq!(result[0].end_char, 2); + assert_eq!(result[1].begin_char, 6); + assert_eq!(result[1].end_char, 8); + assert_eq!(result[2].begin_char, 5); + assert_eq!(result[2].end_char, 6); + } + + #[test] + fn can_find_test_assertions_that_spans_lines() { + let text = " +hello world + +foobar + +"; + let result = get_syntax_test_assertions(&""), &text); + println!("{:?}", result); + + assert_eq!(result.len(), 6); + assert_eq!(result[0].line_number, 3); + assert_eq!(result[1].line_number, 3); + assert_eq!(result[2].line_number, 4); + assert_eq!(result[3].line_number, 4); + assert_eq!(result[4].line_number, 6); + assert_eq!(result[5].line_number, 6); + assert_eq!(result[0].scope_selector_text, " assertion1"); + assert_eq!(result[1].scope_selector_text, " assertion1"); + assert_eq!(result[2].scope_selector_text, " assertion2 "); + assert_eq!(result[3].scope_selector_text, " assertion2 "); + assert_eq!(result[4].scope_selector_text, " -assertion3"); + assert_eq!(result[5].scope_selector_text, " -assertion3"); + assert_eq!(result[0].begin_char, 6); + assert_eq!(result[0].end_char, 12); + assert_eq!(result[0].test_line_offset, 1); + assert_eq!(result[1].begin_char, 0); + assert_eq!(result[1].end_char, 3); + assert_eq!(result[1].test_line_offset, "\nhello world\n".len()); + + assert_eq!(result[2].begin_char, 8); + assert_eq!(result[2].end_char, 12); + assert_eq!(result[2].test_line_offset, 1); + assert_eq!(result[3].begin_char, 0); + assert_eq!(result[3].end_char, 3); + assert_eq!(result[3].test_line_offset, "\nhello world\n".len()); + + assert_eq!(result[4].begin_char, 5); + assert_eq!(result[4].end_char, 7); + assert_eq!(result[5].begin_char, 0); + assert_eq!(result[5].end_char, 1); + } + + #[test] + fn can_parse_syntax_test_header_with_end_token() { + let header = parse_syntax_test_header_line(&"").unwrap(); + assert_eq!(&header.testtoken_start, &""); + assert_eq!(&header.syntax_file, &"XML.sublime-syntax"); + assert!(&header.reindent_text.is_none()); + } + + #[test] + fn can_parse_syntax_test_header_with_end_token_and_carriage_return() { + let header = parse_syntax_test_header_line(&"\r\n").unwrap(); + assert_eq!(&header.testtoken_start, &""); + assert_eq!(&header.syntax_file, &"XML.sublime-syntax"); + assert!(&header.reindent_text.is_none()); + } + + #[test] + fn can_parse_syntax_test_header_with_no_end_token() { + let header = parse_syntax_test_header_line(&"// SYNTAX TEST \"Packages/Example/Example.sublime-syntax\"\n").unwrap(); + assert_eq!(&header.testtoken_start, &"//"); + assert!(!header.testtoken_end.is_some()); + assert_eq!(&header.syntax_file, &"Packages/Example/Example.sublime-syntax"); + assert!(&header.reindent_text.is_none()); + } + + #[test] + fn can_parse_syntax_test_header_with_no_end_token_and_carriage_return() { + let header = parse_syntax_test_header_line(&"// SYNTAX TEST \"Packages/Example/Example.sublime-syntax\"\r").unwrap(); + assert_eq!(&header.testtoken_start, &"//"); + assert!(header.testtoken_end.is_none()); + assert_eq!(&header.syntax_file, &"Packages/Example/Example.sublime-syntax"); + assert!(&header.reindent_text.is_none()); + } + + #[test] + fn can_parse_syntax_test_reindentation_header() { + let header = parse_syntax_test_header_line(&"// SYNTAX TEST reindent-unchanged reindent-unindented \"Packages/PHP/PHP.sublime-syntax\"").unwrap(); + assert_eq!(&header.testtoken_start, &"//"); + assert!(header.testtoken_end.is_none()); + assert_eq!(&header.syntax_file, &"Packages/PHP/PHP.sublime-syntax"); + assert_eq!(&header.reindent_text.unwrap(), &"reindent-unchanged reindent-unindented"); + } +// }