use std::{
collections::HashMap,
path::Path,
sync::{Arc, OnceLock},
};
use crate::engine::regex::{AnchorContext, FallbackMatcher};
#[cfg(feature = "bundled-themes")]
use crate::theme::BuiltinTextMateTheme;
use crate::{
Error, Result,
grammars::bundle::{Bundle, LanguageEntry},
};
#[derive(Debug, Clone)]
pub struct Catalog(Arc<CatalogInner>);
#[derive(Debug)]
struct CatalogInner {
bundle: Arc<Bundle>,
bytes: usize,
version: String,
canonical: HashMap<String, usize>,
basenames: HashMap<String, usize>,
extensions: HashMap<String, usize>,
first_lines: Vec<OnceLock<FallbackMatcher>>,
}
#[derive(Debug, Clone, Copy)]
pub struct LanguageInfo<'a> {
entry: &'a LanguageEntry,
}
impl<'a> LanguageInfo<'a> {
pub fn id(self) -> &'a str {
&self.entry.canonical
}
pub fn aliases(self) -> &'a [String] {
&self.entry.aliases
}
pub fn extensions(self) -> &'a [String] {
&self.entry.extensions
}
pub fn basenames(self) -> &'a [String] {
&self.entry.basenames
}
pub fn root_scope(self) -> &'a str {
&self.entry.scope_name
}
}
impl Catalog {
#[cfg(feature = "bundled-grammars")]
pub fn bundled() -> Self {
static CATALOG: OnceLock<Catalog> = OnceLock::new();
CATALOG
.get_or_init(|| {
Self::from_bundle(
Arc::clone(crate::grammars::embedded_bundle_shared()),
crate::grammars::embedded_bundle_bytes().len(),
)
})
.clone()
}
pub fn from_static(bytes: &'static [u8]) -> Result<Self> {
Self::parse(bytes, Bundle::parse_static)
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
Self::parse(bytes, Bundle::parse)
}
fn parse<'a>(
bytes: &'a [u8],
parse: impl FnOnce(
&'a [u8],
) -> std::result::Result<Bundle, crate::grammars::bundle::BundleError>,
) -> Result<Self> {
if bytes.len() > 64 * 1024 * 1024 {
return Err(Error::Bundle(crate::BundleError::new(
crate::BundleErrorKind::TooLarge,
None,
"bundle exceeds 64 MiB".to_owned(),
)));
}
let bundle = parse(bytes).map_err(|error| {
Error::Bundle(crate::BundleError::new(
crate::BundleErrorKind::Invalid,
None,
format!("invalid Syntaxmate bundle: {error:?}"),
))
})?;
bundle.validate().map_err(|error| {
Error::Bundle(crate::BundleError::new(
crate::BundleErrorKind::Invalid,
None,
format!("invalid Syntaxmate bundle: {error:?}"),
))
})?;
Ok(Self::from_bundle(Arc::new(bundle), bytes.len()))
}
fn from_bundle(bundle: Arc<Bundle>, bytes: usize) -> Self {
let mut canonical = HashMap::new();
let mut basenames = HashMap::new();
let mut extensions = HashMap::new();
for (index, language) in bundle.languages.iter().enumerate() {
canonical.insert(language.canonical.clone(), index);
}
for (index, language) in bundle.languages.iter().enumerate() {
for alias in &language.aliases {
canonical.entry(alias.clone()).or_insert(index);
}
for basename in &language.basenames {
basenames.insert(basename.to_ascii_lowercase(), index);
if basename.contains('.') {
extensions.insert(basename.trim_start_matches('.').to_ascii_lowercase(), index);
}
}
for extension in &language.extensions {
extensions.insert(
extension.trim_start_matches('.').to_ascii_lowercase(),
index,
);
}
}
let first_lines = bundle.languages.iter().map(|_| OnceLock::new()).collect();
Self(Arc::new(CatalogInner {
version: bundle.version_stamp(),
bundle,
bytes,
canonical,
basenames,
extensions,
first_lines,
}))
}
pub(crate) fn bundle(&self) -> &Arc<Bundle> {
&self.0.bundle
}
pub fn languages(&self) -> Vec<&str> {
self.0
.bundle
.languages
.iter()
.map(|entry| entry.canonical.as_str())
.collect()
}
pub fn language(&self, language: &str) -> Option<LanguageInfo<'_>> {
let token = language.trim().trim_start_matches('.');
let index = if token.bytes().any(|byte| byte.is_ascii_uppercase()) {
self.0.canonical.get(&token.to_ascii_lowercase())
} else {
self.0.canonical.get(token)
}?;
Some(LanguageInfo {
entry: &self.0.bundle.languages[*index],
})
}
pub fn canonical_language(&self, language: &str) -> Option<&str> {
self.language(language).map(LanguageInfo::id)
}
pub fn language_for_scope(&self, scope: &str) -> Option<&str> {
self.0
.bundle
.languages
.iter()
.find(|entry| entry.scope_name == scope)
.map(|entry| entry.canonical.as_str())
}
pub fn detect_path(&self, path: impl AsRef<Path>) -> Option<&str> {
let name = path.as_ref().file_name()?.to_str()?.to_ascii_lowercase();
let index = self.0.basenames.get(&name).copied().or_else(|| {
name.match_indices('.')
.find_map(|(dot, _)| self.0.extensions.get(&name[dot + 1..]).copied())
})?;
Some(&self.0.bundle.languages[index].canonical)
}
pub fn detect(&self, path: Option<&Path>, source: &str) -> Option<&str> {
if let Some(language) = path.and_then(|path| self.detect_path(path)) {
return Some(language);
}
let mut end = source.len().min(4096);
while !source.is_char_boundary(end) {
end -= 1;
}
let line = source[..end].split(['\n', '\r']).next().unwrap_or_default();
if let Some((_, rest)) = line.split_once("-*-")
&& let Some((mode, _)) = rest.split_once("-*-")
{
let mode = mode
.trim()
.strip_prefix("mode:")
.unwrap_or(mode)
.split(';')
.next()?
.trim();
if let Some(language) = self.canonical_language(mode) {
return Some(language);
}
}
if line.contains("vim:") || line.contains("vi:") || line.contains("ex:") {
for word in line.split_whitespace() {
if let Some(mode) = word
.strip_prefix("ft=")
.or_else(|| word.strip_prefix("filetype="))
&& let Some(language) = self.canonical_language(mode.trim_end_matches(':'))
{
return Some(language);
}
}
}
for (index, entry) in self
.0
.bundle
.languages
.iter()
.enumerate()
.filter(|(_, entry)| {
entry
.first_line_pattern
.as_ref()
.is_some_and(|pattern| pattern.len() <= 4096)
})
.take(100)
{
let matcher = self.0.first_lines[index].get_or_init(|| {
FallbackMatcher::with_budget(entry.first_line_pattern.as_deref().unwrap(), 10_000)
});
if matcher
.try_find(line, 0, AnchorContext::start_of_file())
.ok()
.is_some_and(|report| report.result.is_some())
{
return Some(&entry.canonical);
}
}
let mut words = line.strip_prefix("#!")?.split_whitespace();
let mut interpreter = words.next()?.rsplit('/').next()?;
if interpreter == "env" {
interpreter = words
.find(|word| !word.starts_with('-') && !word.contains('='))?
.rsplit('/')
.next()?;
}
let interpreter = interpreter.trim_end_matches(|ch: char| ch.is_ascii_digit() || ch == '.');
let interpreter = match interpreter {
"node" | "nodejs" => "javascript",
other => other,
};
self.canonical_language(interpreter)
}
#[cfg(feature = "bundled-themes")]
pub fn themes(&self) -> Vec<&'static str> {
BuiltinTextMateTheme::all()
.iter()
.map(|theme| theme.name())
.collect()
}
pub fn bundle_version(&self) -> &str {
&self.0.version
}
pub fn bundle_summary(&self) -> CatalogSummary {
let summary = crate::grammars::BundleSummary::from_bundle(&self.0.bundle);
CatalogSummary {
version: summary.version,
bundle_bytes: self.0.bytes,
source_hash: summary.source_hash,
grammar_count: summary.grammar_count,
language_count: summary.language_count,
scope_count: summary.scope_count,
license_count: summary.license_count,
source_revision: summary.source_revision,
}
}
pub fn licenses(&self) -> Vec<AssetLicense> {
self.0
.bundle
.licenses
.iter()
.map(|license| AssetLicense {
language: license.language.clone(),
source_path: license.source_path.clone(),
upstream_url: license.upstream_url.clone(),
spdx_id: license.spdx_id.clone(),
license_text: license.license_text.clone(),
source_revision: license.source_revision.clone(),
})
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CatalogSummary {
pub version: String,
pub bundle_bytes: usize,
pub source_hash: u64,
pub grammar_count: usize,
pub language_count: usize,
pub scope_count: usize,
pub license_count: usize,
pub source_revision: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AssetLicense {
pub language: String,
pub source_path: String,
pub upstream_url: String,
pub spdx_id: String,
pub license_text: String,
pub source_revision: String,
}
#[cfg(all(test, feature = "bundled-grammars"))]
mod tests {
use super::*;
#[test]
fn detection_precedence_and_bounded_first_lines() {
let catalog = Catalog::bundled();
assert_eq!(
catalog.detect(Some(Path::new("script.rs")), "#!/usr/bin/env python3"),
Some("rust")
);
assert_eq!(
catalog.detect(None, "#!/usr/bin/env python3"),
Some("python")
);
assert_eq!(catalog.detect(None, "#!/bin/bash"), Some("shellscript"));
assert_eq!(catalog.detect(None, "// -*- mode: rust; -*-"), Some("rust"));
assert_eq!(
catalog.detect(None, "// vim: set filetype=rust:"),
Some("rust")
);
assert_eq!(catalog.detect(None, "\n#!/bin/bash"), None);
assert_eq!(
catalog.detect(None, &format!("{}#!/bin/bash", "é".repeat(4096))),
None
);
assert_eq!(catalog.detect(None, "<script>\0\\"), None);
assert_eq!(catalog.detect_path("a.blade.php"), Some("blade"));
assert_eq!(catalog.detect_path("Dockerfile"), Some("docker"));
}
#[test]
fn first_line_match_uses_engine_regex_syntax() {
let mut bundle = crate::grammars::embedded_bundle().clone();
bundle.languages.truncate(1);
bundle.languages[0].first_line_pattern = Some(r"(?<=^#!)custom([0-9])\1$".into());
let catalog = Catalog::from_bundle(Arc::new(bundle), 0);
assert_eq!(
catalog.detect(None, "#!custom33"),
Some(catalog.languages()[0])
);
assert_eq!(catalog.detect(None, "#!custom34"), None);
}
#[test]
fn pathological_first_line_patterns_are_bounded() {
let mut bundle = crate::grammars::embedded_bundle().clone();
bundle.languages.truncate(1);
bundle.languages[0].first_line_pattern = Some("^(a+)+$".into());
let catalog = Catalog::from_bundle(Arc::new(bundle), 0);
assert_eq!(
catalog.detect(None, &format!("{}!", "a".repeat(4000))),
None
);
}
}