mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
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:
1 parent
c0626b2728
commit
922bbf493a
44 files changed
+1552
-94
No files matched your search
@@ -9,6 +9,7 @@
|
||||
"dist",
|
||||
"target",
|
||||
"src/bindings.ts",
|
||||
"src/editor/core/cm/angular/parser*.ts",
|
||||
"docs/plugins/themes.md",
|
||||
"pnpm-lock.yaml",
|
||||
"**/*.toml",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"src-tauri",
|
||||
"crates",
|
||||
"src/bindings.ts",
|
||||
"src/editor/core/cm/angular/parser*.ts",
|
||||
"fixtures",
|
||||
"plugins",
|
||||
"test-results",
|
||||
|
||||
Generated
+1
@@ -4307,6 +4307,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"tracing",
|
||||
"tree-sitter",
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")?)
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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
@@ -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 }) => {
|
||||
|
||||
@@ -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 }} & {{ 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
@@ -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
|
||||
|
||||
|
||||
Generated
+24
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 & 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>&</b>} @else if (x) {} @else {}</div>`)).toEqual([
|
||||
"div:tag",
|
||||
"@if:control",
|
||||
"user:call",
|
||||
"&&:op",
|
||||
"ok:var",
|
||||
"as:keyword",
|
||||
"u:def",
|
||||
"{:brace",
|
||||
"b:tag",
|
||||
"&: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 & b">x < {{ 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 < {{ 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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
@@ -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!QPXXQOp(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
|
||||
})
|
||||
@@ -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);
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user