use std::path::PathBuf;
mod common;
use common::bin_path;
fn editor_dir() -> PathBuf {
common::workspace_root().join("editors/vscode")
}
fn read(relative: &str) -> String {
let path = editor_dir().join(relative);
std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("the extension ships `{}`: {e}", path.display()))
}
fn grammar() -> serde_json::Value {
serde_json::from_str(&read("syntaxes/praxis.tmLanguage.json"))
.expect("the grammar is well-formed JSON")
}
fn manifest() -> serde_json::Value {
serde_json::from_str(&read("package.json")).expect("the manifest is well-formed JSON")
}
fn rule_pattern(grammar: &serde_json::Value, rule: &str) -> String {
let entry = &grammar["repository"][rule];
assert!(
!entry.is_null(),
"the grammar has no `{rule}` rule; the gate below cannot check what is not there"
);
entry["match"]
.as_str()
.or_else(|| entry["begin"].as_str())
.unwrap_or_else(|| panic!("`{rule}` has neither a `match` nor a `begin`"))
.to_string()
}
fn alternatives(pattern: &str) -> Vec<String> {
let start = match pattern.find('(') {
Some(i) => i + 1,
None => return Vec::new(),
};
let end = match pattern[start..].find(')') {
Some(i) => start + i,
None => return Vec::new(),
};
pattern[start..end]
.split('|')
.map(|alt| alt.trim().trim_start_matches("?:").to_string())
.collect()
}
fn offers(pattern: &str, word: &str) -> bool {
alternatives(pattern).iter().any(|alt| alt == word)
}
#[test]
fn the_alternation_reader_rejects_a_word_that_is_only_a_substring() {
let pattern = r"\b(int|uint|identifier|word)\b";
assert!(offers(pattern, "int"));
assert!(offers(pattern, "identifier"));
assert!(!offers(pattern, "ident"), "a prefix is not an alternative");
assert!(!offers(pattern, "or"), "an infix is not an alternative");
assert!(!offers(pattern, "rest"), "an absent word is absent");
}
#[test]
fn the_grammar_patterns_are_alternations_the_reader_can_see() {
let grammar = grammar();
for (rule, expected_at_least) in [("keywords", 17), ("capture-type", 10), ("parser-call", 14)] {
let alts = alternatives(&rule_pattern(&grammar, rule));
assert!(
alts.len() >= expected_at_least,
"`{rule}` reads as {} alternatives, expected at least {expected_at_least}: {alts:?}",
alts.len()
);
}
}
#[test]
fn every_keyword_is_in_the_grammars_keyword_pattern() {
let grammar = grammar();
let pattern = rule_pattern(&grammar, "keywords");
let keywords = praxis_syntax::SyntaxKind::all_keyword_texts();
assert!(
keywords.len() >= 17,
"the sweep found only {} keywords, which means it is not sweeping",
keywords.len()
);
for keyword in keywords {
assert!(
offers(&pattern, keyword),
"`{keyword}` is a Praxis keyword and the grammar's keyword pattern does not \
offer it — a word that quietly stops being coloured is the failure this \
gate exists for.\npattern: {pattern}"
);
}
}
#[test]
fn every_atomic_is_in_the_grammars_capture_type_pattern() {
let grammar = grammar();
let pattern = rule_pattern(&grammar, "capture-type");
for atomic in praxis_input_parser::AtomicKind::ALL {
assert!(
offers(&pattern, atomic.keyword()),
"`{}` is a capture-type atomic and the capture-type pattern does not offer it\npattern: {pattern}",
atomic.keyword()
);
}
}
#[test]
fn every_constructor_is_in_the_grammars_constructor_pattern() {
let grammar = grammar();
let pattern = rule_pattern(&grammar, "parser-call");
for ctor in praxis_input_parser::Constructor::ALL {
assert!(
offers(&pattern, ctor.keyword()),
"`{}` is a parser constructor and the constructor pattern does not offer it\npattern: {pattern}",
ctor.keyword()
);
}
}
#[test]
fn every_custom_semantic_token_maps_to_a_scope_the_grammar_emits() {
let manifest = manifest();
let grammar_text = read("syntaxes/praxis.tmLanguage.json");
let mapping = manifest["contributes"]["semanticTokenScopes"]
.as_array()
.expect("the manifest contributes semanticTokenScopes");
let praxis = mapping
.iter()
.find(|entry| entry["language"] == "praxis")
.expect("a mapping for the `praxis` language");
let scopes = praxis["scopes"]
.as_object()
.expect("the mapping is an object of token type → scopes");
for token_type in praxis_lsp::semantic::CUSTOM_TOKEN_TYPES {
let entry = scopes.get(*token_type).unwrap_or_else(|| {
panic!(
"the server's legend has the custom token type `{token_type}` and the \
extension maps no scope onto it: a theme would colour it as nothing, \
which the two layers must never do"
)
});
let list = entry
.as_array()
.unwrap_or_else(|| panic!("`{token_type}`'s mapping must be an array of scopes"));
assert!(
!list.is_empty(),
"`{token_type}` maps to an empty scope list"
);
for scope in list {
let scope = scope.as_str().expect("a scope is a string");
assert!(
grammar_text.contains(scope),
"`{token_type}` maps to `{scope}`, which the grammar never emits — the two \
layers would paint the same construct differently and the colour would \
change as the server attached"
);
}
}
}
#[test]
fn the_custom_token_types_are_the_four_parser_classes() {
assert_eq!(
praxis_lsp::semantic::CUSTOM_TOKEN_TYPES,
&[
"parserConstructor",
"parserTemplateText",
"parserCaptureName",
"parserCaptureType",
]
);
for name in praxis_lsp::semantic::CUSTOM_TOKEN_TYPES {
assert!(
praxis_lsp::semantic::TOKEN_TYPES.contains(name),
"`{name}` must also be in the advertised legend"
);
}
}
#[test]
fn the_extensions_argv_names_only_subcommands_the_cli_has() {
let argv_ts = read("src/argv.ts");
let cli_help = String::from_utf8(
std::process::Command::new(bin_path())
.arg("--help")
.output()
.expect("the binary runs")
.stdout,
)
.expect("help is UTF-8");
for subcommand in ["run", "check", "lsp"] {
assert!(
argv_ts.contains(&format!("\"{subcommand}\"")),
"`argv.ts` should name the `{subcommand}` subcommand"
);
assert!(
cli_help.contains(subcommand),
"`argv.ts` invokes `praxis {subcommand}`, which `praxis --help` does not list"
);
}
assert!(
argv_ts.contains("\"--input\""),
"`argv.ts` passes `--input`"
);
let run_help = String::from_utf8(
std::process::Command::new(bin_path())
.args(["run", "--help"])
.output()
.expect("the binary runs")
.stdout,
)
.expect("help is UTF-8");
assert!(
run_help.contains("--input"),
"`argv.ts` passes `--input` to `praxis run`, which does not take it"
);
}
#[test]
fn the_extension_contributes_its_commands_and_the_px_language() {
let manifest = manifest();
let commands: Vec<&str> = manifest["contributes"]["commands"]
.as_array()
.expect("commands")
.iter()
.map(|c| c["command"].as_str().expect("a command id"))
.collect();
assert_eq!(
commands,
vec!["praxis.runFile", "praxis.checkFile", "praxis.restartServer"],
"the extension contributes exactly the commands the CLI can serve"
);
let languages = manifest["contributes"]["languages"]
.as_array()
.expect("languages");
let praxis = languages
.iter()
.find(|l| l["id"] == "praxis")
.expect("the `praxis` language");
let extensions: Vec<&str> = praxis["extensions"]
.as_array()
.expect("extensions")
.iter()
.map(|e| e.as_str().expect("a string"))
.collect();
assert_eq!(extensions, vec![".px"]);
let grammars = manifest["contributes"]["grammars"]
.as_array()
.expect("grammars");
assert_eq!(grammars[0]["scopeName"], "source.praxis");
assert_eq!(
grammars[0]["path"], "./syntaxes/praxis.tmLanguage.json",
"the grammar the gates above read is the one the extension ships"
);
}
#[test]
fn the_breakpoint_marker_has_a_rule_of_its_own() {
let grammar = grammar();
let pattern = rule_pattern(&grammar, "breakpoint-marker");
assert!(
pattern.contains(":bp"),
"the marker's rule must spell the marker: {pattern}"
);
let included = grammar["patterns"]
.as_array()
.expect("the grammar's top-level patterns are a list")
.iter()
.any(|p| p["include"].as_str() == Some("#breakpoint-marker"));
assert!(included, "the rule is in the repository but never included");
}