mirror of
https://github.com/sharkdp/bat
synced 2026-07-20 16:33:20 +00:00
f49641a6c3
When a symlink name has no recognizable extension (e.g. Aliases/0install), syntax detection fails because the symlink path doesn't match any syntax. The target file may have a recognizable extension (e.g. Formula/zero-install.rb) but was never consulted. When path-based syntax detection fails with UndetectedSyntax, canonicalize the path to resolve symlinks and retry detection with the target path. This preserves existing behavior where the symlink path itself matches (e.g. .ssh/config), since the original path is tried first. Closes #1001
818 lines
28 KiB
Rust
818 lines
28 KiB
Rust
use std::ffi::OsStr;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
|
|
use once_cell::unsync::OnceCell;
|
|
|
|
use syntect::highlighting::Theme;
|
|
use syntect::parsing::{SyntaxReference, SyntaxSet};
|
|
|
|
use path_abs::PathAbs;
|
|
|
|
use crate::error::*;
|
|
use crate::input::{InputReader, OpenedInput};
|
|
use crate::syntax_mapping::ignored_suffixes::IgnoredSuffixes;
|
|
use crate::syntax_mapping::MappingTarget;
|
|
use crate::theme::{default_theme, ColorScheme};
|
|
use crate::{bat_warning, SyntaxMapping};
|
|
|
|
use lazy_theme_set::LazyThemeSet;
|
|
|
|
use serialized_syntax_set::*;
|
|
|
|
#[cfg(feature = "build-assets")]
|
|
pub use crate::assets::build_assets::*;
|
|
|
|
pub(crate) mod assets_metadata;
|
|
#[cfg(feature = "build-assets")]
|
|
mod build_assets;
|
|
mod lazy_theme_set;
|
|
mod serialized_syntax_set;
|
|
|
|
#[derive(Debug)]
|
|
pub struct HighlightingAssets {
|
|
syntax_set_cell: OnceCell<SyntaxSet>,
|
|
serialized_syntax_set: SerializedSyntaxSet,
|
|
|
|
theme_set: LazyThemeSet,
|
|
fallback_theme: Option<&'static str>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct SyntaxReferenceInSet<'a> {
|
|
pub syntax: &'a SyntaxReference,
|
|
pub syntax_set: &'a SyntaxSet,
|
|
}
|
|
|
|
/// Lazy-loaded syntaxes are already compressed, and we don't want to compress
|
|
/// already compressed data.
|
|
pub(crate) const COMPRESS_SYNTAXES: bool = false;
|
|
|
|
/// We don't want to compress our [LazyThemeSet] since the lazy-loaded themes
|
|
/// within it are already compressed, and compressing another time just makes
|
|
/// performance suffer
|
|
pub(crate) const COMPRESS_THEMES: bool = false;
|
|
|
|
/// Compress for size of ~40 kB instead of ~200 kB without much difference in
|
|
/// performance due to lazy-loading
|
|
pub(crate) const COMPRESS_LAZY_THEMES: bool = true;
|
|
|
|
/// Compress for size of ~10 kB instead of ~120 kB
|
|
pub(crate) const COMPRESS_ACKNOWLEDGEMENTS: bool = true;
|
|
|
|
impl HighlightingAssets {
|
|
fn new(serialized_syntax_set: SerializedSyntaxSet, theme_set: LazyThemeSet) -> Self {
|
|
HighlightingAssets {
|
|
syntax_set_cell: OnceCell::new(),
|
|
serialized_syntax_set,
|
|
theme_set,
|
|
fallback_theme: None,
|
|
}
|
|
}
|
|
|
|
pub fn from_cache(cache_path: &Path) -> Result<Self> {
|
|
Ok(HighlightingAssets::new(
|
|
SerializedSyntaxSet::FromFile(cache_path.join("syntaxes.bin")),
|
|
asset_from_cache(&cache_path.join("themes.bin"), "theme set", COMPRESS_THEMES)?,
|
|
))
|
|
}
|
|
|
|
pub fn from_binary() -> Self {
|
|
HighlightingAssets::new(
|
|
SerializedSyntaxSet::FromBinary(get_serialized_integrated_syntaxset()),
|
|
get_integrated_themeset(),
|
|
)
|
|
}
|
|
|
|
pub fn set_fallback_theme(&mut self, theme: &'static str) {
|
|
self.fallback_theme = Some(theme);
|
|
}
|
|
|
|
/// Return the collection of syntect syntax definitions.
|
|
pub fn get_syntax_set(&self) -> Result<&SyntaxSet> {
|
|
self.syntax_set_cell
|
|
.get_or_try_init(|| self.serialized_syntax_set.deserialize())
|
|
}
|
|
|
|
/// Use [Self::get_syntaxes] instead
|
|
#[deprecated]
|
|
pub fn syntaxes(&self) -> &[SyntaxReference] {
|
|
self.get_syntax_set()
|
|
.expect(".syntaxes() is deprecated, use .get_syntaxes() instead")
|
|
.syntaxes()
|
|
}
|
|
|
|
pub fn get_syntaxes(&self) -> Result<&[SyntaxReference]> {
|
|
Ok(self.get_syntax_set()?.syntaxes())
|
|
}
|
|
|
|
fn get_theme_set(&self) -> &LazyThemeSet {
|
|
&self.theme_set
|
|
}
|
|
|
|
pub fn themes(&self) -> impl Iterator<Item = &str> {
|
|
self.get_theme_set().themes()
|
|
}
|
|
|
|
/// Use [Self::get_syntax_for_path] instead
|
|
#[deprecated]
|
|
pub fn syntax_for_file_name(
|
|
&self,
|
|
file_name: impl AsRef<Path>,
|
|
mapping: &SyntaxMapping,
|
|
) -> Option<&SyntaxReference> {
|
|
self.get_syntax_for_path(file_name, mapping)
|
|
.ok()
|
|
.map(|syntax_in_set| syntax_in_set.syntax)
|
|
}
|
|
|
|
/// Detect the syntax based on, in order:
|
|
/// 1. Syntax mappings with [MappingTarget::MapTo] and [MappingTarget::MapToUnknown]
|
|
/// (e.g. `/etc/profile` -> `Bourne Again Shell (bash)`)
|
|
/// 2. The file name (e.g. `Dockerfile`)
|
|
/// 3. Syntax mappings with [MappingTarget::MapExtensionToUnknown]
|
|
/// (e.g. `*.conf`)
|
|
/// 4. The file name extension (e.g. `.rs`)
|
|
///
|
|
/// When detecting syntax based on syntax mappings, the full path is taken
|
|
/// into account. When detecting syntax based on file name, no regard is
|
|
/// taken to the path of the file. Only the file name itself matters. When
|
|
/// detecting syntax based on file name extension, only the file name
|
|
/// extension itself matters.
|
|
///
|
|
/// Returns [Error::UndetectedSyntax] if it was not possible detect syntax
|
|
/// based on path/file name/extension (or if the path was mapped to
|
|
/// [MappingTarget::MapToUnknown] or [MappingTarget::MapExtensionToUnknown]).
|
|
/// In this case it is appropriate to fall back to other methods to detect
|
|
/// syntax. Such as using the contents of the first line of the file.
|
|
///
|
|
/// Returns [Error::UnknownSyntax] if a syntax mapping exist, but the mapped
|
|
/// syntax does not exist.
|
|
pub fn get_syntax_for_path(
|
|
&self,
|
|
path: impl AsRef<Path>,
|
|
mapping: &SyntaxMapping,
|
|
) -> Result<SyntaxReferenceInSet<'_>> {
|
|
let path = path.as_ref();
|
|
|
|
let syntax_match = mapping.get_syntax_for(path);
|
|
|
|
if let Some(MappingTarget::MapToUnknown) = syntax_match {
|
|
return Err(Error::UndetectedSyntax(path.to_string_lossy().into()));
|
|
}
|
|
|
|
if let Some(MappingTarget::MapTo(syntax_name)) = syntax_match {
|
|
return self
|
|
.find_syntax_by_token(syntax_name)?
|
|
.ok_or_else(|| Error::UnknownSyntax(syntax_name.to_owned()));
|
|
}
|
|
|
|
let file_name = path.file_name().unwrap_or_default();
|
|
|
|
match (
|
|
self.get_syntax_for_file_name(file_name, &mapping.ignored_suffixes)?,
|
|
syntax_match,
|
|
) {
|
|
(Some(syntax), _) => Ok(syntax),
|
|
|
|
(_, Some(MappingTarget::MapExtensionToUnknown)) => {
|
|
Err(Error::UndetectedSyntax(path.to_string_lossy().into()))
|
|
}
|
|
|
|
_ => self
|
|
.get_syntax_for_file_extension(file_name, &mapping.ignored_suffixes)?
|
|
.ok_or_else(|| Error::UndetectedSyntax(path.to_string_lossy().into())),
|
|
}
|
|
}
|
|
|
|
/// Look up a syntect theme by name.
|
|
pub fn get_theme(&self, theme: &str) -> &Theme {
|
|
match self.get_theme_set().get(theme) {
|
|
Some(theme) => theme,
|
|
None => {
|
|
if theme == "ansi-light" || theme == "ansi-dark" {
|
|
bat_warning!("Theme '{theme}' is deprecated, using 'ansi' instead.");
|
|
return self.get_theme("ansi");
|
|
}
|
|
if !theme.is_empty() {
|
|
bat_warning!("Unknown theme '{theme}', using default.")
|
|
}
|
|
self.get_theme_set()
|
|
.get(
|
|
self.fallback_theme
|
|
.unwrap_or_else(|| default_theme(ColorScheme::Dark)),
|
|
)
|
|
.expect("something is very wrong if the default theme is missing")
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn get_syntax(
|
|
&self,
|
|
language: Option<&str>,
|
|
fallback_syntax: Option<&str>,
|
|
input: &mut OpenedInput,
|
|
mapping: &SyntaxMapping,
|
|
) -> Result<SyntaxReferenceInSet<'_>> {
|
|
if let Some(language) = language {
|
|
let syntax_set = self.get_syntax_set()?;
|
|
return syntax_set
|
|
.find_syntax_by_token(language)
|
|
.map(|syntax| SyntaxReferenceInSet { syntax, syntax_set })
|
|
.ok_or_else(|| Error::UnknownSyntax(language.to_owned()));
|
|
}
|
|
|
|
let path = input.path();
|
|
let absolute_path = path.and_then(|p| {
|
|
PathAbs::new(p)
|
|
.ok()
|
|
.map(|abs| abs.as_path().to_path_buf())
|
|
.or_else(|| Some(p.to_owned()))
|
|
});
|
|
|
|
let path_syntax = if let Some(ref path) = absolute_path {
|
|
self.get_syntax_for_path(path, mapping).or_else(|e| {
|
|
// If syntax detection failed on the given path, retry with the
|
|
// canonicalized path (which resolves symlinks). This handles
|
|
// cases like `Aliases/0install -> ../Formula/zero-install.rb`
|
|
// where the symlink name has no extension but the target does.
|
|
// See #1001.
|
|
if matches!(e, Error::UndetectedSyntax(_)) {
|
|
if let Ok(resolved) = fs::canonicalize(path) {
|
|
if resolved != *path {
|
|
return match self.get_syntax_for_path(&resolved, mapping) {
|
|
Ok(syntax) => Ok(syntax),
|
|
Err(Error::UndetectedSyntax(_)) => Err(e),
|
|
Err(err) => Err(err),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
Err(e)
|
|
})
|
|
} else {
|
|
Err(Error::UndetectedSyntax("[unknown]".into()))
|
|
};
|
|
|
|
// If a path wasn't provided, or if path based syntax detection
|
|
// above failed, we fall back to first-line syntax detection.
|
|
match path_syntax {
|
|
Err(Error::UndetectedSyntax(path)) => {
|
|
if let Some(syntax_in_set) = self.get_first_line_syntax(&mut input.reader)? {
|
|
Ok(syntax_in_set)
|
|
} else if let Some(language) = fallback_syntax {
|
|
self.find_syntax_by_token(language)?
|
|
.ok_or_else(|| Error::UnknownSyntax(language.to_owned()))
|
|
} else {
|
|
Err(Error::UndetectedSyntax(path))
|
|
}
|
|
}
|
|
_ => path_syntax,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn find_syntax_by_name(
|
|
&self,
|
|
syntax_name: &str,
|
|
) -> Result<Option<SyntaxReferenceInSet<'_>>> {
|
|
let syntax_set = self.get_syntax_set()?;
|
|
Ok(syntax_set
|
|
.find_syntax_by_name(syntax_name)
|
|
.map(|syntax| SyntaxReferenceInSet { syntax, syntax_set }))
|
|
}
|
|
|
|
fn find_syntax_by_extension(
|
|
&self,
|
|
e: Option<&OsStr>,
|
|
) -> Result<Option<SyntaxReferenceInSet<'_>>> {
|
|
let syntax_set = self.get_syntax_set()?;
|
|
let extension = e.and_then(|x| x.to_str()).unwrap_or_default();
|
|
Ok(syntax_set
|
|
.find_syntax_by_extension(extension)
|
|
.map(|syntax| SyntaxReferenceInSet { syntax, syntax_set }))
|
|
}
|
|
|
|
fn find_syntax_by_hidden_file_name(
|
|
&self,
|
|
file_name: &OsStr,
|
|
) -> Result<Option<SyntaxReferenceInSet<'_>>> {
|
|
let Some(hidden_file_extension) = file_name
|
|
.to_str()
|
|
.and_then(|name| name.strip_prefix('.'))
|
|
.filter(|name| !name.is_empty())
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
|
|
// syntect stores `hidden_file_extensions` in the same extension list as
|
|
// regular file extensions, but dotfiles must be queried without the
|
|
// leading period.
|
|
self.find_syntax_by_extension(Some(OsStr::new(hidden_file_extension)))
|
|
}
|
|
|
|
fn find_syntax_by_token(&self, token: &str) -> Result<Option<SyntaxReferenceInSet<'_>>> {
|
|
let syntax_set = self.get_syntax_set()?;
|
|
Ok(syntax_set
|
|
.find_syntax_by_token(token)
|
|
.map(|syntax| SyntaxReferenceInSet { syntax, syntax_set }))
|
|
}
|
|
|
|
fn get_syntax_for_file_name(
|
|
&self,
|
|
file_name: &OsStr,
|
|
ignored_suffixes: &IgnoredSuffixes,
|
|
) -> Result<Option<SyntaxReferenceInSet<'_>>> {
|
|
let mut syntax = self.find_syntax_by_extension(Some(file_name))?;
|
|
if syntax.is_none() {
|
|
syntax = self.find_syntax_by_hidden_file_name(file_name)?;
|
|
}
|
|
if syntax.is_none() {
|
|
syntax =
|
|
ignored_suffixes.try_with_stripped_suffix(file_name, |stripped_file_name| {
|
|
// Note: recursion
|
|
self.get_syntax_for_file_name(stripped_file_name, ignored_suffixes)
|
|
})?;
|
|
}
|
|
Ok(syntax)
|
|
}
|
|
|
|
fn get_syntax_for_file_extension(
|
|
&self,
|
|
file_name: &OsStr,
|
|
ignored_suffixes: &IgnoredSuffixes,
|
|
) -> Result<Option<SyntaxReferenceInSet<'_>>> {
|
|
let mut syntax = self.find_syntax_by_extension(Path::new(file_name).extension())?;
|
|
if syntax.is_none() {
|
|
syntax =
|
|
ignored_suffixes.try_with_stripped_suffix(file_name, |stripped_file_name| {
|
|
// Note: recursion
|
|
self.get_syntax_for_file_extension(stripped_file_name, ignored_suffixes)
|
|
})?;
|
|
}
|
|
Ok(syntax)
|
|
}
|
|
|
|
fn get_first_line_syntax(
|
|
&self,
|
|
reader: &mut InputReader,
|
|
) -> Result<Option<SyntaxReferenceInSet<'_>>> {
|
|
let syntax_set = self.get_syntax_set()?;
|
|
Ok(String::from_utf8(reader.first_line.clone())
|
|
.ok()
|
|
.and_then(|l| {
|
|
// Strip UTF-8 BOM if present
|
|
let line = l.strip_prefix('\u{feff}').unwrap_or(&l);
|
|
syntax_set.find_syntax_by_first_line(line)
|
|
})
|
|
.map(|syntax| SyntaxReferenceInSet { syntax, syntax_set }))
|
|
}
|
|
}
|
|
|
|
pub(crate) fn get_serialized_integrated_syntaxset() -> &'static [u8] {
|
|
include_bytes!("../assets/syntaxes.bin")
|
|
}
|
|
|
|
pub(crate) fn get_integrated_themeset() -> LazyThemeSet {
|
|
from_binary(include_bytes!("../assets/themes.bin"), COMPRESS_THEMES)
|
|
}
|
|
|
|
pub fn get_acknowledgements() -> String {
|
|
from_binary(
|
|
include_bytes!("../assets/acknowledgements.bin"),
|
|
COMPRESS_ACKNOWLEDGEMENTS,
|
|
)
|
|
}
|
|
|
|
pub(crate) fn from_binary<T: serde::de::DeserializeOwned>(v: &[u8], compressed: bool) -> T {
|
|
asset_from_contents(v, "n/a", compressed)
|
|
.expect("data integrated in binary is never faulty, but make sure `compressed` is in sync!")
|
|
}
|
|
|
|
fn asset_from_contents<T: serde::de::DeserializeOwned>(
|
|
contents: &[u8],
|
|
description: &str,
|
|
compressed: bool,
|
|
) -> Result<T> {
|
|
if compressed {
|
|
bincode::deserialize_from(flate2::read::ZlibDecoder::new(contents))
|
|
} else {
|
|
bincode::deserialize_from(contents)
|
|
}
|
|
.map_err(|_| format!("Could not parse {description}").into())
|
|
}
|
|
|
|
fn asset_from_cache<T: serde::de::DeserializeOwned>(
|
|
path: &Path,
|
|
description: &str,
|
|
compressed: bool,
|
|
) -> Result<T> {
|
|
let contents = fs::read(path).map_err(|_| {
|
|
format!(
|
|
"Could not load cached {description} '{}'",
|
|
path.to_string_lossy()
|
|
)
|
|
})?;
|
|
asset_from_contents(&contents[..], description, compressed)
|
|
.map_err(|_| format!("Could not parse cached {description}").into())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
use std::ffi::OsStr;
|
|
|
|
use std::fs::File;
|
|
use std::io::{BufReader, Write};
|
|
use tempfile::TempDir;
|
|
|
|
use crate::input::Input;
|
|
|
|
struct SyntaxDetectionTest<'a> {
|
|
assets: HighlightingAssets,
|
|
pub syntax_mapping: SyntaxMapping<'a>,
|
|
pub temp_dir: TempDir,
|
|
}
|
|
|
|
impl SyntaxDetectionTest<'_> {
|
|
fn new() -> Self {
|
|
SyntaxDetectionTest {
|
|
assets: HighlightingAssets::from_binary(),
|
|
syntax_mapping: SyntaxMapping::new(),
|
|
temp_dir: TempDir::new().expect("creation of temporary directory"),
|
|
}
|
|
}
|
|
|
|
fn get_syntax_name(
|
|
&self,
|
|
language: Option<&str>,
|
|
fallback_syntax: Option<&str>,
|
|
input: &mut OpenedInput,
|
|
mapping: &SyntaxMapping,
|
|
) -> String {
|
|
self.assets
|
|
.get_syntax(language, fallback_syntax, input, mapping)
|
|
.map(|syntax_in_set| syntax_in_set.syntax.name.clone())
|
|
.unwrap_or_else(|_| "!no syntax!".to_owned())
|
|
}
|
|
|
|
fn syntax_for_real_file_with_content_os(
|
|
&self,
|
|
file_name: &OsStr,
|
|
first_line: &str,
|
|
) -> String {
|
|
let file_path = self.temp_dir.path().join(file_name);
|
|
{
|
|
let mut temp_file = File::create(&file_path).unwrap();
|
|
writeln!(temp_file, "{first_line}").unwrap();
|
|
}
|
|
|
|
let input = Input::ordinary_file(&file_path);
|
|
let dummy_stdin: &[u8] = &[];
|
|
let mut opened_input = input.open(dummy_stdin, None).unwrap();
|
|
|
|
self.get_syntax_name(None, None, &mut opened_input, &self.syntax_mapping)
|
|
}
|
|
|
|
fn syntax_for_file_with_content_os(&self, file_name: &OsStr, first_line: &str) -> String {
|
|
let file_path = self.temp_dir.path().join(file_name);
|
|
let input = Input::from_reader(Box::new(BufReader::new(first_line.as_bytes())))
|
|
.with_name(Some(&file_path));
|
|
let dummy_stdin: &[u8] = &[];
|
|
let mut opened_input = input.open(dummy_stdin, None).unwrap();
|
|
|
|
self.get_syntax_name(None, None, &mut opened_input, &self.syntax_mapping)
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn syntax_for_file_os(&self, file_name: &OsStr) -> String {
|
|
self.syntax_for_file_with_content_os(file_name, "")
|
|
}
|
|
|
|
fn syntax_for_file_with_content(&self, file_name: &str, first_line: &str) -> String {
|
|
self.syntax_for_file_with_content_os(OsStr::new(file_name), first_line)
|
|
}
|
|
|
|
fn syntax_for_file(&self, file_name: &str) -> String {
|
|
self.syntax_for_file_with_content(file_name, "")
|
|
}
|
|
|
|
fn syntax_for_stdin_with_content(&self, file_name: &str, content: &[u8]) -> String {
|
|
let input = Input::stdin().with_name(Some(file_name));
|
|
let mut opened_input = input.open(content, None).unwrap();
|
|
|
|
self.get_syntax_name(None, None, &mut opened_input, &self.syntax_mapping)
|
|
}
|
|
|
|
fn syntax_is_same_for_inputkinds(&self, file_name: &str, content: &str) -> bool {
|
|
let as_file = self.syntax_for_real_file_with_content_os(file_name.as_ref(), content);
|
|
let as_reader = self.syntax_for_file_with_content_os(file_name.as_ref(), content);
|
|
let consistent = as_file == as_reader;
|
|
// TODO: Compare StdIn somehow?
|
|
|
|
if !consistent {
|
|
eprintln!(
|
|
"Inconsistent syntax detection:\nFor File: {as_file}\nFor Reader: {as_reader}"
|
|
)
|
|
}
|
|
|
|
consistent
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn syntax_detection_basic() {
|
|
let test = SyntaxDetectionTest::new();
|
|
|
|
assert_eq!(test.syntax_for_file("test.rs"), "Rust");
|
|
assert_eq!(test.syntax_for_file("test.cpp"), "C++");
|
|
assert_eq!(test.syntax_for_file("test.build"), "NAnt Build File");
|
|
assert_eq!(
|
|
test.syntax_for_file("PKGBUILD"),
|
|
"Bourne Again Shell (bash)"
|
|
);
|
|
assert_eq!(test.syntax_for_file(".bashrc"), "Bourne Again Shell (bash)");
|
|
assert_eq!(test.syntax_for_file("Makefile"), "Makefile");
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn syntax_detection_invalid_utf8() {
|
|
use std::os::unix::ffi::OsStrExt;
|
|
|
|
let test = SyntaxDetectionTest::new();
|
|
|
|
assert_eq!(
|
|
test.syntax_for_file_os(OsStr::from_bytes(b"invalid_\xFEutf8_filename.rs")),
|
|
"Rust"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn syntax_detection_same_for_inputkinds() {
|
|
let mut test = SyntaxDetectionTest::new();
|
|
|
|
test.syntax_mapping
|
|
.insert("*.myext", MappingTarget::MapTo("C"))
|
|
.ok();
|
|
test.syntax_mapping
|
|
.insert("MY_FILE", MappingTarget::MapTo("Markdown"))
|
|
.ok();
|
|
|
|
assert!(test.syntax_is_same_for_inputkinds("Test.md", ""));
|
|
assert!(test.syntax_is_same_for_inputkinds("Test.txt", "#!/bin/bash"));
|
|
assert!(test.syntax_is_same_for_inputkinds(".bashrc", ""));
|
|
assert!(test.syntax_is_same_for_inputkinds("test.h", ""));
|
|
assert!(test.syntax_is_same_for_inputkinds("test.js", "#!/bin/bash"));
|
|
assert!(test.syntax_is_same_for_inputkinds("test.myext", ""));
|
|
assert!(test.syntax_is_same_for_inputkinds("MY_FILE", ""));
|
|
assert!(test.syntax_is_same_for_inputkinds("MY_FILE", "<?php"));
|
|
}
|
|
|
|
#[test]
|
|
fn syntax_detection_well_defined_mapping_for_duplicate_extensions() {
|
|
let test = SyntaxDetectionTest::new();
|
|
|
|
assert_eq!(test.syntax_for_file("test.h"), "C++");
|
|
assert_eq!(test.syntax_for_file("test.sass"), "Sass");
|
|
assert_eq!(test.syntax_for_file("test.js"), "JavaScript (Babel)");
|
|
assert_eq!(test.syntax_for_file("test.fs"), "F#");
|
|
assert_eq!(test.syntax_for_file("test.v"), "Verilog");
|
|
}
|
|
|
|
#[test]
|
|
fn syntax_detection_first_line() {
|
|
let test = SyntaxDetectionTest::new();
|
|
|
|
assert_eq!(
|
|
test.syntax_for_file_with_content("my_script", "#!/bin/bash"),
|
|
"Bourne Again Shell (bash)"
|
|
);
|
|
assert_eq!(
|
|
test.syntax_for_file_with_content("build", "#!/bin/bash"),
|
|
"Bourne Again Shell (bash)"
|
|
);
|
|
assert_eq!(
|
|
test.syntax_for_file_with_content("my_script", "<?php"),
|
|
"PHP"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn syntax_detection_first_line_with_utf8_bom() {
|
|
let test = SyntaxDetectionTest::new();
|
|
|
|
// Test that XML files are detected correctly even with UTF-8 BOM
|
|
// The BOM should be stripped before first-line syntax detection
|
|
let xml_with_bom = "\u{feff}<?xml version=\"1.0\" encoding=\"utf-8\"?>";
|
|
assert_eq!(
|
|
test.syntax_for_file_with_content("unknown_file", xml_with_bom),
|
|
"XML"
|
|
);
|
|
|
|
// Test the specific .csproj case mentioned in the issue
|
|
// Even if .csproj has extension mapping, this tests first-line fallback
|
|
let csproj_content_with_bom = "\u{feff}<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<Project ToolsVersion=\"15.0\" xmlns=\"http://schemas.microsoft.com/developer/msbuild/2003\">";
|
|
assert_eq!(
|
|
test.syntax_for_file_with_content("test.csproj", csproj_content_with_bom),
|
|
"XML"
|
|
);
|
|
|
|
// Test that shell scripts are detected correctly even with UTF-8 BOM
|
|
let script_with_bom = "\u{feff}#!/bin/bash";
|
|
assert_eq!(
|
|
test.syntax_for_file_with_content("unknown_script", script_with_bom),
|
|
"Bourne Again Shell (bash)"
|
|
);
|
|
|
|
// Test that PHP files are detected correctly even with UTF-8 BOM
|
|
let php_with_bom = "\u{feff}<?php";
|
|
assert_eq!(
|
|
test.syntax_for_file_with_content("unknown_php", php_with_bom),
|
|
"PHP"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn syntax_detection_with_custom_mapping() {
|
|
let mut test = SyntaxDetectionTest::new();
|
|
|
|
assert_eq!(test.syntax_for_file("test.h"), "C++");
|
|
test.syntax_mapping
|
|
.insert("*.h", MappingTarget::MapTo("C"))
|
|
.ok();
|
|
assert_eq!(test.syntax_for_file("test.h"), "C");
|
|
}
|
|
|
|
#[test]
|
|
fn syntax_detection_with_extension_mapping_to_unknown() {
|
|
let mut test = SyntaxDetectionTest::new();
|
|
|
|
// Normally, a CMakeLists.txt file shall use the CMake syntax, even if it is
|
|
// a bash script in disguise
|
|
assert_eq!(
|
|
test.syntax_for_file_with_content("CMakeLists.txt", "#!/bin/bash"),
|
|
"CMake"
|
|
);
|
|
|
|
// Other .txt files shall use the Plain Text syntax
|
|
assert_eq!(
|
|
test.syntax_for_file_with_content("some-other.txt", "#!/bin/bash"),
|
|
"Plain Text"
|
|
);
|
|
|
|
// If we setup MapExtensionToUnknown on *.txt, the match on the full
|
|
// file name of "CMakeLists.txt" shall have higher prio, and CMake shall
|
|
// still be used for it
|
|
test.syntax_mapping
|
|
.insert("*.txt", MappingTarget::MapExtensionToUnknown)
|
|
.ok();
|
|
assert_eq!(
|
|
test.syntax_for_file_with_content("CMakeLists.txt", "#!/bin/bash"),
|
|
"CMake"
|
|
);
|
|
|
|
// However, for *other* files with a .txt extension, first-line fallback
|
|
// shall now be used
|
|
assert_eq!(
|
|
test.syntax_for_file_with_content("some-other.txt", "#!/bin/bash"),
|
|
"Bourne Again Shell (bash)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn syntax_detection_is_case_insensitive() {
|
|
let mut test = SyntaxDetectionTest::new();
|
|
|
|
assert_eq!(test.syntax_for_file("README.md"), "Markdown");
|
|
assert_eq!(test.syntax_for_file("README.mD"), "Markdown");
|
|
assert_eq!(test.syntax_for_file("README.Md"), "Markdown");
|
|
assert_eq!(test.syntax_for_file("README.MD"), "Markdown");
|
|
|
|
// Adding a mapping for "MD" in addition to "md" should not break the mapping
|
|
test.syntax_mapping
|
|
.insert("*.MD", MappingTarget::MapTo("Markdown"))
|
|
.ok();
|
|
|
|
assert_eq!(test.syntax_for_file("README.md"), "Markdown");
|
|
assert_eq!(test.syntax_for_file("README.mD"), "Markdown");
|
|
assert_eq!(test.syntax_for_file("README.Md"), "Markdown");
|
|
assert_eq!(test.syntax_for_file("README.MD"), "Markdown");
|
|
}
|
|
|
|
#[test]
|
|
fn syntax_detection_stdin_filename() {
|
|
let test = SyntaxDetectionTest::new();
|
|
|
|
// from file extension
|
|
assert_eq!(test.syntax_for_stdin_with_content("test.cpp", b"a"), "C++");
|
|
// from first line (fallback)
|
|
assert_eq!(
|
|
test.syntax_for_stdin_with_content("my_script", b"#!/bin/bash"),
|
|
"Bourne Again Shell (bash)"
|
|
);
|
|
}
|
|
|
|
#[cfg(feature = "build-assets")]
|
|
#[test]
|
|
fn syntax_detection_hidden_file_extensions() {
|
|
let source_dir = TempDir::new().expect("creation of temporary source directory");
|
|
let cache_dir = TempDir::new().expect("creation of temporary cache directory");
|
|
let syntax_dir = source_dir.path().join("syntaxes");
|
|
|
|
std::fs::create_dir_all(&syntax_dir).expect("creation of syntax directory succeeds");
|
|
std::fs::write(
|
|
syntax_dir.join("HiddenFileExtension.sublime-syntax"),
|
|
r#"%YAML 1.2
|
|
---
|
|
name: Hidden File Extension
|
|
hidden_file_extensions:
|
|
- testrc
|
|
scope: source.hiddenfileextension
|
|
|
|
contexts:
|
|
main:
|
|
- match: .
|
|
scope: source.hiddenfileextension
|
|
"#,
|
|
)
|
|
.expect("custom syntax can be written");
|
|
|
|
build(
|
|
source_dir.path(),
|
|
false,
|
|
false,
|
|
cache_dir.path(),
|
|
env!("CARGO_PKG_VERSION"),
|
|
)
|
|
.expect("custom assets can be built");
|
|
|
|
let test = SyntaxDetectionTest {
|
|
assets: HighlightingAssets::from_cache(cache_dir.path())
|
|
.expect("custom syntax cache can be loaded"),
|
|
syntax_mapping: SyntaxMapping::new(),
|
|
temp_dir: TempDir::new().expect("creation of temporary directory"),
|
|
};
|
|
|
|
assert_eq!(test.syntax_for_file(".testrc"), "Hidden File Extension");
|
|
assert_eq!(
|
|
test.syntax_for_stdin_with_content(".testrc", b""),
|
|
"Hidden File Extension"
|
|
);
|
|
assert!(test.syntax_is_same_for_inputkinds(".testrc", ""));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn syntax_detection_for_symlinked_file() {
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let test = SyntaxDetectionTest::new();
|
|
let file_path = test.temp_dir.path().join("my_ssh_config_filename");
|
|
{
|
|
File::create(&file_path).unwrap();
|
|
}
|
|
let file_path_symlink = test.temp_dir.path().join(".ssh").join("config");
|
|
|
|
std::fs::create_dir(test.temp_dir.path().join(".ssh"))
|
|
.expect("creation of directory succeeds");
|
|
symlink(&file_path, &file_path_symlink).expect("creation of symbolic link succeeds");
|
|
|
|
let input = Input::ordinary_file(&file_path_symlink);
|
|
let dummy_stdin: &[u8] = &[];
|
|
let mut opened_input = input.open(dummy_stdin, None).unwrap();
|
|
|
|
assert_eq!(
|
|
test.get_syntax_name(None, None, &mut opened_input, &test.syntax_mapping),
|
|
"SSH Config"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn syntax_detection_for_symlinked_file_by_target_extension() {
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let test = SyntaxDetectionTest::new();
|
|
|
|
let formula_dir = test.temp_dir.path().join("Formula");
|
|
std::fs::create_dir(&formula_dir).unwrap();
|
|
let target = formula_dir.join("zero-install.rb");
|
|
File::create(&target).unwrap();
|
|
|
|
let aliases_dir = test.temp_dir.path().join("Aliases");
|
|
std::fs::create_dir(&aliases_dir).unwrap();
|
|
let link = aliases_dir.join("0install");
|
|
symlink(&target, &link).unwrap();
|
|
|
|
let input = Input::ordinary_file(&link);
|
|
let dummy_stdin: &[u8] = &[];
|
|
let mut opened_input = input.open(dummy_stdin, None).unwrap();
|
|
|
|
assert_eq!(
|
|
test.get_syntax_name(None, None, &mut opened_input, &test.syntax_mapping),
|
|
"Ruby"
|
|
);
|
|
}
|
|
}
|