use std::fs;
use std::path::Path;
use std::process;
use harn_vm::stdlib::template::vocabulary::{
block_keywords, filter_names, CLAUSE_KEYWORDS, LITERAL_KEYWORDS, OPERATOR_KEYWORDS, SECTIONS,
};
use serde_json::{json, Value};
pub(crate) fn run(output_path: &str, check_only: bool) {
let generated = generate_file();
let path = Path::new(output_path);
if check_only {
let existing = match fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
eprintln!("error: cannot read {}: {e}", path.display());
eprintln!("hint: run `make gen-prompt-grammar` to regenerate.");
process::exit(1);
}
};
if normalize_line_endings(&existing) != normalize_line_endings(&generated) {
eprintln!(
"error: {} is stale relative to the prompt-template engine.",
path.display()
);
eprintln!("hint: run `make gen-prompt-grammar` to regenerate.");
process::exit(1);
}
return;
}
if let Some(parent) = path.parent() {
if let Err(e) = fs::create_dir_all(parent) {
eprintln!("error: cannot create {}: {e}", parent.display());
process::exit(1);
}
}
if let Err(e) = fs::write(path, &generated) {
eprintln!("error: cannot write {}: {e}", path.display());
process::exit(1);
}
println!("wrote {}", path.display());
}
fn alternation(words: &[&str]) -> String {
let mut sorted: Vec<&str> = words.to_vec();
sorted.sort_by(|a, b| b.len().cmp(&a.len()).then(a.cmp(b)));
sorted.join("|")
}
fn grammar() -> Value {
let block_and_clause: Vec<&str> = block_keywords()
.into_iter()
.chain(CLAUSE_KEYWORDS.iter().copied())
.collect();
json!({
"$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
"name": "Harn Prompt Template",
"scopeName": "text.harn.prompt",
"patterns": [
{ "include": "#comment" },
{ "include": "#raw-block" },
{ "include": "#directive" }
],
"repository": {
"comment": {
"name": "comment.block.harn.prompt",
"begin": "\\{\\{#",
"end": "#\\}\\}",
"patterns": []
},
"raw-block": {
"name": "meta.raw.harn.prompt",
"begin": "(\\{\\{-?)\\s*(raw)\\s*(-?\\}\\})",
"beginCaptures": {
"1": { "name": "punctuation.section.embedded.begin.harn.prompt" },
"2": { "name": "keyword.control.raw.harn.prompt" },
"3": { "name": "punctuation.section.embedded.end.harn.prompt" }
},
"end": "(\\{\\{-?)\\s*(endraw)\\s*(-?\\}\\})",
"endCaptures": {
"1": { "name": "punctuation.section.embedded.begin.harn.prompt" },
"2": { "name": "keyword.control.endraw.harn.prompt" },
"3": { "name": "punctuation.section.embedded.end.harn.prompt" }
},
"contentName": "string.unquoted.raw.harn.prompt"
},
"directive": {
"name": "meta.directive.harn.prompt",
"begin": "\\{\\{-?",
"beginCaptures": {
"0": { "name": "punctuation.section.embedded.begin.harn.prompt" }
},
"end": "-?\\}\\}",
"endCaptures": {
"0": { "name": "punctuation.section.embedded.end.harn.prompt" }
},
"patterns": [
{ "include": "#section-name" },
{ "include": "#filter" },
{ "include": "#keyword" },
{ "include": "#literal-keyword" },
{ "include": "#literal" },
{ "include": "#operator" },
{ "include": "#identifier" }
]
},
"section-name": {
"match": format!(
"(\\bsection\\b)(\\s*)(\"(?:{sections})\"|'(?:{sections})')",
sections = alternation(SECTIONS)
),
"captures": {
"1": { "name": "keyword.control.harn.prompt" },
"3": { "name": "support.constant.section.harn.prompt" }
}
},
"filter": {
"match": format!("(\\|)(\\s*)({filters})\\b", filters = alternation(&filter_names())),
"captures": {
"1": { "name": "keyword.operator.filter.harn.prompt" },
"3": { "name": "entity.name.function.filter.harn.prompt" }
}
},
"keyword": {
"name": "keyword.control.harn.prompt",
"match": format!("\\b({keywords})\\b", keywords = alternation(&block_and_clause))
},
"literal-keyword": {
"name": "constant.language.harn.prompt",
"match": format!("\\b({literals})\\b", literals = alternation(LITERAL_KEYWORDS))
},
"literal": {
"patterns": [
{
"name": "string.quoted.double.harn.prompt",
"begin": "\"",
"end": "\"",
"patterns": [
{ "name": "constant.character.escape.harn.prompt", "match": "\\\\." }
]
},
{
"name": "string.quoted.single.harn.prompt",
"begin": "'",
"end": "'",
"patterns": [
{ "name": "constant.character.escape.harn.prompt", "match": "\\\\." }
]
},
{
"name": "constant.numeric.harn.prompt",
"match": "\\b-?[0-9]+(\\.[0-9]+)?\\b"
}
]
},
"operator": {
"name": "keyword.operator.harn.prompt",
"match": format!(
"==|!=|<=|>=|=|<|>|&&|\\|\\||!|\\.|\\b({words})\\b",
words = alternation(OPERATOR_KEYWORDS)
)
},
"identifier": {
"name": "variable.other.harn.prompt",
"match": "\\b[a-zA-Z_][a-zA-Z0-9_]*\\b"
}
}
})
}
fn generate_file() -> String {
let mut value = grammar();
let map = value
.as_object_mut()
.expect("grammar root is a JSON object by construction");
map.insert(
"_generated".to_string(),
json!(
"GENERATED by `harn dump-prompt-grammar` — do not edit by hand. \
Source of truth: crates/harn-vm/src/stdlib/template/vocabulary.rs. \
Regenerate with: make gen-prompt-grammar"
),
);
let mut out = serde_json::to_string_pretty(&value)
.expect("grammar serializes: it contains only strings, arrays, and maps");
out.push('\n');
out
}
fn normalize_line_endings(text: &str) -> String {
text.replace("\r\n", "\n").replace('\r', "\n")
}
#[cfg(test)]
mod tests {
use super::*;
fn repository() -> Value {
grammar()
.get("repository")
.cloned()
.expect("grammar has a repository")
}
fn pattern_match(name: &str) -> String {
repository()
.get(name)
.and_then(|p| p.get("match"))
.and_then(Value::as_str)
.unwrap_or_else(|| panic!("`{name}` has a match regex"))
.to_string()
}
#[test]
fn keyword_alternation_covers_the_engine_vocabulary() {
let keywords = pattern_match("keyword");
for word in block_keywords().iter().chain(CLAUSE_KEYWORDS) {
assert!(
keywords.contains(word),
"keyword `{word}` missing from the generated grammar"
);
}
}
#[test]
fn filter_alternation_is_exactly_the_engine_filter_set() {
let filters = pattern_match("filter");
for name in filter_names() {
assert!(
filters.contains(name),
"filter `{name}` missing from the generated grammar"
);
}
assert!(
!filters.contains("uppercase"),
"grammar highlights filters the engine does not implement"
);
}
#[test]
fn filter_pattern_accepts_any_spacing_after_the_pipe() {
let filters = pattern_match("filter");
assert!(
!filters.contains("(?<="),
"filter pattern still uses a fixed-width lookbehind"
);
assert!(
filters.starts_with("(\\|)(\\s*)"),
"filter pattern should capture the pipe and any following whitespace, got: {filters}"
);
}
#[test]
fn section_names_come_from_the_engine() {
let sections = pattern_match("section-name");
for name in SECTIONS {
assert!(
sections.contains(name),
"section `{name}` missing from the generated grammar"
);
}
}
#[test]
fn alternation_lists_longer_words_first() {
let alt = alternation(&["end", "endsection", "endraw"]);
assert_eq!(alt, "endsection|endraw|end");
}
#[test]
fn generated_file_is_valid_json_with_a_banner() {
let out = generate_file();
let parsed: Value = serde_json::from_str(&out).expect("generated grammar parses as JSON");
assert_eq!(
parsed.get("scopeName").and_then(Value::as_str),
Some("text.harn.prompt")
);
assert!(parsed
.get("_generated")
.and_then(Value::as_str)
.expect("banner")
.contains("make gen-prompt-grammar"));
assert!(out.ends_with('\n'));
}
#[test]
fn committed_grammar_matches_generator() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let path = Path::new(manifest_dir)
.join("..")
.join("..")
.join("editors")
.join("vscode")
.join("syntaxes")
.join("harn-prompt.tmLanguage.json");
let on_disk = fs::read_to_string(&path).unwrap_or_else(|e| {
panic!(
"failed to read {}: {e}\n\
hint: run `make gen-prompt-grammar` to regenerate.",
path.display()
)
});
assert_eq!(
normalize_line_endings(&on_disk),
normalize_line_endings(&generate_file()),
"editors/vscode/syntaxes/harn-prompt.tmLanguage.json is stale relative to \
the prompt-template engine.\nRun `make gen-prompt-grammar` to regenerate."
);
}
}