feat(angular): highlight Angular templates with control-flow blocks (#284)

HTML files in Angular projects were highlighted as plain HTML. Languages can now declare framework UI modes with root markers (angular.json, node_modules/@angular/core); files under such a root get the framework's editor mode, re-detected when a marker appears or disappears, and the root-marker lookup is shared with language server roots. The Angular Template mode (vendored @codemirror/lang-angular, MIT) adds @if/@for/@switch/@defer/@let blocks, key and animation binding names, and colours {{ }} delimiters.

Lazily loaded editor modes are now loaded before a document's first editor opens, so a file never renders as plain text first (this also left Angular templates uncoloured).

Co-authored-by: nntndfrk <[email protected]>
This commit is contained in:
Mykhailo Smolinskyiandnntndfrk authored and GitHub committed 2026-10-30 07:39:31 +08:00
1 parent c0626b2728
commit 922bbf493a
44 files changed
+1552 -94

No files matched your search

+1
View File
@@ -9,6 +9,7 @@
"dist",
"target",
"src/bindings.ts",
"src/editor/core/cm/angular/parser*.ts",
"docs/plugins/themes.md",
"pnpm-lock.yaml",
"**/*.toml",
+1
View File
@@ -13,6 +13,7 @@
"src-tauri",
"crates",
"src/bindings.ts",
"src/editor/core/cm/angular/parser*.ts",
"fixtures",
"plugins",
"test-results",
Generated
+1
View File
@@ -4307,6 +4307,7 @@ dependencies = [
"serde",
"serde_json",
"specta",
"tempfile",
"toml 0.9.12+spec-1.1.0",
"tracing",
"tree-sitter",
+12
View File
@@ -111,6 +111,18 @@ shell and css dictionaries of [cspell-dicts](https://github.com/streetsidesoftwa
(October 2026): lowercased, filtered and merged by OpenIDEA. Copyright Street Side Software;
MIT licence, full text in `crates/ide-spell/src/dict/LICENSE-cspell-dicts`.
## CodeMirror Angular template language
`src/editor/core/cm/angular/` (`angular.grammar`, `tokens.ts`, `index.ts` and the
`parser.ts` generated from them) is derived from
[@codemirror/lang-angular](https://github.com/codemirror/lang-angular) 0.1.4
(commit `ecc5038f`). Copyright (C) 2018-2021 by Marijn Haverbeke and others; MIT
licence, full text in `src/editor/core/cm/angular/LICENSE`. Modifications by
OpenIDEA: control-flow blocks (`@if`, `@for`, `@switch`, `@defer`, ...) and `@let`
declarations, key and animation binding names (`(keydown.enter)`, `[@fade]`,
`(@fade.done)`), runs of text that entities split (`a && b`) parsed as one, and one
token for `{` and `}`.
## nvim-treesitter queries
`crates/ide-syntax/queries/<grammar>/locals.scm` are derived from the `locals.scm`
+4 -1
View File
@@ -32,10 +32,13 @@ pub enum IdeEvent {
doc: DocId,
dirty: bool,
},
/// An open document's language changed (Settings › File Types, Override File Type).
/// An open document's language changed (Settings › File Types, Override File
/// Type), or its UI mode (a framework's root marker appeared or went).
DocLanguage {
doc: DocId,
language: crate::LanguageId,
/// See `DocumentInfo::ui_mode`.
ui_mode: Option<String>,
},
/// Full replacement of diagnostics for one file from one producer.
Diagnostics {
+1
View File
@@ -893,6 +893,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
block_comment: None,
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
tree_sitter: None,
language_servers: vec![],
debugger: Some(ide_language::DebuggerConfig {
+8
View File
@@ -22,6 +22,13 @@ pub async fn doc_open_virtual(host: &Host, window: &str, uri: String) -> IdeResu
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
}
/// The CodeMirror mode `path` gets when opened (`DocumentInfo::ui_mode`),
/// without opening it: previews of closed files.
pub async fn doc_ui_mode(host: &Host, window: &str, path: PathBuf) -> IdeResult<Option<String>> {
let project = host.project(window)?;
blocking(move || Ok(project.documents.ui_mode_of(&path))).await
}
/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding label.
pub fn doc_set_encoding(host: &Host, window: &str, doc: DocId, encoding: String, reload: bool) -> IdeResult<String> {
host.project(window)?.documents.set_encoding(doc, &encoding, reload)
@@ -108,6 +115,7 @@ pub fn history_content(host: &Host, window: &str, id: String) -> IdeResult<Strin
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("doc_open_virtual", |c| async move { doc_open_virtual(&c.host, &c.window, c.arg("uri")?).await });
d.add("doc_ui_mode", |c| async move { doc_ui_mode(&c.host, &c.window, c.arg("path")?).await });
d.add("doc_set_encoding", |c| async move {
doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?)
});
+22 -15
View File
@@ -2,7 +2,7 @@
//! watchers and the per-project services, opened by [`crate::Host::open_project`].
use crate::Host;
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, SharedSink};
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, LanguageId, SharedSink};
use ide_index::ProjectIndex;
use ide_language::{FileTypes, LanguageRegistry, SyntaxCache};
use ide_lsp::LspManager;
@@ -88,13 +88,7 @@ impl Host {
// The project's own registry: File Types associations may be project-scoped.
let languages = Arc::new(self.languages.fork());
languages.set_file_types(FileTypes::from_settings(&settings.effective().files, Some(&root)));
let documents = {
let languages = languages.clone();
Arc::new(Documents::new(
sink.clone(),
Box::new(move |p: &Path, first: Option<&str>| languages.detect(p, first)),
))
};
let documents = documents(&sink, &languages, Some(&root));
documents.set_file_defaults(ide_workspace::FileDefaults::from_settings(&settings.effective(), Some(&root)));
let history = Arc::new(LocalHistory::open(&self.data_dir.join("history"), &root)?);
{
@@ -190,6 +184,7 @@ impl Host {
let settings = settings.clone();
let index = index.clone();
let documents = documents.clone();
let languages = languages.clone();
let sink = sink.clone();
let root = root.clone();
let detected_roots = detected_roots.clone();
@@ -205,6 +200,12 @@ impl Host {
index.update(&changed, &batch.removed);
// Atomic saves by external tools surface as `created`.
documents.on_disk_changed(&changed);
// A framework's root marker (`angular.json`) created or deleted: its
// templates gain or lose their mode. Markers in `node_modules` only
// count here when the folder isn't excluded (it is by default).
if batch.created.iter().chain(&batch.removed).any(|p| languages.is_framework_marker(p)) {
documents.redetect_languages();
}
record_external_changes(&history, &changed, &batch.removed);
if !batch.removed.is_empty() {
delete_deployed(
@@ -382,13 +383,7 @@ impl Host {
let effective = settings.effective();
let languages = Arc::new(self.languages.fork());
languages.set_file_types(FileTypes::from_settings(&effective.files, None));
let documents = {
let languages = languages.clone();
Arc::new(Documents::new(
sink.clone(),
Box::new(move |p: &Path, first: Option<&str>| languages.detect(p, first)),
))
};
let documents = documents(&sink, &languages, None);
documents.set_file_defaults(ide_workspace::FileDefaults::from_settings(&effective, None));
let history = Arc::new(LocalHistory::open(&self.data_dir.join("history"), &dir)?);
let index = ProjectIndex::unindexed(&dir, languages.clone(), self.syntax(), sink.clone())?;
@@ -452,6 +447,18 @@ impl Project {
}
}
/// The documents of a project whose languages and UI modes `languages` detects,
/// looking for frameworks' root markers up to `root` (anywhere for loose files).
fn documents(sink: &SharedSink, languages: &Arc<LanguageRegistry>, root: Option<&Path>) -> Arc<Documents> {
let (detect, modes, root) = (languages.clone(), languages.clone(), root.map(Path::to_path_buf));
Arc::new(
Documents::new(sink.clone(), Box::new(move |p: &Path, first: Option<&str>| detect.detect(p, first)))
.with_ui_mode_detection(Box::new(move |p: &Path, lang: &LanguageId| {
modes.framework_ui_mode(lang, p, root.as_deref())
})),
)
}
fn light_watcher(
dir: &Path,
sink: &SharedSink,
+1
View File
@@ -58,6 +58,7 @@ tree-sitter-gdscript = "6.1.0"
[dev-dependencies]
criterion = { version = "0.5", default-features = false }
tempfile.workspace = true
[[bench]]
name = "syntax"
+17
View File
@@ -9,8 +9,10 @@ use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
mod markers;
mod registry;
mod syntax;
pub use markers::marked_root;
pub use registry::{FileTypes, LanguageRegistry};
pub use syntax::{
CSS_CLASS, CSS_ID, CSS_VARIABLE, FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES,
@@ -41,6 +43,10 @@ pub struct LanguageConfig {
/// Name of the CodeMirror/Lezer language the UI should load for instant highlighting.
#[serde(default)]
pub ui_mode: Option<String>,
/// Modes replacing `ui_mode` in a framework's projects (Angular templates);
/// the first one whose root marker is found applies.
#[serde(default)]
pub framework_ui_modes: Vec<FrameworkUiMode>,
#[serde(default)]
pub tree_sitter: Option<String>,
#[serde(default)]
@@ -49,6 +55,17 @@ pub struct LanguageConfig {
pub debugger: Option<DebuggerConfig>,
}
/// A CodeMirror mode for the files of a framework's projects.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct FrameworkUiMode {
pub ui_mode: String,
/// Files whose presence in a directory above a file, up to the project
/// folder, marks the framework's project, e.g. `angular.json`; matched like
/// language servers' `root-markers`.
pub root_markers: Vec<String>,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all(serialize = "camelCase", deserialize = "kebab-case"))]
pub struct IndentConfig {
+89
View File
@@ -0,0 +1,89 @@
//! Root markers: files whose presence in a directory marks a tool's or a
//! framework's project there (`Cargo.toml`, `angular.json`). Language server
//! roots (`root-markers`) and framework modes (`framework-ui-modes`) share them.
use std::path::{Path, PathBuf};
/// The outermost ancestor of `file` containing a root marker, within
/// `project_root` (any ancestor without one: loose files).
pub fn marked_root(project_root: Option<&Path>, file: &Path, markers: &[String]) -> Option<PathBuf> {
let mut best = None;
for dir in file.ancestors().skip(1) {
if project_root.is_some_and(|root| !dir.starts_with(root)) {
break;
}
if markers.iter().any(|m| has_marker(dir, m)) {
best = Some(dir.to_path_buf());
}
}
best
}
/// `dir` contains `marker`: a relative path, or a file name pattern with one
/// `*` (`*.csproj`).
fn has_marker(dir: &Path, marker: &str) -> bool {
let Some((prefix, suffix)) = marker.split_once('*') else {
return dir.join(marker).exists();
};
std::fs::read_dir(dir).is_ok_and(|entries| {
entries.flatten().any(|e| matches_pattern(&e.file_name().to_string_lossy(), prefix, suffix))
})
}
/// `path` is (or, after its creation or removal, was) a root marker of the
/// directory above it.
pub(crate) fn is_marker_path(path: &Path, marker: &str) -> bool {
match marker.split_once('*') {
None => path.ends_with(marker),
Some((prefix, suffix)) => {
path.file_name().is_some_and(|name| matches_pattern(&name.to_string_lossy(), prefix, suffix))
}
}
}
fn matches_pattern(name: &str, prefix: &str, suffix: &str) -> bool {
name.len() >= prefix.len() + suffix.len() && name.starts_with(prefix) && name.ends_with(suffix)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn marked_root_needs_a_marker() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let file = root.join("apps/web/src/app.ts");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
let markers = vec!["angular.json".to_string(), "node_modules/@angular/core".to_string()];
assert_eq!(marked_root(Some(root), &file, &markers), None);
std::fs::create_dir_all(root.join("apps/web/node_modules/@angular/core")).unwrap();
assert_eq!(marked_root(Some(root), &file, &markers), Some(root.join("apps/web")));
assert_eq!(marked_root(Some(root), &root.join("apps/api/main.ts"), &markers), None);
}
#[test]
fn markers_above_the_project_root_only_count_without_one() {
let tmp = tempfile::tempdir().unwrap();
let (home, project) = (tmp.path(), tmp.path().join("work/site"));
let file = project.join("src/index.html");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::create_dir_all(home.join("node_modules/@angular/core")).unwrap();
let markers = vec!["node_modules/@angular/core".to_string()];
assert_eq!(marked_root(Some(&project), &file, &markers), None);
assert_eq!(marked_root(None, &file, &markers), Some(home.to_path_buf()));
}
#[test]
fn patterns_match_file_names() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
std::fs::write(dir.join("App.csproj"), "").unwrap();
assert!(has_marker(dir, "*.csproj") && has_marker(dir, "App.*") && !has_marker(dir, "*.fsproj"));
assert!(is_marker_path(&dir.join("App.csproj"), "*.csproj"));
assert!(is_marker_path(Path::new("/p/web/node_modules/@angular/core"), "node_modules/@angular/core"));
assert!(is_marker_path(Path::new("/p/web/angular.json"), "angular.json"));
assert!(!is_marker_path(Path::new("/p/web/angular.json.bak"), "angular.json"));
assert!(!is_marker_path(Path::new("/p/core"), "node_modules/@angular/core"));
}
}
+54 -1
View File
@@ -277,6 +277,27 @@ impl LanguageRegistry {
self.languages.iter().find(|l| &l.id == id).cloned()
}
/// The `framework-ui-modes` mode of a `language` file at `path`: the first
/// whose root marker is in a directory above it, up to `project_root` (any
/// directory without one: loose files). `None` keeps the language's own
/// `ui-mode`.
pub fn framework_ui_mode(&self, language: &LanguageId, path: &Path, project_root: Option<&Path>) -> Option<String> {
let cfg = self.languages.iter().find(|l| &l.id == language)?;
cfg.framework_ui_modes
.iter()
.find(|f| crate::marked_root(project_root, path, &f.root_markers).is_some())
.map(|f| f.ui_mode.clone())
}
/// Creating or removing `path` can change a file's `framework_ui_mode`:
/// it is a framework's root marker.
pub fn is_framework_marker(&self, path: &Path) -> bool {
self.languages
.iter()
.flat_map(|l| &l.framework_ui_modes)
.any(|f| f.root_markers.iter().any(|m| crate::markers::is_marker_path(path, m)))
}
/// Detects by Override File Type, exact file name, then file-name pattern
/// (`Dockerfile.*`), then extension (longest compound first, e.g. `d.ts`),
/// then hashbang / first line; falls back to plaintext. The user's patterns
@@ -556,6 +577,37 @@ mod tests {
}
}
#[test]
fn html_of_angular_projects_is_an_angular_template() {
let reg = reg();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let mode = |lang: &str, file: &str| reg.framework_ui_mode(&LanguageId::new(lang), &root.join(file), Some(root));
std::fs::create_dir_all(root.join("apps/web/node_modules/@angular/core")).unwrap();
std::fs::create_dir_all(root.join("apps/admin")).unwrap();
std::fs::write(root.join("apps/admin/angular.json"), "{}").unwrap();
assert_eq!(mode("html", "apps/web/src/app/app.html").as_deref(), Some("Angular Template"));
assert_eq!(mode("html", "apps/admin/src/index.html").as_deref(), Some("Angular Template"));
assert_eq!(mode("html", "docs/index.html"), None);
assert_eq!(mode("typescript", "apps/web/src/app/app.ts"), None);
assert!(reg.is_framework_marker(&root.join("apps/admin/angular.json")));
assert!(!reg.is_framework_marker(&root.join("apps/admin/package.json")));
}
#[test]
fn markers_outside_the_project_do_not_make_angular_templates() {
let reg = reg();
let tmp = tempfile::tempdir().unwrap();
// A stray `~/node_modules/@angular/core` above an unrelated project.
std::fs::create_dir_all(tmp.path().join("node_modules/@angular/core")).unwrap();
let project = tmp.path().join("work/site");
let page = project.join("index.html");
assert_eq!(reg.framework_ui_mode(&LanguageId::new("html"), &page, Some(&project)), None);
// Loose files (LightEdit) have no project to stop at.
let loose = reg.framework_ui_mode(&LanguageId::new("html"), &page, None);
assert_eq!(loose.as_deref(), Some("Angular Template"));
}
#[test]
fn every_builtin_file_is_registered_and_parses() {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../languages");
@@ -625,7 +677,8 @@ mod tests {
fn ui_modes_exist_in_codemirror_language_data() {
for (name, src) in BUILTIN {
let cfg = parse(src).unwrap();
if let Some(mode) = &cfg.ui_mode {
let framework_modes = cfg.framework_ui_modes.iter().map(|f| &f.ui_mode);
for mode in cfg.ui_mode.iter().chain(framework_modes) {
assert!(UI_MODES.contains(&mode.as_str()), "{name}: ui-mode {mode:?} is not a language-data name");
}
}
+3 -38
View File
@@ -231,32 +231,10 @@ pub fn find_root(project_root: &Path, file: &Path, markers: &[String]) -> Option
Some(marked_root(project_root, file, markers).unwrap_or_else(|| project_root.to_path_buf()))
}
/// The outermost ancestor of `file` within `project_root` containing a root marker.
/// The outermost ancestor of `file` within `project_root` containing a root
/// marker (the same lookup as `framework-ui-modes`).
pub fn marked_root(project_root: &Path, file: &Path, markers: &[String]) -> Option<PathBuf> {
let mut best = None;
for dir in file.ancestors().skip(1) {
if !dir.starts_with(project_root) {
break;
}
if markers.iter().any(|m| has_marker(dir, m)) {
best = Some(dir.to_path_buf());
}
}
best
}
fn has_marker(dir: &Path, marker: &str) -> bool {
if !marker.contains('*') {
return dir.join(marker).exists();
}
let (prefix, suffix) = marker.split_once('*').unwrap_or((marker, ""));
std::fs::read_dir(dir).is_ok_and(|entries| {
entries.flatten().any(|e| {
let name = e.file_name();
let name = name.to_string_lossy();
name.len() >= prefix.len() + suffix.len() && name.starts_with(prefix) && name.ends_with(suffix)
})
})
ide_language::marked_root(Some(project_root), file, markers)
}
#[cfg(test)]
@@ -280,19 +258,6 @@ mod tests {
std::fs::remove_dir_all(&tmp).unwrap();
}
#[test]
fn marked_root_needs_a_marker() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let file = root.join("apps/web/src/app.ts");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
let markers = vec!["angular.json".to_string(), "node_modules/@angular/core".to_string()];
assert_eq!(marked_root(root, &file, &markers), None);
std::fs::create_dir_all(root.join("apps/web/node_modules/@angular/core")).unwrap();
assert_eq!(marked_root(root, &file, &markers), Some(root.join("apps/web")));
assert_eq!(marked_root(root, &root.join("apps/api/main.ts"), &markers), None);
}
#[test]
fn search_path_contains_env_path() {
let p = search_path();
+1
View File
@@ -245,6 +245,7 @@ fn harness_configured(
block_comment: None,
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
tree_sitter: None,
language_servers: servers
.iter()
+1
View File
@@ -54,6 +54,7 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig {
block_comment: None,
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
tree_sitter: None,
language_servers: vec![server],
debugger: None,
+44 -12
View File
@@ -35,6 +35,10 @@ pub struct DocumentInfo {
/// provides, e.g. `jdt://…`); `path` then holds the same URI.
#[specta(optional)]
pub uri: Option<String>,
/// CodeMirror mode replacing the language's own one (an Angular project's
/// HTML templates).
#[specta(optional)]
pub ui_mode: Option<String>,
}
/// Current text and identity of a document, for analyses (outline, folding).
@@ -49,6 +53,9 @@ pub struct TextSnapshot {
/// Language of a file from its path and first line (hashbangs).
pub type DetectLanguage = dyn Fn(&Path, Option<&str>) -> LanguageId + Send + Sync;
/// CodeMirror mode of a file replacing its language's own one; `None` keeps it.
pub type DetectUiMode = dyn Fn(&Path, &LanguageId) -> Option<String> + Send + Sync;
/// Registry of open documents keyed by path, with ref-counting so split
/// editors on the same file share one buffer.
///
@@ -59,6 +66,7 @@ pub struct Documents {
observers: RwLock<Vec<Arc<dyn DocumentObserver>>>,
sink: SharedSink,
detect_language: Box<DetectLanguage>,
detect_ui_mode: Box<DetectUiMode>,
editorconfig: EditorConfig,
defaults: RwLock<FileDefaults>,
}
@@ -107,6 +115,7 @@ struct DocState {
path: PathBuf,
doc: Document,
language: LanguageId,
ui_mode: Option<String>,
encoding: String,
has_bom: bool,
read_only: bool,
@@ -157,11 +166,24 @@ impl Documents {
observers: RwLock::default(),
sink,
detect_language,
detect_ui_mode: Box::new(|_, _| None),
editorconfig: EditorConfig::new(),
defaults: RwLock::default(),
}
}
/// Detects per-file CodeMirror modes (framework templates) besides languages.
pub fn with_ui_mode_detection(mut self, detect_ui_mode: Box<DetectUiMode>) -> Self {
self.detect_ui_mode = detect_ui_mode;
self
}
/// The `DocumentInfo::ui_mode` `path` gets when opened, without opening it
/// (previews of other files).
pub fn ui_mode_of(&self, path: &Path) -> Option<String> {
(self.detect_ui_mode)(path, &(self.detect_language)(path, None))
}
/// Settings-derived defaults for new files, fallback decoding and EditorConfig support.
pub fn set_file_defaults(&self, defaults: FileDefaults) {
*self.defaults.write() = defaults;
@@ -221,6 +243,7 @@ impl Documents {
large: st.large,
editorconfig: st.editorconfig.clone(),
uri: st.uri.clone(),
ui_mode: st.ui_mode.clone(),
}
}
@@ -242,6 +265,7 @@ impl Documents {
let defaults = self.file_defaults();
let loaded = ide_vfs::load_text_with_fallback(path, defaults.fallback_for(&absolute(path)))?;
let language = (self.detect_language)(path, loaded.text.lines().next());
let ui_mode = (self.detect_ui_mode)(path, &language);
// Archive entries (`<jar>!/<entry>`) are read-only and kept from language
// servers and Local History, which only know real files.
let in_archive = ide_vfs::is_archive_path(path);
@@ -278,6 +302,7 @@ impl Documents {
path: path.to_path_buf(),
doc,
language,
ui_mode,
encoding,
has_bom,
read_only: !observed || !writable,
@@ -334,6 +359,7 @@ impl Documents {
path: key.clone(),
doc: Document::new(text),
language,
ui_mode: None,
encoding: "utf-8".into(),
has_bom: false,
read_only: true,
@@ -586,10 +612,12 @@ impl Documents {
self.registry.lock().docs.keys().copied().collect()
}
/// Detects the language of every open file again (file type associations
/// changed). A document whose language changes is reopened for the
/// observers, so it moves to the matching language servers and syntax tree,
/// and reported as [`IdeEvent::DocLanguage`]. Returns the changed documents.
/// Detects the language and UI mode of every open file again (file type
/// associations changed, a framework's root marker appeared or went). A
/// document whose language or mode changes is reported as
/// [`IdeEvent::DocLanguage`]; one whose language changes is also reopened
/// for the observers, so it moves to the matching language servers and
/// syntax tree. Returns the changed documents.
pub fn redetect_languages(&self) -> Vec<DocId> {
let slots: Vec<(DocId, Slot)> = self.registry.lock().docs.iter().map(|(id, e)| (*id, e.slot.clone())).collect();
let mut changed = Vec::new();
@@ -600,18 +628,22 @@ impl Documents {
}
let text = st.doc.text();
let language = (self.detect_language)(&st.path, text.lines().next());
if language == st.language {
let ui_mode = (self.detect_ui_mode)(&st.path, &language);
if language == st.language && ui_mode == st.ui_mode {
continue;
}
st.language = language.clone();
if st.observed {
let info = Self::info_with_text(id, &st, text);
for o in self.observers() {
o.closed(id, &st.path);
o.opened(&info);
st.ui_mode = ui_mode;
if language != st.language {
st.language = language.clone();
if st.observed {
let info = Self::info_with_text(id, &st, text);
for o in self.observers() {
o.closed(id, &st.path);
o.opened(&info);
}
}
}
self.sink.emit(IdeEvent::DocLanguage { doc: id, language });
self.sink.emit(IdeEvent::DocLanguage { doc: id, language, ui_mode: st.ui_mode.clone() });
changed.push(id);
}
changed
+88 -1
View File
@@ -102,10 +102,97 @@ fn redetect_reopens_documents_whose_language_changed() {
let log = rec.log.lock().clone();
assert_eq!(log[log.len() - 2..], ["close".to_owned(), "open v0 python".to_owned()]);
assert!(events.lock().iter().any(
|e| matches!(e, IdeEvent::DocLanguage { doc, language } if *doc == a.id && language.as_str() == "python")
|e| matches!(e, IdeEvent::DocLanguage { doc, language, .. } if *doc == a.id && language.as_str() == "python")
));
}
#[test]
fn ui_modes_are_detected_with_the_language() {
let tmp = tempfile::tempdir().unwrap();
let events: Arc<Mutex<Vec<IdeEvent>>> = Arc::default();
let sink: SharedSink = {
let events = events.clone();
Arc::new(move |e: IdeEvent| events.lock().push(e))
};
let tpl_is_html = Arc::new(Mutex::new(false));
let docs = {
let tpl_is_html = tpl_is_html.clone();
Documents::new(
sink,
Box::new(move |p: &Path, _: Option<&str>| match p.extension().and_then(|e| e.to_str()) {
Some("html") => LanguageId::new("html"),
Some("tpl") if *tpl_is_html.lock() => LanguageId::new("html"),
_ => LanguageId::plain_text(),
}),
)
.with_ui_mode_detection(Box::new(|p: &Path, lang: &LanguageId| {
(lang.as_str() == "html" && p.parent().is_some_and(|d| d.ends_with("ng")))
.then(|| "Angular Template".into())
}))
};
fs::create_dir_all(tmp.path().join("ng")).unwrap();
let (template, page, tpl) =
(tmp.path().join("ng/app.html"), tmp.path().join("index.html"), tmp.path().join("ng/a.tpl"));
for p in [&template, &page, &tpl] {
fs::write(p, "<p></p>\n").unwrap();
}
assert_eq!(docs.open(&template).unwrap().ui_mode.as_deref(), Some("Angular Template"));
assert_eq!(docs.open(&page).unwrap().ui_mode, None);
let a = docs.open(&tpl).unwrap();
assert_eq!(a.ui_mode, None);
*tpl_is_html.lock() = true;
assert_eq!(docs.redetect_languages(), vec![a.id]);
assert_eq!(docs.snapshot(a.id).unwrap().ui_mode.as_deref(), Some("Angular Template"));
assert!(events.lock().iter().any(|e| matches!(
e,
IdeEvent::DocLanguage { doc, ui_mode: Some(mode), .. } if *doc == a.id && mode == "Angular Template"
)));
}
#[test]
fn ui_modes_are_redetected_without_a_language_change() {
let tmp = tempfile::tempdir().unwrap();
let events: Arc<Mutex<Vec<IdeEvent>>> = Arc::default();
let sink: SharedSink = {
let events = events.clone();
Arc::new(move |e: IdeEvent| events.lock().push(e))
};
let docs = Documents::new(sink, Box::new(|_: &Path, _: Option<&str>| LanguageId::new("html")))
.with_ui_mode_detection(Box::new(|p: &Path, _: &LanguageId| {
p.parent().is_some_and(|d| d.join("angular.json").exists()).then(|| "Angular Template".into())
}));
let rec = Arc::new(Recorder::default());
docs.add_observer(rec.clone());
let (template, marker) = (tmp.path().join("app.html"), tmp.path().join("angular.json"));
fs::write(&template, "<p></p>\n").unwrap();
let a = docs.open(&template).unwrap();
assert_eq!(a.ui_mode, None);
let opened = rec.log.lock().len();
fs::write(&marker, "{}").unwrap();
assert_eq!(docs.ui_mode_of(&template).as_deref(), Some("Angular Template"));
assert_eq!(docs.redetect_languages(), vec![a.id]);
assert_eq!(docs.snapshot(a.id).unwrap().ui_mode.as_deref(), Some("Angular Template"));
assert!(docs.redetect_languages().is_empty());
fs::remove_file(&marker).unwrap();
assert_eq!(docs.redetect_languages(), vec![a.id]);
assert_eq!(docs.snapshot(a.id).unwrap().ui_mode, None);
let modes: Vec<Option<String>> = events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::DocLanguage { doc, language, ui_mode } if *doc == a.id && language.as_str() == "html" => {
Some(ui_mode.clone())
}
_ => None,
})
.collect();
assert_eq!(modes, [Some("Angular Template".to_owned()), None]);
assert_eq!(rec.log.lock().len(), opened, "language servers keep the document");
}
fn dirty_events(events: &[IdeEvent]) -> Vec<bool> {
events.iter().filter_map(|e| if let IdeEvent::DocDirty { dirty, .. } = e { Some(*dirty) } else { None }).collect()
}
+7 -1
View File
@@ -2,7 +2,13 @@ import type { KnipConfig } from "knip";
const config: KnipConfig = {
// Entries come from the Vite (index.html), Vitest, Playwright and package.json script plugins.
project: ["src/**/*.{ts,tsx,css}", "scripts/**/*.ts", "perf/**/*.ts", "e2e/**/*.ts"],
project: [
"src/**/*.{ts,tsx,css}",
"!src/editor/core/cm/angular/parser.terms.ts",
"scripts/**/*.ts",
"perf/**/*.ts",
"e2e/**/*.ts",
],
ignoreExportsUsedInFile: true,
// lint-staged formats Rust files with the toolchain's rustfmt.
ignoreBinaries: ["rustfmt"],
+6
View File
@@ -7,6 +7,12 @@ indent = { tab-size = 2, insert-spaces = true }
ui-mode = "HTML"
tree-sitter = "html"
# Angular projects: component templates, with bindings and interpolations
# highlighted as expressions.
[[framework-ui-modes]]
ui-mode = "Angular Template"
root-markers = ["angular.json", "node_modules/@angular/core"]
# Angular projects only: component templates. First, so that hover, rename and
# document symbols of templates come from it rather than plain HTML.
[[language-servers]]
+6
View File
@@ -18,6 +18,7 @@
"knip": "knip",
"typecheck": "tsrs --noEmit && tsrs -p tsconfig.node.json --noEmit && tsrs -p perf --noEmit && tsrs -p scripts --noEmit",
"gen:bindings": "cargo test -p openidea --test export_bindings",
"gen:angular-grammar": "lezer-generator src/editor/core/cm/angular/angular.grammar --typeScript -o src/editor/core/cm/angular/parser.ts",
"gen:theme-docs": "node scripts/theme/gen-docs.ts",
"theme:import-intellij": "node scripts/theme/import-intellij-theme.ts",
"dev:app": "node scripts/tauri.ts dev --config src-tauri/tauri.dev.conf.json",
@@ -37,6 +38,8 @@
"dependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/language": "^6.13.1",
"@codemirror/language-data": "^6.5.2",
"@codemirror/lint": "^6.9.7",
@@ -47,7 +50,9 @@
"@floating-ui/react-dom": "^2.1.9",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@lezer/common": "^1.5.3",
"@lezer/highlight": "^1.2.5",
"@lezer/lr": "^1.4.11",
"@replit/codemirror-vim": "6.4.0",
"@tanstack/react-virtual": "^3.14.21",
"@tauri-apps/api": "^2",
@@ -69,6 +74,7 @@
"zustand": "^5.0.15"
},
"devDependencies": {
"@lezer/generator": "^1.8.1",
"@playwright/test": "^1.64.1",
"@tauri-apps/cli": "^2",
"@testing-library/jest-dom": "^7.0.1",
+10 -7
View File
@@ -152,15 +152,18 @@ export class Harness {
return this.page.evaluate(() => performance.now());
}
/** Seeds a file into the in-memory backend without opening it. */
async seed(path: string, text: string) {
await this.page.evaluate(
({ path, text }) =>
(window as never as { __openideaPerf: { seed(p: string, t: string): void } }).__openideaPerf.seed(path, text),
{ path, text },
);
}
/** Seeds a file into the in-memory backend and opens it, returning open → painted ms. */
async openFile(path: string, text?: string): Promise<number> {
if (text !== undefined) {
await this.page.evaluate(
({ path, text }) =>
(window as never as { __openideaPerf: { seed(p: string, t: string): void } }).__openideaPerf.seed(path, text),
{ path, text },
);
}
if (text !== undefined) await this.seed(path, text);
const name = path.slice(path.lastIndexOf("/") + 1);
return this.page.evaluate(
async ({ path, name }) => {
+46
View File
@@ -31,6 +31,32 @@ function tsSource(lines: number, tag: string): string {
return out.join("\n");
}
/** ~`lines` lines of an Angular template: bindings, interpolations and control-flow blocks. */
function angularTemplate(lines: number, tag: string): string {
const out = [`<!-- ${tag} -->`];
for (let i = 0; out.length < lines; i++) {
out.push(
`<section class="card" [class.active]="selected() === ${i}">`,
` <h2 (click)="select(${i})">{{ title${i}() | titlecase }}</h2>`,
` @if (items${i}().length) {`,
` <ul>`,
` @for (item of items${i}(); track item.id; let last = $last) {`,
` <li [title]="item.label" (keydown.enter)="open(item)">{{ item.name }} &amp; {{ item.count }}</li>`,
` } @empty {`,
` <li>None</li>`,
` }`,
` </ul>`,
` } @else {`,
` <p>Loading</p>`,
` }`,
`</section>`,
`@let total${i} = items${i}().length + ${i};`,
``,
);
}
return out.join("\n");
}
const TYPED = 'val total = orders.sumOf { it.amount * (1 + rate) }\nif (total > limit) log("over limit")\n';
/** Typing at a brisk pace (~12 keys/s) in the small Kotlin sample. */
@@ -65,6 +91,25 @@ const typingLarge: Scenario = {
},
};
/**
* The same typing in the middle of a ~12k-line Angular template (an
* `angular.json` above it): its text, attributes and blocks are nested parses.
*/
const typingAngular: Scenario = {
id: "typingAngular",
title: "Type into a 12k-line Angular template",
async run(h) {
await h.seed(`${ROOT}/web/angular.json`, "{}");
const openMs = await h.openFile(`${ROOT}/web/src/app/big.component.html`, angularTemplate(12_000, "big"));
const st = await h.editorState();
await h.setCursor(Math.floor((st?.length ?? 0) / 2));
await h.parserIdle();
await h.page.keyboard.press("End");
await h.page.keyboard.press("Enter");
return { openMs, ...(await typeAndMeasure(h, "@if (open()) { {{ total + 1 }} items }\n", 80)) };
},
};
/** Held key: auto-repeat at ~30 Hz must never fall behind. */
const keyRepeat: Scenario = {
id: "keyRepeat",
@@ -323,6 +368,7 @@ export const SCENARIOS: Scenario[] = [
startup,
typing,
typingLarge,
typingAngular,
keyRepeat,
scroll,
tabs,
+6 -1
View File
@@ -192,7 +192,12 @@ token reference and IntelliJ / VS Code porting tables are in
`languages/<id>.toml` files use the IDE's own language format; see any file in
[`languages/`](../languages) (`id`, `name`, `extensions`, `filenames`,
`first-line`, `line-comment`, `block-comment`, `indent`, `ui-mode`,
`language-servers`, `debugger`). `language-toy` is a minimal example.
`framework-ui-modes`, `language-servers`, `debugger`). `language-toy` is a
minimal example. `[[framework-ui-modes]]` entries (`ui-mode`, `root-markers`)
replace `ui-mode` for the files of a framework's projects: the first whose
root marker is in a directory above the file, up to the project folder, applies
(see [`languages/html.toml`](../languages/html.toml), Angular templates).
Markers are matched like a language server's `root-markers`.
## 5. Installing and developing
+24
View File
@@ -14,6 +14,12 @@ importers:
'@codemirror/commands':
specifier: ^6.11.1
version: 6.11.1
'@codemirror/lang-html':
specifier: ^6.4.12
version: 6.4.12
'@codemirror/lang-javascript':
specifier: ^6.2.5
version: 6.2.5
'@codemirror/language':
specifier: ^6.13.1
version: 6.13.1
@@ -44,9 +50,15 @@ importers:
'@fontsource-variable/jetbrains-mono':
specifier: ^5.3.0
version: 5.3.0
'@lezer/common':
specifier: ^1.5.3
version: 1.5.3
'@lezer/highlight':
specifier: ^1.2.5
version: 1.2.5
'@lezer/lr':
specifier: ^1.4.11
version: 1.4.11
'@replit/codemirror-vim':
specifier: 6.4.0
version: 6.4.0(@codemirror/[email protected])(@codemirror/[email protected])(@codemirror/[email protected])(@codemirror/[email protected])(@codemirror/[email protected])
@@ -105,6 +117,9 @@ importers:
specifier: ^5.0.15
version: 5.0.15(@types/[email protected])([email protected])([email protected])
devDependencies:
'@lezer/generator':
specifier: ^1.8.1
version: 1.8.1
'@playwright/test':
specifier: ^1.64.1
version: 1.64.1
@@ -418,6 +433,10 @@ packages:
'@lezer/[email protected]':
resolution: {integrity: sha512-ZmzgvTOBdJWGIsY/ItAfN7UW8DV802oZG+jeG6E508SwEzz5YhuAajptpWPICvIyqYAD2+VjMdOS40GgGkyEOQ==}
'@lezer/[email protected]':
resolution: {integrity: sha512-7q7szXqT7EPTs6SODBl58Qoz/4OrhNvm5pW4lbu1ZDnG5e0nn7drLzeN9Nxkm8j/1iMs5QZ9j3i4SV+W4Z6SQA==}
hasBin: true
'@lezer/[email protected]':
resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==}
@@ -2688,6 +2707,11 @@ snapshots:
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.11
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.3
'@lezer/lr': 1.4.11
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.3
+8
View File
@@ -63,6 +63,14 @@ pub async fn doc_open_virtual(window: Window, state: State<'_, AppState>, uri: S
ide_host::ops::doc::doc_open_virtual(&state.host, window.label(), uri).await
}
/// The CodeMirror mode `path` gets when opened (`DocumentInfo::ui_mode`),
/// without opening it: previews of closed files.
#[tauri::command]
#[specta::specta]
pub async fn doc_ui_mode(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<Option<String>> {
ide_host::ops::doc::doc_ui_mode(&state.host, window.label(), path).await
}
/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding label.
#[tauri::command]
#[specta::specta]
+1
View File
@@ -130,6 +130,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
doc::doc_save,
doc::doc_save_all,
doc::doc_snapshot,
doc::doc_ui_mode,
doc::doc_set_encoding,
doc::doc_set_line_ending,
doc::doc_outline,
+17 -2
View File
@@ -187,6 +187,11 @@ export const commands = {
docSave: (doc: DocId) => typedError<null, IdeError>(__TAURI_INVOKE("doc_save", { doc })),
docSaveAll: () => typedError<null, IdeError>(__TAURI_INVOKE("doc_save_all")),
docSnapshot: (doc: DocId) => typedError<DocumentInfo, IdeError>(__TAURI_INVOKE("doc_snapshot", { doc })),
/**
* The CodeMirror mode `path` gets when opened (`DocumentInfo::ui_mode`),
* without opening it: previews of closed files.
*/
docUiMode: (path: string) => typedError<string | null, IdeError>(__TAURI_INVOKE("doc_ui_mode", { path })),
/** "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding label. */
docSetEncoding: (doc: DocId, encoding: string, reload: boolean) => typedError<string, IdeError>(__TAURI_INVOKE("doc_set_encoding", { doc, encoding, reload })),
docSetLineEnding: (doc: DocId, lineEnding: LineEnding) => typedError<null, IdeError>(__TAURI_INVOKE("doc_set_line_ending", { doc, lineEnding })),
@@ -2533,6 +2538,11 @@ export type DocumentInfo = {
* provides, e.g. `jdt://…`); `path` then holds the same URI.
*/
uri?: string | null,
/**
* CodeMirror mode replacing the language's own one (an Angular project's
* HTML templates).
*/
uiMode?: string | null,
};
/**
@@ -3307,8 +3317,13 @@ export type IdeEvent =
{ type: "docPatched"; doc: DocId; baseVersion: number; version: number; changes: TextChange[] } |
/** A document's dirty flag changed (saved / modified externally). */
{ type: "docDirty"; doc: DocId; dirty: boolean } |
/** An open document's language changed (Settings › File Types, Override File Type). */
{ type: "docLanguage"; doc: DocId; language: LanguageId } |
/**
* An open document's language changed (Settings › File Types, Override File
* Type), or its UI mode (a framework's root marker appeared or went).
*/
{ type: "docLanguage"; doc: DocId; language: LanguageId;
/** See `DocumentInfo::ui_mode`. */
uiMode: string | null } |
/** Full replacement of diagnostics for one file from one producer. */
{ type: "diagnostics"; path: IpcPath; source: string; items: Diagnostic[] } |
/** Progress of a background task; `fraction` is `None` when indeterminate. */
+21 -2
View File
@@ -48,12 +48,17 @@ export interface DocBackend {
highlights(doc: DocId): Promise<SyntaxHighlights>;
selectionRanges(doc: DocId, offset: number): Promise<Range[]>;
languages(): Promise<LanguageInfo[]>;
/** The `DocumentInfo.uiMode` a file would open with, without opening it (previews). */
uiModeOf?(path: string): Promise<string | null>;
onPatched(handler: (e: DocPatchedEvent) => void): () => void;
onDirty(handler: (e: { doc: DocId; dirty: boolean }) => void): () => void;
/** Files changed on disk (used to refresh nothing but closed-file state). */
onFsChanged?(handler: (e: { removed: string[] }) => void): () => void;
/** An open document's language changed (Settings › File Types, Override File Type). */
onLanguage?(handler: (e: { doc: DocId; language: string }) => void): () => void;
/**
* An open document's language changed (Settings › File Types, Override File
* Type), or its own mode (`DocumentInfo.uiMode`).
*/
onLanguage?(handler: (e: { doc: DocId; language: string; uiMode: string | null }) => void): () => void;
}
export const ipcBackend: DocBackend = {
@@ -70,6 +75,7 @@ export const ipcBackend: DocBackend = {
highlights: (doc) => call(commands.docHighlights(doc)),
selectionRanges: (doc, offset) => call(commands.docSelectionRanges(doc, offset)),
languages: () => commands.languages(),
uiModeOf: (path) => call(commands.docUiMode(path)),
onPatched: (h) => onIdeEvent("docPatched", (e) => h(e)),
onDirty: (h) => onIdeEvent("docDirty", (e) => h(e)),
onFsChanged: (h) => onIdeEvent("fsChanged", (e) => h(e)),
@@ -89,6 +95,7 @@ interface MemDoc {
lineEnding: LineEnding;
readOnly: boolean;
language: string;
uiMode?: string;
uri?: string;
}
@@ -327,7 +334,14 @@ export function createMemoryBackend(files: Record<string, string> = {}): MemoryB
readOnly: d.readOnly,
dirty: d.text !== d.saved,
...(d.uri ? { uri: d.uri } : {}),
...(d.uiMode ? { uiMode: d.uiMode } : {}),
});
// An Angular project's templates (an `angular.json` above them), like the backend's `framework-ui-modes`.
const uiModeOf = (path: string) =>
path.endsWith(".html") &&
[...store.keys()].some((f) => f.endsWith("/angular.json") && path.startsWith(f.slice(0, -"angular.json".length)))
? "Angular Template"
: null;
const get = (doc: DocId) => {
const d = docs.get(doc);
if (!d) throw new IpcError({ kind: "notFound", message: `document ${doc} is not open` });
@@ -374,6 +388,8 @@ export function createMemoryBackend(files: Record<string, string> = {}): MemoryB
readOnly: false,
language: EXT_LANG[ext] ?? "plaintext",
};
const uiMode = uiModeOf(path);
if (uiMode) d.uiMode = uiMode;
docs.set(d.id, d);
byPath.set(path, d.id);
return info(d);
@@ -511,6 +527,9 @@ export function createMemoryBackend(files: Record<string, string> = {}): MemoryB
async languages() {
return MEM_LANGUAGES;
},
async uiModeOf(path) {
return uiModeOf(path);
},
onPatched(h) {
patched.add(h);
return () => patched.delete(h);
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <[email protected]> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+121
View File
@@ -0,0 +1,121 @@
// Angular templates: the text and attributes of HTML parsed by `@lezer/html`.
// From @codemirror/lang-angular 0.1.4 (MIT, see LICENSE). Changed by OpenIDEA:
// control-flow blocks (`@if`, `@for`, `@switch`, `@defer`, ...) and `@let`
// declarations in text, key and animation event names (`(keydown.enter)`,
// `(@fade.done)`) and animation bindings (`[@fade]`). Regenerate with
// `pnpm gen:angular-grammar`.
@top Content {
(Text | Interpolation | Entity | InvalidEntity | Block | Brace | LetDeclaration)+
}
@skip { space } {
@top Attribute {
(BoundAttributeName { "[" bindingName "]" | "[" "(" bindingName ")" "]" } |
ReferenceName { "#" Identifier })
(Is ExpressionAttributeValue[isolate] { scriptAttributeValue })? |
(EventName { "(" bindingName ")" } | DirectiveName { "*" Identifier })
(Is StatementAttributeValue[isolate] { scriptAttributeValue })? |
AttributeName { Identifier } (Is AttributeValue)?
}
// `class.active`, `keydown.enter`, and animations' `@fade`, `@fade.done`.
bindingName { "@"? Identifier ("." Identifier)* }
// The header of a block, up to its `{`: its body and `}` are content and
// HTML elements around it. Both braces are a `Brace`, which CodeMirror
// matches like plain text.
Block {
"@if" BlockParameters<Expression (";" kw<"as"> VariableDefinition)?> Brace |
"@else" (kw<"if"> BlockParameters<Expression (";" kw<"as"> VariableDefinition)?>)? Brace |
"@for" BlockParameters<VariableDefinition kw<"of"> Expression (";" forOption?)*> Brace |
("@switch" | "@case") BlockParameters<Expression> Brace |
("@default" | "@empty" | "@error") Brace |
"@defer" BlockParameters<(deferOption (";" deferOption?)*)?>? Brace |
("@placeholder" | "@loading") BlockParameters<(timingOption (";" timingOption?)*)?>? Brace
}
LetDeclaration {
"@let" VariableDefinition Is Expression ";"
}
forOption {
kw<"track"> Expression |
kw<"let"> ForAlias ("," ForAlias)*
}
ForAlias { VariableDefinition Is VariableName }
deferOption {
(kw<"prefetch"> | kw<"hydrate">)?
(kw<"on"> DeferTrigger ("," DeferTrigger)* | kw<"when"> Expression | kw<"never">)
}
DeferTrigger { TriggerName ("(" (Time | Expression)? ")")? }
timingOption { (kw<"minimum"> | kw<"after">) Time }
BlockParameters<content> { "(" content ")" }
VariableDefinition { jsIdentifier }
VariableName { jsIdentifier }
TriggerName { jsIdentifier }
kw<term> { @specialize[@name={term}]<jsIdentifier, term> }
}
AttributeValue[isolate] {
'"' (attributeContentDouble | Entity | InvalidEntity | Interpolation)* '"' |
"'" (attributeContentSingle | Entity | InvalidEntity | Interpolation)* "'"
}
scriptAttributeValue {
'"' AttributeInterpolation { scriptAttributeContentDouble } '"' |
"'" AttributeInterpolation { scriptAttributeContentSingle } "'"
}
Interpolation[isolate] {
"{{" InterpolationContent? "}}"
}
@external tokens text from "./tokens" { Text }
@external tokens brace from "./tokens" { Brace }
@external tokens time from "./tokens" { Time }
@external tokens expression from "./tokens" { Expression }
@external tokens attrSingle from "./tokens" { attributeContentSingle }
@external tokens attrDouble from "./tokens" { attributeContentDouble }
@external tokens scriptAttrSingle from "./tokens" { scriptAttributeContentSingle }
@external tokens scriptAttrDouble from "./tokens" { scriptAttributeContentDouble }
@tokens {
Entity { "&" ![; ]+ ";" }
InvalidEntity { "&" }
InterpolationContent { (![}] | "}" ![}]) InterpolationContent? }
Identifier { (":" | "-" | @asciiLetter | @digit | "_" | $[À-\u{EFFFF}])+ }
jsIdentifier {
(@asciiLetter | "_" | "$" | $[À-\u{EFFFF}]) (@asciiLetter | @digit | "_" | "$" | $[À-\u{EFFFF}])*
}
space { (" " | "\t" | "\r" | "\n")+ }
Is { "=" }
"{{" "}}" "[" "]" "(" ")"
"*" "#" "@" ";" ","
"@if" "@else" "@for" "@empty" "@switch" "@case" "@default"
"@defer" "@placeholder" "@loading" "@error" "@let"
}
@detectDelim
+365
View File
@@ -0,0 +1,365 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { EditorState } from "@codemirror/state";
import { ensureSyntaxTree, matchBrackets } from "@codemirror/language";
import { NodeProp, type Tree } from "@lezer/common";
import { buildParserFile } from "@lezer/generator";
import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight";
import { findLanguageDescription, loadSupport } from "../languages";
import { angular, angularLanguage } from "./index";
const highlighter = tagHighlighter([
{ tag: t.controlKeyword, class: "control" },
{ tag: t.keyword, class: "keyword" },
{ tag: t.definitionKeyword, class: "let" },
{ tag: t.definition(t.variableName), class: "def" },
{ tag: t.function(t.variableName), class: "call" },
{ tag: t.variableName, class: "var" },
{ tag: t.propertyName, class: "prop" },
{ tag: t.special(t.attributeName), class: "event" },
{ tag: t.attributeName, class: "attr" },
{ tag: t.attributeValue, class: "value" },
{ tag: t.operator, class: "op" },
{ tag: t.number, class: "num" },
{ tag: t.string, class: "str" },
{ tag: t.character, class: "entity" },
{ tag: t.brace, class: "brace" },
{ tag: t.tagName, class: "tag" },
{ tag: t.comment, class: "comment" },
]);
/** `text:class` of the styled tokens of `doc`, from `tree` or a fresh parse. */
function tokens(doc: string, tree: Tree = angularLanguage.parser.parse(doc)): string[] {
const out: string[] = [];
highlightTree(tree, highlighter, (from, to, cls) => out.push(`${doc.slice(from, to)}:${cls}`));
return out;
}
/** The tree of `state`, parsed to the end. */
function stateTree(state: EditorState): Tree {
const tree = ensureSyntaxTree(state, state.doc.length, 5000);
if (!tree) throw new Error("parse did not finish");
return tree;
}
/** A ~6 KB template starting with an element, with blocks and `@let` at the top level. */
function template(): string {
let doc = "";
for (let i = 0; i < 40; i++) {
doc +=
`<section>\n <h2>{{ title${i} }}</h2>\n</section>\n` +
`@if (show${i}) {\n <p [title]="t${i}">a &amp; b {{ v${i} }}</p>\n} @else {\n none\n}\n` +
`@let n${i} = count${i} + 1;\n`;
}
return doc;
}
describe("Angular templates", () => {
it("highlights bindings and interpolations as expressions", () => {
expect(tokens(`<input [id]="label() ? inputId() : undefined" (click)="save($event)">{{ user.name }}`)).toEqual([
"input:tag",
"id:attr",
"=:op",
'":value',
"label:call",
"?:op",
"inputId:call",
"::op",
"undefined:var",
'":value',
"click:event",
"=:op",
'":value',
"save:call",
"$event:var",
'":value',
"{{:brace",
"user:var",
".:op",
"name:prop",
"}}:brace",
]);
});
it("highlights @if and @else blocks, across entities and elements", () => {
expect(tokens(`<div>@if (user() && ok; as u) {<b>&amp;</b>} @else if (x) {} @else {}</div>`)).toEqual([
"div:tag",
"@if:control",
"user:call",
"&&:op",
"ok:var",
"as:keyword",
"u:def",
"{:brace",
"b:tag",
"&amp;:entity",
"b:tag",
"}:brace",
"@else:control",
"if:control",
"x:var",
"{:brace",
"}:brace",
"@else:control",
"{:brace",
"}:brace",
"div:tag",
]);
});
it("highlights @for, @switch and @let", () => {
expect(tokens(`@for (item of items(); track item.id; let i = $index) {} @empty {}`)).toEqual([
"@for:control",
"item:def",
"of:keyword",
"items:call",
"track:keyword",
"item:var",
".:op",
"id:prop",
"let:let",
"i:def",
"=:op",
"$index:var",
"{:brace",
"}:brace",
"@empty:control",
"{:brace",
"}:brace",
]);
expect(tokens(`@switch (mode) { @case ('a') {} @default {} }`)).toEqual([
"@switch:control",
"mode:var",
"{:brace",
"@case:control",
"'a':str",
"{:brace",
"}:brace",
"@default:control",
"{:brace",
"}:brace",
"}:brace",
]);
expect(tokens(`@let total = price() * 2;`)).toEqual([
"@let:let",
"total:def",
"=:op",
"price:call",
"*:op",
"2:num",
]);
});
it("highlights @defer triggers and timings", () => {
expect(
tokens(`@defer (on viewport(el), timer(5s); prefetch when ready) {} @placeholder (minimum 500ms) {}`),
).toEqual([
"@defer:keyword",
"on:keyword",
"viewport:keyword",
"el:var",
"timer:keyword",
"5s:num",
"prefetch:keyword",
"when:keyword",
"ready:var",
"{:brace",
"}:brace",
"@placeholder:keyword",
"minimum:keyword",
"500ms:num",
"{:brace",
"}:brace",
]);
});
it("keeps an @ of text as text", () => {
expect(tokens(`<p>mail [email protected], use @for loops, @if</p>`)).toEqual(["p:tag", "p:tag"]);
});
it("parses `{{{` in an ICU case as a brace and an interpolation", () => {
expect(tokens(`{n, plural, other {{{n}} items}}`)).toEqual([
"{:brace",
"{:brace",
"{{:brace",
"n:var",
"}}:brace",
"}:brace",
"}:brace",
]);
});
it("matches a block's braces around its elements", () => {
const doc = `@if (a) {\n <p>{{ a }}</p>\n}`;
const state = EditorState.create({ doc, extensions: angular() });
ensureSyntaxTree(state, doc.length, 5000);
expect(matchBrackets(state, doc.indexOf("{"), 1)).toMatchObject({
matched: true,
end: { from: doc.lastIndexOf("}") },
});
});
it("highlights key and animation binding names", () => {
const doc = `<input (keydown.enter)="save()" (window:resize)="fit()" [@fade]="s" (@fade.done)="end()">`;
expect(tokens(doc)).toEqual([
"input:tag",
"keydown:event",
"enter:event",
"=:op",
'":value',
"save:call",
'":value',
"window:resize:event",
"=:op",
'":value',
"fit:call",
'":value',
"@:attr",
"fade:attr",
"=:op",
'":value',
"s:var",
'":value',
"@:event",
"fade:event",
"done:event",
"=:op",
'":value',
"end:call",
'":value',
]);
});
it("keeps HTML's script, style and event handler attribute languages", () => {
expect(tokens(`<script>let a = 1;</script><style>p { color: red }</style><b onclick="go(1)">x</b>`)).toEqual([
"script:tag",
"let:let",
"a:def",
"=:op",
"1:num",
"script:tag",
"style:tag",
"p:tag",
"{:brace",
"color:prop",
"red:keyword",
"}:brace",
"style:tag",
"b:tag",
"onclick:attr",
"=:op",
'":value',
"go:call",
"1:num",
'":value',
"b:tag",
]);
});
it("parses the text directly in an element, not its attribute values", () => {
const doc = `<p title="a &amp; b">x &lt; {{ y }}</p>`;
const p = angularLanguage.parser.parse(doc).topNode.getChild("Element")!;
const overlay = p.tree?.prop(NodeProp.mounted)?.overlay?.map((r) => doc.slice(p.from + r.from, p.from + r.to));
expect(overlay).toEqual(["x &lt; {{ y }}"]);
});
it("parses headers interrupted by comments and elements", () => {
const cases: [string, string[]][] = [
[`<div>@if (a <!-- c -->) {}</div>`, ["div:tag", "@if:control", "a:var", "<!-- c -->:comment", "{:brace"]],
[`<div>@for (x of xs; track x <!--c--> ) {}</div>`, ["div:tag", "@for:control", "x:def", "of:keyword"]],
[`@let x = foo\n<p>a</p>\n`, ["@let:let", "x:def", "=:op", "foo:var", "p:tag", "p:tag"]],
[`<div>\n @if (u\n <p>x</p>\n</div>`, ["div:tag", "@if:control", "u:var", "p:tag", "p:tag", "div:tag"]],
[
`<div>@if (a && <!-- c --> b && c) {}</div>`,
["div:tag", "@if:control", "a:var", "&&:op", "<!-- c -->:comment"],
],
];
for (const [doc, start] of cases) {
const state = EditorState.create({ doc, extensions: angular() });
expect(tokens(doc, stateTree(state)).slice(0, start.length)).toEqual(start);
const edited = state.update({ changes: { from: doc.indexOf("@") + 1, insert: " " } }).state;
expect(() => stateTree(edited)).not.toThrow();
}
});
it("ends an unterminated header before the blocks on the next lines", () => {
expect(tokens(`@if (a(\n@for (x of xs; track x) {}`)).toEqual([
"@if:control",
"a:call",
"@for:control",
"x:def",
"of:keyword",
"xs:var",
"track:keyword",
"x:var",
"{:brace",
"}:brace",
]);
expect(tokens(`@let x = foo\n @if (a) {}`)).toEqual([
"@let:let",
"x:def",
"=:op",
"foo:var",
"@if:control",
"a:var",
"{:brace",
"}:brace",
]);
expect(tokens(`@if (a &&\n b) {}`)).toEqual(["@if:control", "a:var", "&&:op", "b:var", "{:brace", "}:brace"]);
});
it("ends an unfinished interpolation with its text", () => {
expect(tokens(`{{ total\n<p>x</p>\nshipping and tax\n`)).toEqual(["{{:brace", "total:var", "p:tag", "p:tag"]);
expect(tokens(`<ul>{{ a <li>x</li> plain words</ul>`)).toEqual([
"ul:tag",
"{{:brace",
"a:var",
"li:tag",
"li:tag",
"ul:tag",
]);
});
it("highlights like a fresh parse after resuming and editing", () => {
const doc = template();
// Opening parses the first 3000 characters; the rest resumes from that tree.
let state = EditorState.create({ doc, extensions: angular() });
expect(tokens(doc, stateTree(state))).toEqual(tokens(doc));
const at = (text: string, n = 1) => {
let pos = -1;
for (let i = 0; i < n; i++) pos = state.doc.toString().indexOf(text, pos + 1);
return pos;
};
const edits = [
() => ({ from: at("{{ title12") + 9, insert: "x" }),
() => ({ from: at("show15") + 6, insert: " && ok" }),
() => ({ from: at("</section>", 20), insert: "\n@for (i of is; track i) { <b>{{ i }}</b> }\n" }),
() => ({ from: at("none", 3), to: at("none", 3) + 4 }),
() => ({ from: at("count7"), to: at("count7") + 6, insert: "total()" }),
() => ({ from: 0, insert: "@if (top) {}\n" }),
() => ({ from: at("<p [title]", 10), insert: "@let z = 2;" }),
];
for (const edit of edits) {
state = state.update({ changes: edit() }).state;
const text = state.doc.toString();
expect(tokens(text, stateTree(state))).toEqual(tokens(text));
}
});
it("is generated from angular.grammar", () => {
const dir = import.meta.dirname;
const { parser, terms } = buildParserFile(readFileSync(resolve(dir, "angular.grammar"), "utf8"), {
fileName: "src/editor/core/cm/angular/angular.grammar",
moduleStyle: "es",
typeScript: true,
});
expect(parser).toBe(readFileSync(resolve(dir, "parser.ts"), "utf8"));
expect(terms).toBe(readFileSync(resolve(dir, "parser.terms.ts"), "utf8"));
});
it("is the Angular Template mode", async () => {
const desc = findLanguageDescription("Angular Template", "/p/app.html");
expect((await loadSupport(desc!))?.language).toBe(angularLanguage);
});
});
+135
View File
@@ -0,0 +1,135 @@
/**
* Angular template language: HTML whose text and binding attributes are
* Angular syntax (`angular.grammar`), expressions parsed as JavaScript. From
* @codemirror/lang-angular 0.1.4 (MIT, see LICENSE); changed by OpenIDEA:
* control-flow blocks, `@let`, key and animation binding names, and runs of
* text that entities split (`a && b`) parsed as one.
*/
import { LRLanguage, LanguageSupport } from "@codemirror/language";
import { html } from "@codemirror/lang-html";
import { javascriptLanguage } from "@codemirror/lang-javascript";
import { styleTags, tags as t } from "@lezer/highlight";
import { IterMode, parseMixed, type Input, type NestedParse, type SyntaxNode, type SyntaxNodeRef } from "@lezer/common";
import { parser } from "./parser";
const exprParser = javascriptLanguage.parser.configure({ top: "SingleExpression" });
const baseParser = parser.configure({
props: [
styleTags({
Text: t.content,
Is: t.definitionOperator,
AttributeName: t.attributeName,
"AttributeValue ExpressionAttributeValue StatementAttributeValue": t.attributeValue,
Entity: t.character,
InvalidEntity: t.invalid,
"BoundAttributeName/Identifier BoundAttributeName/@": t.attributeName,
"EventName/Identifier EventName/@": t.special(t.attributeName),
"ReferenceName/Identifier": t.variableName,
"DirectiveName/Identifier": t.keyword,
"{{ }}": t.special(t.brace),
"( )": t.paren,
"[ ]": t.bracket,
"# '*'": t.punctuation,
"@if @else @for @empty @switch @case @default if": t.controlKeyword,
"@defer @placeholder @loading @error of as track on when prefetch hydrate never minimum after TriggerName":
t.keyword,
"@let let": t.definitionKeyword,
Brace: t.brace,
"; ,": t.separator,
VariableDefinition: t.definition(t.variableName),
VariableName: t.variableName,
Time: t.number,
}),
],
});
const exprMixed = { parser: exprParser },
statementMixed = { parser: javascriptLanguage.parser };
const textParser = baseParser.configure({
wrap: parseMixed((node) => (node.name == "InterpolationContent" || node.name == "Expression" ? exprMixed : null)),
});
// To reuse old inner trees, parseMixed takes those that the node at a mount's
// start and its ancestors had with the same parser. So each kind of mount has
// its own instance: the document's runs would take those of an element at its
// start, and a text those of its element's runs.
const runsParser = textParser.configure({}),
documentRunsParser = textParser.configure({});
const attrParser = baseParser.configure({
wrap: parseMixed((node) =>
node.name == "InterpolationContent"
? exprMixed
: node.name != "AttributeInterpolation"
? null
: node.node.parent?.name == "StatementAttributeValue"
? statementMixed
: exprMixed,
),
top: "Attribute",
});
const textMixed = { parser: textParser },
attrMixed = { parser: attrParser };
// Text is parsed per node, like upstream, so an unfinished `{{` or `@if (`
// ends with it. But HTML splits text at each `&` (`@if (a && b) {`), so a run
// of text and entities is parsed as one: an overlay of the element (or the
// document) holding it.
const TEXT_NODES = new Set(["Text", "EntityReference", "CharacterReference", "InvalidEntity"]);
/** The runs of text directly in `node` that entities split, `null` without any. */
function entityRuns(node: SyntaxNode): { from: number; to: number }[] | null {
const runs: { from: number; to: number }[] = [];
let from = -1,
to = -1,
parts = 0;
const child = node.cursor(IterMode.IgnoreMounts);
if (child.firstChild()) {
do {
if (!TEXT_NODES.has(child.name)) continue;
if (child.from == to) {
to = child.to;
parts++;
continue;
}
if (parts > 1) runs.push({ from, to });
({ from, to } = child);
parts = 1;
} while (child.nextSibling());
}
if (parts > 1) runs.push({ from, to });
return runs.length ? runs : null;
}
const baseHTML = html({ selfClosingTags: true });
function mixAngular(node: SyntaxNodeRef, input: Input): NestedParse | null {
switch (node.name) {
case "Attribute":
return /^[*#([]|\{\{/.test(input.read(node.from, node.to)) ? attrMixed : null;
// Text in a run is covered by its parent's overlay, so not asked about.
case "Text":
return textMixed;
case "Document":
case "Element": {
const overlay = entityRuns(node.node);
return overlay && { parser: node.name == "Document" ? documentRunsParser : runsParser, overlay };
}
}
return null;
}
export const angularLanguage = (baseHTML.language as LRLanguage).configure({ wrap: parseMixed(mixAngular) }, "angular");
export function angular(): LanguageSupport {
return new LanguageSupport(angularLanguage, [
baseHTML.support,
baseHTML.language.data.of({
closeBrackets: { brackets: ["[", "{", '"'] },
indentOnInput: /^\s*[}\]]$/,
}),
]);
}
@@ -0,0 +1,38 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
Text = 1,
Brace = 2,
Time = 3,
Expression = 4,
attributeContentSingle = 80,
attributeContentDouble = 81,
scriptAttributeContentSingle = 82,
scriptAttributeContentDouble = 83,
Content = 5,
Interpolation = 8,
InterpolationContent = 9,
Entity = 10,
InvalidEntity = 11,
Block = 12,
as = 18,
VariableDefinition = 19,
_if = 21,
of = 24,
track = 25,
_let = 26,
ForAlias = 27,
Is = 28,
VariableName = 29,
prefetch = 39,
hydrate = 40,
on = 41,
DeferTrigger = 42,
TriggerName = 43,
when = 44,
never = 45,
minimum = 49,
after = 50,
LetDeclaration = 51,
Attribute = 53,
Identifier = 57,
AttributeValue = 69
+24
View File
@@ -0,0 +1,24 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {text, brace, time, expression, attrSingle, attrDouble, scriptAttrSingle, scriptAttrDouble} from "./tokens"
const spec_jsIdentifier = {__proto__:null,as:36, if:42, of:48, track:50, let:52, prefetch:78, hydrate:80, on:82, when:88, never:90, minimum:98, after:100}
export const parser = LRParser.deserialize({
version: 14,
states: "4jOVO&mOOO!^Q&jOOO!oO,UO'#CdO!wQ&jO'#ChO!|Q7^O'#ChO#UQ&jO'#ChO#ZQ&jO'#ChO#`Q&lO'#ChO#eQ&lO'#ChO#mQ&lO'#ChO#uQ7[O'#DaOOOR'#Dt'#DtQVO&mOOO#zQMhO'#DdO$VQ&jO'#DiO#}QMhO'#DnO$[Q&jO'#DoOOQO'#Dr'#DrQ$aQ&jOOQ$fQ&jOOQ$kQ&jOOOOO!T,59O,59OO$pO&jO,59OO$uQ&rO'#ClO$zQ&lO,59SOOOR,59S,59SO!wQ&jO,59SO#uQ7[O'#CsO%PQ&rO'#C}O%gQ7[O'#DSO%nQ7[O'#D^OOQO'#Co'#CoO%yQ&jO,59{OOOR-E7r-E7rO&OQ&jO'#EYO&ZQ&jO'#EYO&`Q&jO,5:OO#}QMhO,5:OOOQO,5:T,5:TO&eQ&jO,5:YOOQO,5:Z,5:ZO&jQ&jOOO&jQ&jOOO&rQ&jOOOOO!T1G.j1G.jO&zQ&jO,59WOOOR1G.n1G.nO'SQ&lO1G.nO'XQ7[O,59_O'^Q&jO,59iOOQQ,59n,59nO'cQ&jO,59nO'kQ7[O'#EWO'pQ&rO'#EWOOQO'#EW'#EWO'uQ7[O'#EWOOQQ,59x,59xO(QQ&jO,59xO(YQ&nO'#EXO(_Q&rO1G/gO(dQ&jO'#DzO(iQ&jO,5:tO(iQ&jO,5:tOOQO1G/j1G/jO(tQ&jO1G/jOOQO1G/t1G/tO(yO#tO'#E[O)OO!bO'#E[OOQO'#Dk'#DkQOQ&jOOOOQO'#Dq'#DqO)TO'[O'#DsO)fO&zO'#DsOOQQ1G.r1G.rO)wQ7[O1G.rOOOR7+$Y7+$YO)|Q&rO1G.yOOQQ1G/T1G/TO*RQ7[O'#DxO*]Q&jO1G/YOOQQ1G/Y1G/YOOQO'#DX'#DXO*eQ&jO'#DWO*sQ&jO,5:rOOQO,5:r,5:rO'kQ7[O,5:rO+OQ&rO,5:rO+TQ7[O'#DyO+cQ&jO1G/dOOQQ1G/d1G/dOOQO,5:s,5:sO+kQ&jO7+%ROOQO,5:f,5:fOOQO-E7x-E7xO+pQ&jO1G0`O+{Q&jO7+%UOOOO'#Dl'#DlO,QO&jO,5:vOOOO'#Dm'#DmO,VO&jO,5:vOOOp'#D{'#D{O,[O'[O,5:_OOQO,5:_,5:_OOO`'#D|'#D|O,mO&zO,5:_O#uQ7[O7+$^O-OQ&jO7+$eOOQO,5:d,5:dOOQO-E7v-E7vOOQQ7+$t7+$tO-WQ&vO,59rO'kQ7[O'#DwO-cQ&jO1G0^O-cQ&jO1G0^OOQO1G0^1G0^OOQO,5:e,5:eOOQO-E7w-E7wOOQQ7+%O7+%OOOOR<<Hm<<HmOOQO<<Hp<<HpOOQO1G0b1G0bOOOp-E7y-E7yOOQO1G/y1G/yOOO`-E7z-E7zO-nQ&jO<<GxO-sQ7[O'#DuO.RQ&jO<<HPOOQQ<<HP<<HPOOQO1G/^1G/^O.ZQ&jO1G/^OOQO,5:c,5:cOOQO-E7u-E7uO.`Q&jO7+%xOOQQAN=dAN=dOOQO,5:a,5:aO.kQ&rO'#EVO#uQ7[O'#EVOOQO-E7s-E7sOOQQAN=kAN=kOOQO7+$x7+$xOOQO,5:q,5:qO.pQ&jO'#CwO.uQ&jO,5:qO/QQ7[O,59cO#uQ7[O'#DvO/VQ&jO1G0]OOQO'#Cy'#CyOOQO1G.}1G.}OOQO,5:b,5:bOOQO-E7t-E7t",
stateData: "/b~O!wOS~OPZOQZOVQOYZOZZO]ROdSOfTOoUOpUOrVOsVOtVOuWO!OXO!PXO!UYO~O__O!X]O!ZaO!^^O!d`O~OUeOXfO~O_gO~OQiOejO~O_kO~O_lO~OQiO~OQiO_mO~OQiO_nO~O!xoO~O_uO!YsO!ZrO~O!ZvO~O!ZxO~OlyO~OlzO~Ol{O~OU|O~OS}O~OQ!OO~OS!RO~Ow!XOx!XOy!UO|!VO}!WO~O^!SO~P%UO^!YO!R![O!S![O~Ol!]O~O!}!^O![!|X^!|X~O!Z!`O~O![!aO~O^!cO~O#P!dO#Q!eO~O#P!iO#Q!jO~O^!kOa!lO~OQ!mO~Oh!nO~O^!oO~O^!rOa!pO~O!x!sO~OS!vO~Oy!wO|!xO}!vO~O^!{Oa!yO~OR!|O~OS!}O~O!Z#OO~O!}!^O![!|a^!|a~O^#RO~O!u#SO~O!t#UO~OVQOY#WOZ#WO!s#WO#P#YO~OVQOY#ZOZ#ZO!r#ZO#Q#YO~Ob#]O~OS#^O~O^!lXa!lX~P%UO^#aOa!pO~O_#bO^zXazXnzX~On#cO^!zaa!za~OS#fO~O!R![O!S![O^!mXa!mX~O^#iOa!yO~Oa#jO~O!}!^O![!|i^!|i~O![#kO~O#P#lO~O#Q#lO~OVQOY#WOZ#WO!s#WO#P#nO~OVQOY#ZOZ#ZO!r#ZO#Q#nO~O^#sOa#qO~OR#uOS#uO^#tO~On#cO^!zia!zi~O^#yO~Oi#{Oj#|O^!iXa!iX~O^$OOa#qO~O^$PO~On#cO^!zqa!zq~OS$QO~Ol$TO~On$UO^!yaa!ya~O!x$WO~On$UO^!yia!yi~O",
goto: "'S#PPPPPPPPP#QPPP#^PPP#bPP#hPPP#uPPP#xP$OPPP$RPPPP$UPPP$X$bPPPP$gPP#^PP$jPPPP$jP$m$p$s$v$vP$y$|%P%S%Y%`%f%p%v%|&W&^PPPPPPPP&d&g&m&sP&|SZO[S#W!i#XT#Z!j#[TZO[QhRR!PjQpYQ!QkQ#p#]T$R#|$URhTQ$S#|R$Y$UR$X$TRhURhWQ!u!UQ#e!wR#v#cV!t!U!w#cRhXRbPR!gyR#T!dR#V!eRcPR!gzRdPR!g{Q[ORq[Q#r#^R#}#rQ$V$SR$Z$VQ#d!uS#w#d#xR#x#eQ!q!TR#`!qQ!z!ZR#h!zQ!_rS#P!_#QR#Q!`Q#X!iR#m#XQ#[!jR#o#[R#z#qQ!TmR#_!pQ!ZnR#g!yQt]Qw_R!buQ!fyR!hz",
nodeNames: "⚠ Text Brace Time Expression Content }} {{ Interpolation InterpolationContent Entity InvalidEntity Block @if ) ( BlockParameters ; as VariableDefinition @else if @for BlockParameters of track let ForAlias Is VariableName , @switch @case BlockParameters @default @empty @error @defer BlockParameters prefetch hydrate on DeferTrigger TriggerName when never @placeholder @loading BlockParameters minimum after LetDeclaration @let Attribute BoundAttributeName [ @ Identifier ] ReferenceName # ExpressionAttributeValue AttributeInterpolation AttributeInterpolation EventName DirectiveName * StatementAttributeValue AttributeName AttributeValue",
maxTerm: 94,
nodeProps: [
["openedBy", 6,"{{",14,"("],
["closedBy", 7,"}}",15,")"],
["isolate", -4,8,61,67,69,""]
],
skippedNodes: [0],
repeatNodeCount: 9,
tokenData: "!'a~R}OX$OXY$yYZ$yZ]$O]^$y^p$Opq$yqr$Ors%vst&^tu&tuv$Ovw(Wwx+jxy,Qyz,hz{-O{|$O|}-f}!O-|!O!P/c!P!Q$O!Q![-|![!]-|!]!^/y!^!_$O!_!`0a!`!b$O!b!c0w!c!}!#Z!}#O!$x#O#P$O#P#Q!%`#Q#R$O#R#S!#Z#S#T$O#T#o!#Z#o#p!%v#p#q$O#q#r!&x#r%W$O%W;'S!#Z;'S;:j!$r;:j;=`$s<%lO!#ZQ$TTXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OQ$gSO#q$O#r;'S$O;'S;=`$s<%lO$OQ$vP;=`<%l$O_%Q[XQ!w]OX$OXY$yYZ$yZ]$O]^$y^p$Opq$yq#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR%}T#PPXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR&eT!^PXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OU&{aXQ!xSOt$Otu&tu!Q$O!Q![&t![!c$O!c!}&t!}#R$O#R#S&t#S#T$O#T#o&t#o#q$O#q#r$d#r%W$O%W;'S&t;'S;:j(Q;:j;=`$s<%lO&tU(TP;=`<%l&tR(_XXQZPOp(zpq$Oq!](z!]!^$O!^#q(z#q#r*S#r;'S(z;'S;=`+d<%lO(zR)PXXQOp(zpq$Oq!](z!]!^)l!^#q(z#q#r*S#r;'S(z;'S;=`+d<%lO(zR)sTYPXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR*VXOp(zpq$Oq!](z!]!^)l!^#q(z#q#r*r#r;'S(z;'S;=`+d<%lO(zP*uUOp*rq!]*r!]!^+X!^;'S*r;'S;=`+^<%lO*rP+^OYPP+aP;=`<%l*rR+gP;=`<%l(zR+qT#QPXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OZ,XT_XXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OV,oT^TXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR-VT!dPXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR-mTnPXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OZ.Tb!ZXXQO}$O}!O-|!O!Q$O!Q![-|![!]-|!]!c$O!c!}-|!}#R$O#R#S-|#S#T$O#T#o-|#o#q$O#q#r$d#r%W$O%W;'S-|;'S;:j/];:j;=`$s<%lO-|Z/`P;=`<%l-|R/jT!}PXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OV0QTaTXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR0hTXQlPO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OZ1Ob!YWXQO#V$O#V#W2W#W#X4`#X#Y9b#Y#Z@T#Z#]$O#]#^Aq#^#`$O#`#aBs#a#d$O#d#eGu#e#g$O#g#hM{#h#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR2]VXQO#T$O#T#U2r#U#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR2wVXQO#g$O#g#h3^#h#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR3cVXQO#X$O#X#Y3x#Y#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR4PTpPXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR4eVXQO#X$O#X#Y4z#Y#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR5PVXQO#Y$O#Y#Z5f#Z#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR5kXXQO#T$O#T#U6W#U#X$O#X#Y8`#Y#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR6]VXQO#i$O#i#j6r#j#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR6wVXQO#`$O#`#a7^#a#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR7cVXQO#h$O#h#i7x#i#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR8PTrPXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR8eVXQO#f$O#f#g8z#g#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR9RTuPXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR9gYXQO#`$O#`#a:V#a#b;s#b#f$O#f#g={#g#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR:[VXQO#g$O#g#h:q#h#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR:vVXQO#X$O#X#Y;]#Y#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR;dTdPXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR;xVXQO#d$O#d#e<_#e#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR<dVXQO#h$O#h#i<y#i#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR=OVXQO#m$O#m#n=e#n#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR=lTsPXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR>QVXQO#f$O#f#g>g#g#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR>lVXQO#c$O#c#d?R#d#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR?WVXQO#f$O#f#g?m#g#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR?tTtPXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR@YVXQO#c$O#c#d@o#d#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR@tVXQO#f$O#f#gAZ#g#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORAbTfPXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORAvVXQO#Y$O#Y#ZB]#Z#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORBdT]PXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORBxXXQO#X$O#X#YCe#Y#c$O#c#dDg#d#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORCjVXQO#h$O#h#iDP#i#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORDWT!UPXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORDlVXQO#T$O#T#UER#U#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OREWVXQO#W$O#W#XEm#X#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORErVXQO#]$O#]#^FX#^#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORF^VXQO#b$O#b#cFs#c#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORFxVXQO#Z$O#Z#[G_#[#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORGfT!PPXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORGzVXQO#`$O#`#aHa#a#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORHfVXQO#T$O#T#UH{#U#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORIQVXQO#V$O#V#WIg#W#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORIlVXQO#X$O#X#YJR#Y#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORJWVXQO#[$O#[#]Jm#]#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORJrVXQO#c$O#c#dKX#d#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORK^VXQO#`$O#`#aKs#a#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORKxVXQO#W$O#W#XL_#X#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORLdVXQO#X$O#X#YLy#Y#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORMOVXQO#f$O#f#gMe#g#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORMlT!OPXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORNQVXQO#k$O#k#lNg#l#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$ORNlVXQO#]$O#]#^! R#^#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR! WVXQO#h$O#h#i! m#i#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR! rVXQO#V$O#V#W!!X#W#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR!!^VXQO#[$O#[#]!!s#]#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR!!zToPXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$O_!#dd!ZXXQ!xSOt$Otu&tu}$O}!O-|!O!Q$O!Q![!#Z![!]-|!]!c$O!c!}!#Z!}#R$O#R#S!#Z#S#T$O#T#o!#Z#o#q$O#q#r$d#r%W$O%W;'S!#Z;'S;:j!$r;:j;=`$s<%lO!#Z_!$uP;=`<%l!#ZR!%PT!XPXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR!%gT![PXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR!%{VXQO#o$O#o#p!&b#p#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$OR!&iTVPXQO#q$O#q#r$d#r;'S$O;'S;=`$s<%lO$O~!&{TO#q$O#q#r!'[#r;'S$O;'S;=`$s<%lO$O~!'aOU~",
tokenizers: [text, brace, time, expression, attrSingle, attrDouble, scriptAttrSingle, scriptAttrDouble, 0, 1, 2, 3],
topRules: {"Content":[0,5],"Attribute":[1,53]},
specialized: [{term: 86, get: (value: keyof typeof spec_jsIdentifier) => spec_jsIdentifier[value] || -1}],
tokenPrec: 0
})
+188
View File
@@ -0,0 +1,188 @@
/**
* External tokens of `angular.grammar`. From @codemirror/lang-angular 0.1.4
* (MIT, see LICENSE); text stopping at blocks, and the block tokens, added by
* OpenIDEA.
*/
import { ExternalTokenizer, type InputStream } from "@lezer/lr";
import {
Brace,
Expression,
Text,
Time,
attributeContentDouble,
attributeContentSingle,
scriptAttributeContentDouble,
scriptAttributeContentSingle,
} from "./parser.terms";
const NEWLINE = 10,
DOUBLE_QUOTE = 34,
DOLLAR = 36,
AMPERSAND = 38,
SINGLE_QUOTE = 39,
PAREN_L = 40,
PAREN_R = 41,
DOT = 46,
SEMICOLON = 59,
AT = 64,
BRACKET_L = 91,
BACKSLASH = 92,
BRACKET_R = 93,
UNDERSCORE = 95,
BACKTICK = 96,
BRACE_L = 123,
BRACE_R = 125;
const BLOCKS = new Set([
"if",
"else",
"for",
"empty",
"switch",
"case",
"default",
"defer",
"placeholder",
"loading",
"error",
"let",
]);
const isLetter = (ch: number) => (ch >= 65 && ch <= 90) || (ch >= 97 && ch <= 122);
const isDigit = (ch: number) => ch >= 48 && ch <= 57;
const isIdentifierStart = (ch: number) => isLetter(ch) || ch == UNDERSCORE || ch == DOLLAR || ch >= 0xc0;
const isIdentifierChar = (ch: number) => isIdentifierStart(ch) || isDigit(ch);
const isSpace = (ch: number) => ch == 32 || ch == 9 || ch == 10 || ch == 13;
/**
* `@if (`, `@else {`, `@let x`, ...: a block rather than an `@` of text
* (pre-v17 templates, or a block name being typed). `at` is the offset of the `@`.
*/
function blockAt(input: InputStream, at = 0): boolean {
let name = "",
i = at + 1;
for (let ch = input.peek(i); isLetter(ch); ch = input.peek(++i)) name += String.fromCharCode(ch);
if (!BLOCKS.has(name)) return false;
let next = input.peek(i);
if (!isSpace(next) && next != PAREN_L && next != BRACE_L) return false;
while (isSpace(next)) next = input.peek(++i);
if (name == "let") return isIdentifierStart(next);
if (name == "else" && next == 105 /* i */ && input.peek(i + 1) == 102 /* f */) return true;
return next == PAREN_L || next == BRACE_L;
}
export const text = new ExternalTokenizer((input) => {
const start = input.pos;
for (;;) {
if (input.next == NEWLINE) {
input.advance();
break;
} else if (
input.next < 0 ||
input.next == BRACE_L ||
input.next == BRACE_R ||
input.next == AMPERSAND ||
(input.next == AT && blockAt(input))
) {
break;
}
input.advance();
}
if (input.pos > start) input.acceptToken(Text);
});
/**
* A block's `{` or `}`, or one of text (ICU messages). `{{` in text starts an
* interpolation, and `{{{` is a brace before one.
*/
export const brace = new ExternalTokenizer(
(input, stack) => {
const interpolation = input.peek(1) == BRACE_L && input.peek(2) != BRACE_L && stack.canShift(Text);
if (input.next == BRACE_R || (input.next == BRACE_L && !interpolation)) input.acceptToken(Brace, 1);
},
{ contextual: true },
);
/** `500ms`, `1.5s` or `100` (milliseconds). */
export const time = new ExternalTokenizer((input) => {
const start = input.pos;
while (isDigit(input.next) || input.next == DOT) input.advance();
if (input.pos == start) return;
if (input.next == 109 /* m */ && input.peek(1) == 115 /* s */) input.advance(2);
else if (input.next == 115 /* s */) input.advance();
if (!isIdentifierChar(input.next)) input.acceptToken(Time);
});
/**
* Moves past the next code unit; false when that leaves the parsed range, i.e.
* a child element or comment follows.
*/
function step(input: InputStream): boolean {
const pos = input.pos;
input.advance();
return input.pos == pos + 1;
}
/** At a newline: the next line starts with a block (`@if (`, `@let x`, ...). */
function blockOnNextLine(input: InputStream): boolean {
let i = 1;
while (isSpace(input.peek(i))) i++;
return input.peek(i) == AT && blockAt(input, i);
}
/**
* A block parameter's or `@let`'s expression: up to a `;` or a closing bracket
* outside brackets and strings, without the spaces around it. An unterminated
* one (being typed) also ends before a child element or comment and before a
* line starting with a block, so the blocks after it keep their highlighting.
*/
export const expression = new ExternalTokenizer((input) => {
if (isSpace(input.next)) return;
const start = input.pos;
let depth = 0,
quote = 0,
escaped = false,
end = start;
for (;;) {
const ch = input.next;
if (ch < 0 || (ch == NEWLINE && blockOnNextLine(input))) break;
if (escaped) {
escaped = false;
} else if (quote) {
// Strings but template literals end with their line, like in JavaScript.
if (ch == quote || (ch == NEWLINE && quote != BACKTICK)) quote = 0;
else escaped = ch == BACKSLASH;
} else if (ch == SINGLE_QUOTE || ch == DOUBLE_QUOTE || ch == BACKTICK) {
quote = ch;
} else if (ch == PAREN_L || ch == BRACKET_L || ch == BRACE_L) {
depth++;
} else if (ch == PAREN_R || ch == BRACKET_R || ch == BRACE_R) {
if (depth == 0) break;
depth--;
} else if (ch == SEMICOLON && depth == 0) {
break;
}
if (!isSpace(ch)) end = input.pos + 1;
if (!step(input)) break;
}
// `end` is a document position: the stream skips the ranges' gaps.
if (end > start) input.acceptTokenTo(Expression, end);
});
function attrContent(quote: number, token: number, script: boolean) {
return new ExternalTokenizer((input) => {
const start = input.pos;
while (
input.next != quote &&
input.next >= 0 &&
(script || (input.next != AMPERSAND && (input.next != BRACE_L || input.peek(1) != BRACE_L)))
)
input.advance();
if (input.pos > start) input.acceptToken(token);
});
}
export const attrSingle = attrContent(SINGLE_QUOTE, attributeContentSingle, false);
export const attrDouble = attrContent(DOUBLE_QUOTE, attributeContentDouble, false);
export const scriptAttrSingle = attrContent(SINGLE_QUOTE, scriptAttributeContentSingle, true);
export const scriptAttrDouble = attrContent(DOUBLE_QUOTE, scriptAttributeContentDouble, true);
+31
View File
@@ -0,0 +1,31 @@
import { expect, it } from "vitest";
import { highlightTree, tags as t } from "@lezer/highlight";
import { findLanguageDescription, loadSupport } from "./languages";
import { openideaHighlightStyle } from "./highlight";
it("lazily loads Angular templates with the editor's HTML and expression colours", async () => {
const desc = findLanguageDescription("Angular Template", "/angular/src/app/app.component.html");
expect(desc?.name).toBe("Angular Template");
const support = await loadSupport(desc!);
expect(support?.language.name).toBe("angular");
const doc = '<button [disabled]="busy">{{ name }}</button> @if (ready) { <p>Ready</p> }';
const tokens: { text: string; cls: string }[] = [];
highlightTree(support!.language.parser.parse(doc), openideaHighlightStyle, (from, to, cls) => {
tokens.push({ text: doc.slice(from, to), cls });
});
for (const [text, tag] of [
["button", t.tagName],
["disabled", t.attributeName],
["busy", t.variableName],
["{{", t.special(t.brace)],
["name", t.variableName],
["}}", t.special(t.brace)],
["@if", t.controlKeyword],
] as const) {
const cls = openideaHighlightStyle.style([tag]);
expect(cls).toBeTruthy();
expect(tokens).toContainEqual({ text, cls });
}
expect(findLanguageDescription("HTML", "/plain/index.html")?.name).toBe("HTML");
expect(findLanguageDescription("Batch", "/plain/build.bat")?.name).toBe("Batch");
});
+1
View File
@@ -66,6 +66,7 @@ export const openideaHighlightStyle = HighlightStyle.define([
token([t.variableName, t.definition(t.variableName), t.local(t.variableName)], "variable"),
token([t.propertyName, t.definition(t.propertyName)], "property"),
token(t.attributeName, "attribute"),
token(t.special(t.brace), "decorator"),
token([t.tagName, t.angleBracket], "tag"),
token([t.punctuation, t.bracket, t.separator, t.operator, t.derefOperator], "punct"),
token(t.invalid, "invalid"),
+6 -1
View File
@@ -60,6 +60,11 @@ export function uiModeForPath(path: string): string | null | undefined {
const supports = new Map<string, Promise<LanguageSupport | null>>();
/** Modes replacing `@codemirror/language-data`'s: Angular templates with control-flow blocks. */
const OWN_MODES = [
LanguageDescription.of({ name: "Angular Template", load: () => import("./angular").then((m) => m.angular()) }),
];
const BATCH_KEYWORDS = new Set([
"echo",
"set",
@@ -134,7 +139,7 @@ const BUILTIN_MODES: readonly LanguageDescription[] = [...languageData, batchLan
export function findLanguageDescription(uiMode: string | null, path: string): LanguageDescription | null {
if (uiMode) {
const contributed = _languageMode(uiMode);
const contributed = _languageMode(uiMode) ?? OWN_MODES.find((l) => l.name === uiMode);
if (contributed) return contributed;
const exact = BUILTIN_MODES.find((l) => l.name === uiMode);
if (exact) return exact;
+8
View File
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { language } from "@codemirror/language";
import { selectableText } from "@/ui";
import type { DocBackend } from "../backend";
import { previewExtensions } from "./preview";
describe("previewExtensions", () => {
@@ -15,4 +17,10 @@ describe("previewExtensions", () => {
expect(view.contentDOM.classList.contains(selectableText)).toBe(true);
view.destroy();
});
it("shows a closed file in the mode it would open with", async () => {
const backend = { uiModeOf: async () => "Angular Template" } as Partial<DocBackend> as DocBackend;
const extensions = await previewExtensions("/p/app.html", undefined, backend);
expect(EditorState.create({ doc: "@if (a) {}", extensions }).facet(language)?.name).toBe("angular");
});
});
+9 -2
View File
@@ -8,6 +8,7 @@ import { EditorView, highlightSpecialChars, lineNumbers } from "@codemirror/view
import { syntaxHighlighting } from "@codemirror/language";
import { editorsFor } from "@/editor/api";
import type { EditorSettings } from "@/platform/ipc";
import type { DocBackend } from "../backend";
import { openideaHighlightStyle } from "./highlight";
import { openideaTheme } from "./theme";
import { findLanguageDescription, loadSupport, loadedSupport, uiModeForPath } from "./languages";
@@ -15,9 +16,15 @@ import { fontExtension } from "./setup";
import { tx } from "@/platform/i18n";
import { selectableText } from "@/ui";
export async function previewExtensions(path: string, settings: EditorSettings | undefined): Promise<Extension> {
export async function previewExtensions(
path: string,
settings: EditorSettings | undefined,
backend?: DocBackend,
): Promise<Extension> {
const open = editorsFor(path)[0]?.ctx;
const desc = findLanguageDescription(open ? open.uiMode : (uiModeForPath(path) ?? null), path);
// A closed file: its own mode (an Angular project's templates) like an editor would show it.
const own = open ? null : await backend?.uiModeOf?.(path).catch(() => null);
const desc = findLanguageDescription(open ? open.uiMode : (own ?? uiModeForPath(path) ?? null), path);
const support = desc ? (loadedSupport(desc) ?? (await loadSupport(desc))) : null;
return [
fontExtension(settings),
+1 -1
View File
@@ -56,7 +56,7 @@ export function activate(): void | (() => void) {
},
});
_provideDocumentAttacher((path) => attachDocument(ctrl.manager, path));
_provideCodePreview((path) => previewExtensions(path, ctrl.manager.settings()?.editor));
_provideCodePreview((path) => previewExtensions(path, ctrl.manager.settings()?.editor, ctrl.manager.backend));
const disposers = [
installColorScheme(),
provideEditorArea(EditorAreaView),
+30
View File
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { language as languageFacet } from "@codemirror/language";
import { remoteChange } from "@/editor/api";
import { createMemoryBackend, type MemoryBackend } from "../backend";
import { _resetLanguages } from "../cm/languages";
@@ -101,6 +102,35 @@ describe("DocumentManager + slots", () => {
expect(manager.get("/p/a.txt")).toBeUndefined();
});
it("loads the document's own mode over its language's (Angular templates)", async () => {
const { manager } = setup({ "/p/angular.json": "{}", "/p/src/app.html": '<p [title]="name()"></p>' });
const model = await manager.acquire("/p/src/app.html");
expect(model.getMeta().uiMode).toBe("Angular Template");
await model.loadLanguage();
const slot = new EditorSlot(model, "t1", host);
expect(slot.currentState().facet(languageFacet)?.name).toBe("angular");
});
it("takes the document's mode from a language change", async () => {
const { manager } = setup({ "/p/app.txt": "<p></p>" });
const model = await manager.acquire("/p/app.txt");
model.setLanguage("html", "Angular Template");
expect(model.getMeta().uiMode).toBe("Angular Template");
});
it("follows a change of the document's mode alone", async () => {
const { manager } = setup({ "/p/app.html": "<p></p>" });
const model = await manager.acquire("/p/app.html");
const slot = new EditorSlot(model, "t1", host);
model.setLanguage("html", "Angular Template");
await model.loadLanguage();
expect(model.getMeta().uiMode).toBe("Angular Template");
expect(slot.currentState().facet(languageFacet)?.name).toBe("angular");
model.setLanguage("html", null);
expect(model.getMeta().uiMode).not.toBe("Angular Template");
expect(slot.currentState().facet(languageFacet)?.name).not.toBe("angular");
});
it("Save All rejects a failed document save after saving the remaining dirty documents", async () => {
const { backend, manager } = setup({ "/p/a.txt": "a\n", "/p/b.txt": "b\n" }, { files: { autoSave: false } });
try {
+20 -9
View File
@@ -209,6 +209,8 @@ export class DocModel {
language: LanguageSupport | null;
/** Too large for language support (no CodeMirror language, backend highlighting only). */
private large: boolean;
/** The backend's mode for this file over its language's (an Angular project's templates). */
private ownUiMode: string | null;
lastFocused: EditorSlot | null = null;
/** Bumped by every change (local or remote). */
changeCounter = 0;
@@ -229,13 +231,16 @@ export class DocModel {
// `large`: the backend skips LSP and syntax trees for it; heavy UI features follow.
const large = !!info.large || info.text.length > HEAVY_LIMIT;
this.large = large;
this.desc = large ? null : findLanguageDescription(this.info?.uiMode ?? null, info.path);
// The document's own mode (an Angular project's templates) wins over the language's.
this.ownUiMode = info.uiMode ?? null;
const uiMode = this.ownUiMode ?? this.info?.uiMode ?? null;
this.desc = large ? null : findLanguageDescription(uiMode, info.path);
this.language = loadedSupport(this.desc);
this.meta = {
doc: info.id,
path: info.path,
language: info.language,
uiMode: this.info?.uiMode ?? this.desc?.name ?? null,
uiMode: uiMode ?? this.desc?.name ?? null,
encoding: info.encoding,
lineEnding: info.lineEnding,
// Library sources (External Libraries) open read-only, like IDEA's.
@@ -266,7 +271,6 @@ export class DocModel {
},
manager.scheduler,
);
if (this.desc && !this.language) void this.loadLanguage();
this.scheduleRefresh(0);
}
@@ -354,14 +358,19 @@ export class DocModel {
this.scheduleRefresh();
}
/** The backend detected another language (Settings › File Types, Override File Type). */
setLanguage(language: string) {
if (this.closed || language === this.meta.language) return;
/**
* The backend detected another language (Settings › File Types, Override File
* Type) or mode (`angular.json` created or removed).
*/
setLanguage(language: string, ownUiMode: string | null = null) {
if (this.closed || (language === this.meta.language && ownUiMode === this.ownUiMode)) return;
this.info = languageInfo(language);
this.desc = this.large ? null : findLanguageDescription(this.info?.uiMode ?? null, this.path);
this.ownUiMode = ownUiMode;
const uiMode = ownUiMode ?? this.info?.uiMode ?? null;
this.desc = this.large ? null : findLanguageDescription(uiMode, this.path);
this.language = loadedSupport(this.desc);
this.highlights = null;
this.setMeta({ language, uiMode: this.info?.uiMode ?? this.desc?.name ?? null });
this.setMeta({ language, uiMode: uiMode ?? this.desc?.name ?? null });
for (const s of this.slots) {
s.reconfigure([compartments.language.reconfigure(this.language ?? [])]);
s.syncContributions(true);
@@ -638,7 +647,7 @@ export class DocumentManager {
start() {
this.disposers.push(
this.backend.onPatched((e) => this.byId.get(e.doc)?.sync.remote(e)),
this.backend.onLanguage?.((e) => this.byId.get(e.doc)?.setLanguage(e.language)) ?? (() => {}),
this.backend.onLanguage?.((e) => this.byId.get(e.doc)?.setLanguage(e.language, e.uiMode)) ?? (() => {}),
this.backend.onDirty((e) => {
const m = this.byId.get(e.doc);
if (m && m.getMeta().dirty !== e.dirty) {
@@ -682,6 +691,8 @@ export class DocumentManager {
return raced;
}
const model = new DocModel(this, info);
// The first slot must include the lazy mode, not briefly render plain text.
await model.loadLanguage();
this.byPath.set(path, model);
this.byId.set(model.doc, model);
_emitEditorEvent({ type: "docOpened", doc: model.doc, path, language: info.language });
@@ -0,0 +1,54 @@
import { expect, it, vi } from "vitest";
import { language as languageFacet } from "@codemirror/language";
import { createMemoryBackend } from "../backend";
import { DocumentManager } from "./document";
import { EditorSlot } from "./slot";
const loading = vi.hoisted(() => {
let finish!: () => void;
const ready = new Promise<void>((resolve) => {
finish = resolve;
});
return { ready, finish, started: false };
});
vi.mock("../cm/languages", async (importOriginal) => {
const actual = await importOriginal<typeof import("../cm/languages")>();
return {
...actual,
loadSupport: async (...args: Parameters<typeof actual.loadSupport>) => {
loading.started = true;
await loading.ready;
return actual.loadSupport(...args);
},
};
});
it("waits for the lazy mode before exposing an Angular document to its first slot", async () => {
const manager = new DocumentManager(
createMemoryBackend({ "/p/angular.json": "{}", "/p/src/app.html": "<p>{{ name }}</p>" }),
{ settings: () => ({}), notify: vi.fn() },
);
let acquired = false;
const pending = manager.acquire("/p/src/app.html").then((model) => {
acquired = true;
return model;
});
await vi.waitFor(() => expect(loading.started).toBe(true));
try {
expect(acquired).toBe(false);
} finally {
loading.finish();
}
const model = await pending;
const slot = new EditorSlot(model, "t1", {
onFocus() {},
onBlur() {},
onSelection() {},
onUserEdit() {},
onContextMenu() {},
});
expect(slot.currentState().facet(languageFacet)?.name).toBe("angular");
slot.dispose();
manager.stop();
});