use serde::Serialize;
use std::sync::OnceLock;
use synoptic::{Highlighter, TokOpt};
const TAB_WIDTH: usize = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Lang {
Rust,
TypeScript,
JavaScript,
Python,
Go,
Java,
CSharp,
Json,
Yaml,
Toml,
Markdown,
Shell,
Hcl,
}
impl Lang {
fn synoptic_ext(self) -> &'static str {
match self {
Lang::Rust => "rs",
Lang::TypeScript => "ts",
Lang::JavaScript => "js",
Lang::Python => "py",
Lang::Go => "go",
Lang::Java => "java",
Lang::CSharp => "cs",
Lang::Json => "json",
Lang::Yaml => "yml",
Lang::Toml => "toml",
Lang::Markdown => "md",
Lang::Shell => "sh",
Lang::Hcl => "hcl",
}
}
pub fn name(self) -> &'static str {
match self {
Lang::Rust => "rust",
Lang::TypeScript => "typescript",
Lang::JavaScript => "javascript",
Lang::Python => "python",
Lang::Go => "go",
Lang::Java => "java",
Lang::CSharp => "csharp",
Lang::Json => "json",
Lang::Yaml => "yaml",
Lang::Toml => "toml",
Lang::Markdown => "markdown",
Lang::Shell => "shell",
Lang::Hcl => "hcl",
}
}
fn highlighter(self) -> Option<Highlighter> {
match self {
Lang::Hcl => Some(hcl_highlighter().clone()),
other => synoptic::from_extension(other.synoptic_ext(), TAB_WIDTH),
}
}
}
fn hcl_highlighter() -> &'static Highlighter {
static HL: OnceLock<Highlighter> = OnceLock::new();
HL.get_or_init(|| {
let mut h = Highlighter::new(TAB_WIDTH);
h.bounded("comment", r"/\*", r"\*/", false);
h.bounded("comment", "#", "$", false);
h.bounded("comment", "//", "$", false);
h.bounded_interp("string", "\"", "\"", r"\$\{", r"\}", true);
h.keyword(
"keyword",
r#"^\s*(resource|data|variable|output|module|provider|locals|terraform|backend|provisioner|connection|dynamic|content|lifecycle|moved|import|check|removed|required_providers|cloud)\s*(?:"|\{|$)"#,
);
h.keyword("keyword", r"^\s*(count|for_each|depends_on|provider|source|version|required_version)\s*=");
h.keyword("keyword", r"\b(for|in|if|else|endif|endfor)\b");
h.keyword("boolean", r"\b(true|false|null)\b");
h.keyword("type", r"(?:^|[^.\w])(string|number|bool|list|map|set|object|tuple|any|optional)\b");
h.keyword("namespace", r"\b(var|local|module|data|each|path|terraform|self)\.");
h.keyword("function", r"\b([a-z_][a-z0-9_]*)\(");
h.keyword("digit", r"\b(\d+(?:\.\d+)?)\b");
h
})
}
pub fn lang_for(path: &str) -> Option<Lang> {
let ext = path.rsplit('.').next().filter(|e| *e != path)?.to_ascii_lowercase();
Some(match ext.as_str() {
"rs" => Lang::Rust,
"ts" | "tsx" => Lang::TypeScript,
"js" | "jsx" | "mjs" | "cjs" => Lang::JavaScript,
"py" | "pyw" => Lang::Python,
"go" => Lang::Go,
"java" => Lang::Java,
"cs" | "csproj" | "fsproj" | "vbproj" | "props" | "targets" => Lang::CSharp,
"json" => Lang::Json,
"yaml" | "yml" => Lang::Yaml,
"toml" => Lang::Toml,
"md" | "markdown" => Lang::Markdown,
"sh" | "bash" | "zsh" => Lang::Shell,
"bashrc" | "bash_profile" | "bash_aliases" | "bash_logout" | "profile" | "zshrc"
| "zshenv" | "zprofile" | "zlogin" | "zlogout" => Lang::Shell,
"tf" | "tfvars" | "hcl" => Lang::Hcl,
_ => return None,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum HlKind {
Keyword,
Type,
Str,
Comment,
Number,
Func,
Punct,
Heading,
Link,
Emph,
Plain,
}
pub fn kind_for_name(name: &str) -> HlKind {
match name {
"keyword" | "boolean" => HlKind::Keyword,
"type" | "struct" | "namespace" | "attribute" => HlKind::Type,
"string" | "character" => HlKind::Str,
"comment" => HlKind::Comment,
"digit" => HlKind::Number,
"function" | "macro" => HlKind::Func,
"operator" | "reference" => HlKind::Punct,
"heading" | "table" => HlKind::Heading,
"link" | "image" => HlKind::Link,
"bold" | "italic" | "strikethrough" => HlKind::Emph,
"block" => HlKind::Str, "math" => HlKind::Number,
"quote" | "list" | "linebreak" => HlKind::Comment,
_ => HlKind::Plain,
}
}
pub struct LineHighlighter {
hl: Highlighter,
}
impl LineHighlighter {
pub fn new(lang: Lang) -> Option<LineHighlighter> {
lang.highlighter().map(|hl| LineHighlighter { hl })
}
pub fn line(&mut self, text: &str) -> Vec<(String, HlKind)> {
self.hl.run(&[text.to_string()]);
self.hl
.line(0, text)
.into_iter()
.map(|tok| match tok {
TokOpt::Some(s, name) => (s, kind_for_name(&name)),
TokOpt::None(s) => (s, HlKind::Plain),
})
.collect()
}
}
impl LineHighlighter {
pub fn spans(&mut self, text: &str) -> Vec<(usize, HlKind)> {
let expanded: Vec<HlKind> = self
.line(text)
.into_iter()
.flat_map(|(t, k)| std::iter::repeat_n(k, t.chars().count()))
.collect();
let mut out: Vec<(usize, HlKind)> = Vec::new();
let mut col = 0;
for c in text.chars() {
let kind = expanded.get(col).copied().unwrap_or(HlKind::Plain);
col += if c == '\t' { TAB_WIDTH } else { 1 };
let units = c.len_utf16();
match out.last_mut() {
Some((len, k)) if *k == kind => *len += units,
_ => out.push((units, kind)),
}
}
out
}
}
pub fn highlight_line(lang: Lang, text: &str) -> Vec<(String, HlKind)> {
match LineHighlighter::new(lang) {
Some(mut h) => h.line(text),
None => vec![(text.to_string(), HlKind::Plain)],
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_language_from_extension() {
assert_eq!(lang_for("src/main.rs"), Some(Lang::Rust));
assert_eq!(lang_for("app/Component.tsx"), Some(Lang::TypeScript));
assert_eq!(lang_for("index.mjs"), Some(Lang::JavaScript));
assert_eq!(lang_for("deploy/values.yaml"), Some(Lang::Yaml));
assert_eq!(lang_for("k8s.yml"), Some(Lang::Yaml));
assert_eq!(lang_for("Main.JAVA"), Some(Lang::Java)); assert_eq!(lang_for("Controllers/SampleController.cs"), Some(Lang::CSharp));
assert_eq!(lang_for("src/SampleApp.csproj"), Some(Lang::CSharp));
assert_eq!(lang_for("Directory.Build.props"), Some(Lang::CSharp));
assert_eq!(lang_for("build/Common.targets"), Some(Lang::CSharp));
assert_eq!(lang_for("src/Lib.fsproj"), Some(Lang::CSharp));
assert_eq!(lang_for("src/Legacy.vbproj"), Some(Lang::CSharp));
assert_eq!(lang_for("Cargo.toml"), Some(Lang::Toml));
assert_eq!(lang_for("README.md"), Some(Lang::Markdown));
assert_eq!(lang_for("docs/guide.markdown"), Some(Lang::Markdown));
assert_eq!(lang_for("scripts/release.sh"), Some(Lang::Shell));
assert_eq!(lang_for("ci/setup.bash"), Some(Lang::Shell));
assert_eq!(lang_for("tools/env.zsh"), Some(Lang::Shell));
assert_eq!(lang_for("terraform/modules/web/frontdoor/main.tf"), Some(Lang::Hcl));
assert_eq!(lang_for("env/prod.tfvars"), Some(Lang::Hcl));
assert_eq!(lang_for("packer/build.hcl"), Some(Lang::Hcl));
assert_eq!(lang_for("infra/MAIN.TF"), Some(Lang::Hcl)); }
#[test]
fn detects_shell_dotfiles() {
assert_eq!(lang_for(".bashrc"), Some(Lang::Shell));
assert_eq!(lang_for(".zshrc"), Some(Lang::Shell));
assert_eq!(lang_for("home/.bash_profile"), Some(Lang::Shell));
assert_eq!(lang_for("skel/.profile"), Some(Lang::Shell));
assert_eq!(lang_for(".gitignore"), None);
assert_eq!(lang_for(".editorconfig"), None);
assert_eq!(lang_for("bashrc"), None);
}
#[test]
fn unknown_or_extensionless_paths_are_none() {
assert_eq!(lang_for("README"), None);
assert_eq!(lang_for("notes.txt"), None);
assert_eq!(lang_for("Makefile"), None);
assert_eq!(lang_for(".gitignore"), None); }
#[test]
fn token_names_map_to_kinds() {
assert_eq!(kind_for_name("keyword"), HlKind::Keyword);
assert_eq!(kind_for_name("string"), HlKind::Str);
assert_eq!(kind_for_name("comment"), HlKind::Comment);
assert_eq!(kind_for_name("digit"), HlKind::Number);
assert_eq!(kind_for_name("function"), HlKind::Func);
assert_eq!(kind_for_name("something-new"), HlKind::Plain);
}
fn has(tokens: &[(String, HlKind)], needle: &str, kind: HlKind) -> bool {
tokens.iter().any(|(t, k)| *k == kind && t.contains(needle))
}
#[test]
fn highlights_rust_keyword_number_string_and_comment() {
let toks = highlight_line(Lang::Rust, r#"let n = 5; // note"#);
let joined: String = toks.iter().map(|(t, _)| t.as_str()).collect();
assert_eq!(joined, r#"let n = 5; // note"#);
assert!(has(&toks, "let", HlKind::Keyword), "`let` is a keyword: {toks:?}");
assert!(has(&toks, "5", HlKind::Number), "`5` is a number: {toks:?}");
assert!(has(&toks, "note", HlKind::Comment), "trailing `// note` is a comment: {toks:?}");
}
#[test]
fn highlights_python_string() {
let toks = highlight_line(Lang::Python, r#"name = "sam""#);
assert!(has(&toks, "sam", HlKind::Str), "double-quoted string: {toks:?}");
}
#[test]
fn highlights_csharp_keyword_type_and_string() {
let toks = highlight_line(Lang::CSharp, r#"public class Sample { const string S = "x"; }"#);
let joined: String = toks.iter().map(|(t, _)| t.as_str()).collect();
assert_eq!(joined, r#"public class Sample { const string S = "x"; }"#);
assert!(has(&toks, "public", HlKind::Keyword), "`public` is a keyword: {toks:?}");
assert!(has(&toks, "Sample", HlKind::Type), "PascalCase name is a type: {toks:?}");
assert!(has(&toks, "x", HlKind::Str), "double-quoted string: {toks:?}");
}
#[test]
fn highlights_toml_table_and_string() {
let toks = highlight_line(Lang::Toml, r#"[package]"#);
assert!(has(&toks, "[package]", HlKind::Heading), "table header is a heading: {toks:?}");
let toks = highlight_line(Lang::Toml, r#"name = "forgetop" # ours"#);
assert!(has(&toks, "forgetop", HlKind::Str), "quoted value: {toks:?}");
assert!(has(&toks, "ours", HlKind::Comment), "trailing `#` comment: {toks:?}");
}
#[test]
fn highlights_markdown_heading_and_code_span() {
let toks = highlight_line(Lang::Markdown, "# Title");
assert!(has(&toks, "# Title", HlKind::Heading), "`#` heading: {toks:?}");
let toks = highlight_line(Lang::Markdown, "run `cargo test` now");
assert!(has(&toks, "cargo test", HlKind::Str), "inline code reads as a string: {toks:?}");
}
#[test]
fn highlights_shell_keyword_and_comment() {
let toks = highlight_line(Lang::Shell, r#"if [ -f x ]; then echo "hi"; fi # note"#);
let joined: String = toks.iter().map(|(t, _)| t.as_str()).collect();
assert_eq!(joined, r#"if [ -f x ]; then echo "hi"; fi # note"#);
assert!(has(&toks, "if", HlKind::Keyword), "`if` is a keyword: {toks:?}");
assert!(has(&toks, "note", HlKind::Comment), "trailing `#` comment: {toks:?}");
}
fn covers(tokens: &[(String, HlKind)], line: &str) {
let joined: String = tokens.iter().map(|(t, _)| t.as_str()).collect();
assert_eq!(joined, line);
}
fn kind_of(tokens: &[(String, HlKind)], needle: &str) -> Option<HlKind> {
tokens.iter().find(|(t, _)| t == needle).map(|(_, k)| *k)
}
#[test]
fn hcl_block_header_keyword_and_label_strings() {
let line = r#"resource "azurerm_cdn_frontdoor_origin" "web" {"#;
let toks = highlight_line(Lang::Hcl, line);
covers(&toks, line);
assert_eq!(kind_of(&toks, "resource"), Some(HlKind::Keyword), "{toks:?}");
assert!(has(&toks, "azurerm_cdn_frontdoor_origin", HlKind::Str), "labels are strings: {toks:?}");
assert!(has(&toks, "web", HlKind::Str), "{toks:?}");
let toks = highlight_line(Lang::Hcl, r#" dynamic "private_link" {"#);
assert_eq!(kind_of(&toks, "dynamic"), Some(HlKind::Keyword), "{toks:?}");
let toks = highlight_line(Lang::Hcl, " lifecycle {");
assert_eq!(kind_of(&toks, "lifecycle"), Some(HlKind::Keyword), "{toks:?}");
}
#[test]
fn hcl_block_type_only_at_line_start_but_reference_prefix_anywhere() {
let toks = highlight_line(Lang::Hcl, r#"data "azurerm_subscription" "current" {}"#);
assert_eq!(kind_of(&toks, "data"), Some(HlKind::Keyword), "{toks:?}");
let line = " target = data.azurerm_subscription.current.id";
let toks = highlight_line(Lang::Hcl, line);
covers(&toks, line);
assert_eq!(kind_of(&toks, "data"), Some(HlKind::Type), "{toks:?}");
assert!(has(&toks, "target", HlKind::Plain), "{toks:?}");
let toks = highlight_line(Lang::Hcl, " name = var.origin_name");
assert_eq!(kind_of(&toks, "var"), Some(HlKind::Type), "{toks:?}");
let toks = highlight_line(Lang::Hcl, " key = each.key");
assert_eq!(kind_of(&toks, "each"), Some(HlKind::Type), "{toks:?}");
}
#[test]
fn hcl_reference_continuing_a_line_is_not_a_block_type() {
for line in [" data.azurerm_subscription.current.id,", " module.vpc.vpc_id,", " terraform.workspace"] {
let toks = highlight_line(Lang::Hcl, line);
covers(&toks, line);
let first = toks.iter().find(|(t, _)| !t.trim().is_empty()).unwrap();
assert_eq!(first.1, HlKind::Type, "{line:?}: reference prefix, not block type: {toks:?}");
}
let toks = highlight_line(Lang::Hcl, " content = jsonencode(local.x)");
assert!(!has(&toks, "content", HlKind::Keyword) && has(&toks, "content", HlKind::Plain), "{toks:?}");
assert_eq!(kind_of(&toks, "jsonencode"), Some(HlKind::Func), "{toks:?}");
for line in [r#"data "azurerm_subscription" "current" {"#, " content {", "terraform", "locals{"] {
let toks = highlight_line(Lang::Hcl, line);
let first = toks.iter().find(|(t, _)| !t.trim().is_empty()).unwrap();
assert_eq!(first.1, HlKind::Keyword, "{line:?}: {toks:?}");
}
}
#[test]
fn hcl_trailing_comment_after_a_string_holding_a_comment_marker() {
for (line, comment) in [
(r#" description = "issue #123 // not a comment" # real"#, "# real"),
(r#" url = "http://x" // c"#, "// c"),
(r#" source = "git::https://example.net/mods//vpc?ref=v1" # pinned"#, "# pinned"),
(r#" x = "a \" # b" # c"#, "# c"),
] {
let toks = highlight_line(Lang::Hcl, line);
covers(&toks, line);
assert_eq!(kind_of(&toks, comment), Some(HlKind::Comment), "{line:?}: {toks:?}");
assert!(toks.iter().all(|(t, k)| *k != HlKind::Comment || t == comment), "{line:?}: {toks:?}");
}
let toks = highlight_line(Lang::Hcl, " ttl = 600 # ten minutes");
assert_eq!(kind_of(&toks, "# ten minutes"), Some(HlKind::Comment), "{toks:?}");
let toks = highlight_line(Lang::Hcl, "// header");
assert_eq!(toks, vec![("// header".to_string(), HlKind::Comment)]);
}
#[test]
fn hcl_type_name_after_a_dot_is_an_attribute() {
let toks = highlight_line(Lang::Hcl, " n = var.number + local.list[0]");
assert!(!has(&toks, "number", HlKind::Type) && has(&toks, "number", HlKind::Plain), "{toks:?}");
assert!(!has(&toks, "list", HlKind::Type) && has(&toks, "list", HlKind::Plain), "{toks:?}");
assert_eq!(kind_of(&toks, "var"), Some(HlKind::Type), "{toks:?}");
let toks = highlight_line(Lang::Hcl, " type = number");
assert_eq!(kind_of(&toks, "number"), Some(HlKind::Type), "{toks:?}");
}
#[test]
fn hcl_meta_arguments_only_when_assigned() {
let toks = highlight_line(Lang::Hcl, " for_each = var.origins");
assert_eq!(kind_of(&toks, "for_each"), Some(HlKind::Keyword), "{toks:?}");
let toks = highlight_line(Lang::Hcl, " count = length(var.zones)");
assert_eq!(kind_of(&toks, "count"), Some(HlKind::Keyword), "{toks:?}");
assert_eq!(kind_of(&toks, "length"), Some(HlKind::Func), "{toks:?}");
let toks = highlight_line(Lang::Hcl, " total = local.count + 1");
assert!(!has(&toks, "count", HlKind::Keyword), "{toks:?}");
assert_eq!(kind_of(&toks, "1"), Some(HlKind::Number), "{toks:?}");
}
#[test]
fn hcl_string_with_interpolation_comments_and_literals() {
let line = r#" description = "Approved by ${var.actor} for Front Door" # INF-1067"#;
let toks = highlight_line(Lang::Hcl, line);
covers(&toks, line);
assert!(has(&toks, "Approved by ", HlKind::Str), "{toks:?}");
assert!(has(&toks, "for Front Door", HlKind::Str), "{toks:?}");
assert_eq!(kind_of(&toks, "var"), Some(HlKind::Type), "interpolation is tokenised: {toks:?}");
assert!(has(&toks, "INF-1067", HlKind::Comment), "{toks:?}");
let toks = highlight_line(Lang::Hcl, " enabled = true // always");
assert_eq!(kind_of(&toks, "true"), Some(HlKind::Keyword), "{toks:?}");
assert!(has(&toks, "always", HlKind::Comment), "{toks:?}");
let toks = highlight_line(Lang::Hcl, " /* legacy */ ttl = 600");
assert!(has(&toks, "legacy", HlKind::Comment), "{toks:?}");
assert_eq!(kind_of(&toks, "600"), Some(HlKind::Number), "{toks:?}");
let toks = highlight_line(Lang::Hcl, r#" note = "resource for data""#);
assert!(!has(&toks, "resource", HlKind::Keyword), "{toks:?}");
assert!(!has(&toks, "for", HlKind::Keyword), "{toks:?}");
}
#[test]
fn hcl_types_and_for_expressions() {
let line = " type = list(object({ name = string, ttl = optional(number, 60) }))";
let toks = highlight_line(Lang::Hcl, line);
covers(&toks, line);
assert_eq!(kind_of(&toks, "list"), Some(HlKind::Type), "type wins over function: {toks:?}");
assert_eq!(kind_of(&toks, "string"), Some(HlKind::Type), "{toks:?}");
assert_eq!(kind_of(&toks, "optional"), Some(HlKind::Type), "{toks:?}");
let line = " ids = [for o in var.origins : o.id if o.enabled]";
let toks = highlight_line(Lang::Hcl, line);
covers(&toks, line);
assert_eq!(kind_of(&toks, "for"), Some(HlKind::Keyword), "{toks:?}");
assert_eq!(kind_of(&toks, "in"), Some(HlKind::Keyword), "{toks:?}");
assert_eq!(kind_of(&toks, "if"), Some(HlKind::Keyword), "{toks:?}");
}
fn spans_covering(lang: Lang, line: &str) -> Vec<(usize, HlKind)> {
let spans = LineHighlighter::new(lang).unwrap().spans(line);
let total: usize = spans.iter().map(|(n, _)| n).sum();
assert_eq!(total, line.encode_utf16().count(), "spans cover the line: {spans:?}");
spans
}
#[test]
fn spans_cover_the_line_in_utf16_units_and_merge_neighbours() {
let spans = spans_covering(Lang::Hcl, r#"resource "aws_s3_bucket" "logs" {"#);
assert_eq!(spans[0], (8, HlKind::Keyword), "{spans:?}");
assert_eq!(spans[1], (1, HlKind::Plain), "{spans:?}");
assert_eq!(spans[2], (15, HlKind::Str), "quoted label incl. quotes: {spans:?}");
for w in spans.windows(2) {
assert_ne!(w[0].1, w[1].1, "merged: {spans:?}");
}
}
#[test]
fn spans_count_a_tab_as_one_unit_and_an_astral_char_as_two() {
let line = "\tttl = 600 # 🚀 lift-off";
let spans = spans_covering(Lang::Hcl, line);
assert_eq!(spans[0], (7, HlKind::Plain), "the leading tab is one unit: {spans:?}");
let (n, k) = *spans.last().unwrap();
assert_eq!(k, HlKind::Comment, "{spans:?}");
assert_eq!(n, 13, "{spans:?}");
assert!(spans.contains(&(3, HlKind::Number)), "{spans:?}");
}
#[test]
fn spans_of_an_empty_line_are_empty() {
assert!(LineHighlighter::new(Lang::Hcl).unwrap().spans("").is_empty());
}
#[test]
fn lang_names_are_lowercase_and_distinct() {
let names: Vec<&str> = [Lang::Rust, Lang::TypeScript, Lang::CSharp, Lang::Hcl].iter().map(|l| l.name()).collect();
assert_eq!(names, ["rust", "typescript", "csharp", "hcl"]);
}
#[test]
fn hl_kind_serialises_as_lowercase_name() {
assert_eq!(serde_json::to_string(&HlKind::Keyword).unwrap(), "\"keyword\"");
assert_eq!(serde_json::to_string(&HlKind::Str).unwrap(), "\"str\"");
assert_eq!(serde_json::to_string(&HlKind::Plain).unwrap(), "\"plain\"");
}
#[test]
fn unknown_language_still_covers_the_line_as_plain() {
let toks = highlight_line(Lang::Json, r#"{"k": 1}"#);
let joined: String = toks.iter().map(|(t, _)| t.as_str()).collect();
assert_eq!(joined, r#"{"k": 1}"#);
}
}