From 922bbf493a0710036eed1d27a037a4dceb65a0c4 Mon Sep 17 00:00:00 2001 From: Mykhailo Smolinskyi Date: Fri, 30 Oct 2026 01:39:31 +0200 Subject: [PATCH] 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 <2458463+nntndfrk@users.noreply.github.com> --- .oxfmtrc.json | 1 + .oxlintrc.json | 1 + Cargo.lock | 1 + THIRD_PARTY_LICENSES.md | 12 + crates/ide-core/src/event.rs | 5 +- crates/ide-dap/src/tests.rs | 1 + crates/ide-host/src/ops/doc/commands.rs | 8 + crates/ide-host/src/project.rs | 37 +- crates/ide-language/Cargo.toml | 1 + crates/ide-language/src/lib.rs | 17 + crates/ide-language/src/markers.rs | 89 +++++ crates/ide-language/src/registry.rs | 55 ++- crates/ide-lsp/src/launch.rs | 41 +- crates/ide-lsp/tests/mock_server.rs | 1 + crates/ide-lsp/tests/real_servers.rs | 1 + crates/ide-workspace/src/documents.rs | 56 ++- crates/ide-workspace/tests/documents.rs | 89 ++++- knip.ts | 8 +- languages/html.toml | 6 + package.json | 6 + perf/lib/harness.ts | 17 +- perf/scenarios.ts | 46 +++ plugins/README.md | 7 +- pnpm-lock.yaml | 24 ++ src-tauri/src/commands/doc.rs | 8 + src-tauri/src/commands/mod.rs | 1 + src/bindings.ts | 19 +- src/editor/core/backend.ts | 23 +- src/editor/core/cm/angular/LICENSE | 21 + src/editor/core/cm/angular/angular.grammar | 121 ++++++ src/editor/core/cm/angular/angular.test.ts | 365 ++++++++++++++++++ src/editor/core/cm/angular/index.ts | 135 +++++++ src/editor/core/cm/angular/parser.terms.ts | 38 ++ src/editor/core/cm/angular/parser.ts | 24 ++ src/editor/core/cm/angular/tokens.ts | 188 +++++++++ src/editor/core/cm/angularLoading.test.ts | 31 ++ src/editor/core/cm/highlight.ts | 1 + src/editor/core/cm/languages.ts | 7 +- src/editor/core/cm/preview.test.ts | 8 + src/editor/core/cm/preview.ts | 11 +- src/editor/core/index.ts | 2 +- src/editor/core/model/document.test.ts | 30 ++ src/editor/core/model/document.ts | 29 +- .../core/model/documentLanguage.test.ts | 54 +++ 44 files changed, 1552 insertions(+), 94 deletions(-) create mode 100644 crates/ide-language/src/markers.rs create mode 100644 src/editor/core/cm/angular/LICENSE create mode 100644 src/editor/core/cm/angular/angular.grammar create mode 100644 src/editor/core/cm/angular/angular.test.ts create mode 100644 src/editor/core/cm/angular/index.ts create mode 100644 src/editor/core/cm/angular/parser.terms.ts create mode 100644 src/editor/core/cm/angular/parser.ts create mode 100644 src/editor/core/cm/angular/tokens.ts create mode 100644 src/editor/core/cm/angularLoading.test.ts create mode 100644 src/editor/core/model/documentLanguage.test.ts diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 758635b6..d2730f45 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -9,6 +9,7 @@ "dist", "target", "src/bindings.ts", + "src/editor/core/cm/angular/parser*.ts", "docs/plugins/themes.md", "pnpm-lock.yaml", "**/*.toml", diff --git a/.oxlintrc.json b/.oxlintrc.json index 2db88769..51778e9c 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -13,6 +13,7 @@ "src-tauri", "crates", "src/bindings.ts", + "src/editor/core/cm/angular/parser*.ts", "fixtures", "plugins", "test-results", diff --git a/Cargo.lock b/Cargo.lock index b572dc22..54c5d2b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4307,6 +4307,7 @@ dependencies = [ "serde", "serde_json", "specta", + "tempfile", "toml 0.9.12+spec-1.1.0", "tracing", "tree-sitter", diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 4ef87492..0c711821 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -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//locals.scm` are derived from the `locals.scm` diff --git a/crates/ide-core/src/event.rs b/crates/ide-core/src/event.rs index b730a9e1..95fcca2c 100644 --- a/crates/ide-core/src/event.rs +++ b/crates/ide-core/src/event.rs @@ -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, }, /// Full replacement of diagnostics for one file from one producer. Diagnostics { diff --git a/crates/ide-dap/src/tests.rs b/crates/ide-dap/src/tests.rs index 7f9987cd..be4adefe 100644 --- a/crates/ide-dap/src/tests.rs +++ b/crates/ide-dap/src/tests.rs @@ -893,6 +893,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc 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> { + 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 { host.project(window)?.documents.set_encoding(doc, &encoding, reload) @@ -108,6 +115,7 @@ pub fn history_content(host: &Host, window: &str, id: String) -> IdeResult| 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, root: Option<&Path>) -> Arc { + 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, diff --git a/crates/ide-language/Cargo.toml b/crates/ide-language/Cargo.toml index 12ff2717..b830b706 100644 --- a/crates/ide-language/Cargo.toml +++ b/crates/ide-language/Cargo.toml @@ -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" diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index 20d71aec..632ec160 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -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, + /// 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, #[serde(default)] pub tree_sitter: Option, #[serde(default)] @@ -49,6 +55,17 @@ pub struct LanguageConfig { pub debugger: Option, } +/// 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, +} + #[derive(Clone, Copy, Debug, Serialize, Deserialize, Type)] #[serde(rename_all(serialize = "camelCase", deserialize = "kebab-case"))] pub struct IndentConfig { diff --git a/crates/ide-language/src/markers.rs b/crates/ide-language/src/markers.rs new file mode 100644 index 00000000..832dc0c6 --- /dev/null +++ b/crates/ide-language/src/markers.rs @@ -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 { + 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")); + } +} diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index 10ab386e..31a9eda2 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -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 { + 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"); } } diff --git a/crates/ide-lsp/src/launch.rs b/crates/ide-lsp/src/launch.rs index bfe50de6..782df3b8 100644 --- a/crates/ide-lsp/src/launch.rs +++ b/crates/ide-lsp/src/launch.rs @@ -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 { - 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(); diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 43160519..2e57fb67 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -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() diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index f258c40e..a76fca35 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -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, diff --git a/crates/ide-workspace/src/documents.rs b/crates/ide-workspace/src/documents.rs index ca420936..0f44819d 100644 --- a/crates/ide-workspace/src/documents.rs +++ b/crates/ide-workspace/src/documents.rs @@ -35,6 +35,10 @@ pub struct DocumentInfo { /// provides, e.g. `jdt://…`); `path` then holds the same URI. #[specta(optional)] pub uri: Option, + /// CodeMirror mode replacing the language's own one (an Angular project's + /// HTML templates). + #[specta(optional)] + pub ui_mode: Option, } /// 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 + 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>>, sink: SharedSink, detect_language: Box, + detect_ui_mode: Box, editorconfig: EditorConfig, defaults: RwLock, } @@ -107,6 +115,7 @@ struct DocState { path: PathBuf, doc: Document, language: LanguageId, + ui_mode: Option, 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) -> 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 { + (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 (`!/`) 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 { 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 diff --git a/crates/ide-workspace/tests/documents.rs b/crates/ide-workspace/tests/documents.rs index 04614dea..681f9be5 100644 --- a/crates/ide-workspace/tests/documents.rs +++ b/crates/ide-workspace/tests/documents.rs @@ -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>> = 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, "

\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>> = 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, "

\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> = 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 { events.iter().filter_map(|e| if let IdeEvent::DocDirty { dirty, .. } = e { Some(*dirty) } else { None }).collect() } diff --git a/knip.ts b/knip.ts index a341b687..50822935 100644 --- a/knip.ts +++ b/knip.ts @@ -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"], diff --git a/languages/html.toml b/languages/html.toml index 03ed20a0..9da5321d 100644 --- a/languages/html.toml +++ b/languages/html.toml @@ -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]] diff --git a/package.json b/package.json index 1ec1289e..c9012e6a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/perf/lib/harness.ts b/perf/lib/harness.ts index 325bcc05..5e310a8d 100644 --- a/perf/lib/harness.ts +++ b/perf/lib/harness.ts @@ -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 { - 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 }) => { diff --git a/perf/scenarios.ts b/perf/scenarios.ts index c20e70ee..567dcb5d 100644 --- a/perf/scenarios.ts +++ b/perf/scenarios.ts @@ -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 = [``]; + for (let i = 0; out.length < lines; i++) { + out.push( + `
`, + `

{{ title${i}() | titlecase }}

`, + ` @if (items${i}().length) {`, + `
    `, + ` @for (item of items${i}(); track item.id; let last = $last) {`, + `
  • {{ item.name }} & {{ item.count }}
  • `, + ` } @empty {`, + `
  • None
  • `, + ` }`, + `
`, + ` } @else {`, + `

Loading

`, + ` }`, + `
`, + `@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, diff --git a/plugins/README.md b/plugins/README.md index 02a2f175..fd1a745b 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -192,7 +192,12 @@ token reference and IntelliJ / VS Code porting tables are in `languages/.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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b75c6245..80c5ac0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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/commands@6.11.1)(@codemirror/language@6.13.1)(@codemirror/search@6.7.2)(@codemirror/state@6.7.6)(@codemirror/view@6.43.14) @@ -105,6 +117,9 @@ importers: specifier: ^5.0.15 version: 5.0.15(@types/react@19.3.0)(immer@11.1.21)(react@19.3.1) 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/css@1.3.9': resolution: {integrity: sha512-ZmzgvTOBdJWGIsY/ItAfN7UW8DV802oZG+jeG6E508SwEzz5YhuAajptpWPICvIyqYAD2+VjMdOS40GgGkyEOQ==} + '@lezer/generator@1.8.1': + resolution: {integrity: sha512-7q7szXqT7EPTs6SODBl58Qoz/4OrhNvm5pW4lbu1ZDnG5e0nn7drLzeN9Nxkm8j/1iMs5QZ9j3i4SV+W4Z6SQA==} + hasBin: true + '@lezer/go@1.0.1': resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==} @@ -2688,6 +2707,11 @@ snapshots: '@lezer/highlight': 1.2.5 '@lezer/lr': 1.4.11 + '@lezer/generator@1.8.1': + dependencies: + '@lezer/common': 1.5.3 + '@lezer/lr': 1.4.11 + '@lezer/go@1.0.1': dependencies: '@lezer/common': 1.5.3 diff --git a/src-tauri/src/commands/doc.rs b/src-tauri/src/commands/doc.rs index 027ba324..ddbd3b05 100644 --- a/src-tauri/src/commands/doc.rs +++ b/src-tauri/src/commands/doc.rs @@ -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> { + 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] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d6262d3a..ac8dd155 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -130,6 +130,7 @@ pub fn builder() -> tauri_specta::Builder { 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, diff --git a/src/bindings.ts b/src/bindings.ts index be8f34ba..2b70878c 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -187,6 +187,11 @@ export const commands = { docSave: (doc: DocId) => typedError(__TAURI_INVOKE("doc_save", { doc })), docSaveAll: () => typedError(__TAURI_INVOKE("doc_save_all")), docSnapshot: (doc: DocId) => typedError(__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(__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(__TAURI_INVOKE("doc_set_encoding", { doc, encoding, reload })), docSetLineEnding: (doc: DocId, lineEnding: LineEnding) => typedError(__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. */ diff --git a/src/editor/core/backend.ts b/src/editor/core/backend.ts index 05d53647..bcaefa95 100644 --- a/src/editor/core/backend.ts +++ b/src/editor/core/backend.ts @@ -48,12 +48,17 @@ export interface DocBackend { highlights(doc: DocId): Promise; selectionRanges(doc: DocId, offset: number): Promise; languages(): Promise; + /** The `DocumentInfo.uiMode` a file would open with, without opening it (previews). */ + uiModeOf?(path: string): Promise; 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 = {}): 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 = {}): 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 = {}): MemoryB async languages() { return MEM_LANGUAGES; }, + async uiModeOf(path) { + return uiModeOf(path); + }, onPatched(h) { patched.add(h); return () => patched.delete(h); diff --git a/src/editor/core/cm/angular/LICENSE b/src/editor/core/cm/angular/LICENSE new file mode 100644 index 00000000..9a91f486 --- /dev/null +++ b/src/editor/core/cm/angular/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (C) 2018-2021 by Marijn Haverbeke 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. diff --git a/src/editor/core/cm/angular/angular.grammar b/src/editor/core/cm/angular/angular.grammar new file mode 100644 index 00000000..b21af301 --- /dev/null +++ b/src/editor/core/cm/angular/angular.grammar @@ -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 VariableDefinition)?> Brace | + "@else" (kw<"if"> BlockParameters VariableDefinition)?>)? Brace | + "@for" BlockParameters Expression (";" forOption?)*> Brace | + ("@switch" | "@case") BlockParameters 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 ")" } + + VariableDefinition { jsIdentifier } + + VariableName { jsIdentifier } + + TriggerName { jsIdentifier } + + kw { @specialize[@name={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 diff --git a/src/editor/core/cm/angular/angular.test.ts b/src/editor/core/cm/angular/angular.test.ts new file mode 100644 index 00000000..0dbbd7a5 --- /dev/null +++ b/src/editor/core/cm/angular/angular.test.ts @@ -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 += + `
\n

{{ title${i} }}

\n
\n` + + `@if (show${i}) {\n

a & b {{ v${i} }}

\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(`{{ 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(`
@if (user() && ok; as u) {&} @else if (x) {} @else {}
`)).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(`

mail a@b.com, use @for loops, @if

`)).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

{{ a }}

\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 = ``; + 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(`x`)).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 = `

x < {{ y }}

`; + 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[]][] = [ + [`
@if (a ) {}
`, ["div:tag", "@if:control", "a:var", ":comment", "{:brace"]], + [`
@for (x of xs; track x ) {}
`, ["div:tag", "@for:control", "x:def", "of:keyword"]], + [`@let x = foo\n

a

\n`, ["@let:let", "x:def", "=:op", "foo:var", "p:tag", "p:tag"]], + [`
\n @if (u\n

x

\n
`, ["div:tag", "@if:control", "u:var", "p:tag", "p:tag", "div:tag"]], + [ + `
@if (a && b && c) {}
`, + ["div:tag", "@if:control", "a:var", "&&:op", ":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

x

\nshipping and tax\n`)).toEqual(["{{:brace", "total:var", "p:tag", "p:tag"]); + expect(tokens(`
    {{ a
  • x
  • plain words
`)).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("", 20), insert: "\n@for (i of is; track i) { {{ i }} }\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("

{ + 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); + }); +}); diff --git a/src/editor/core/cm/angular/index.ts b/src/editor/core/cm/angular/index.ts new file mode 100644 index 00000000..ffa415fc --- /dev/null +++ b/src/editor/core/cm/angular/index.ts @@ -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*[}\]]$/, + }), + ]); +} diff --git a/src/editor/core/cm/angular/parser.terms.ts b/src/editor/core/cm/angular/parser.terms.ts new file mode 100644 index 00000000..44a62530 --- /dev/null +++ b/src/editor/core/cm/angular/parser.terms.ts @@ -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 diff --git a/src/editor/core/cm/angular/parser.ts b/src/editor/core/cm/angular/parser.ts new file mode 100644 index 00000000..e84c8ffd --- /dev/null +++ b/src/editor/core/cm/angular/parser.ts @@ -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<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 +}) diff --git a/src/editor/core/cm/angular/tokens.ts b/src/editor/core/cm/angular/tokens.ts new file mode 100644 index 00000000..9a131193 --- /dev/null +++ b/src/editor/core/cm/angular/tokens.ts @@ -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); diff --git a/src/editor/core/cm/angularLoading.test.ts b/src/editor/core/cm/angularLoading.test.ts new file mode 100644 index 00000000..33b8ad3d --- /dev/null +++ b/src/editor/core/cm/angularLoading.test.ts @@ -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 = ' @if (ready) {

Ready

}'; + 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"); +}); diff --git a/src/editor/core/cm/highlight.ts b/src/editor/core/cm/highlight.ts index 07c36b9e..472c5e84 100644 --- a/src/editor/core/cm/highlight.ts +++ b/src/editor/core/cm/highlight.ts @@ -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"), diff --git a/src/editor/core/cm/languages.ts b/src/editor/core/cm/languages.ts index 07ac1b3a..396966d6 100644 --- a/src/editor/core/cm/languages.ts +++ b/src/editor/core/cm/languages.ts @@ -60,6 +60,11 @@ export function uiModeForPath(path: string): string | null | undefined { const supports = new Map>(); +/** 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; diff --git a/src/editor/core/cm/preview.test.ts b/src/editor/core/cm/preview.test.ts index 8b4a1b3a..a97f4258 100644 --- a/src/editor/core/cm/preview.test.ts +++ b/src/editor/core/cm/preview.test.ts @@ -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 as DocBackend; + const extensions = await previewExtensions("/p/app.html", undefined, backend); + expect(EditorState.create({ doc: "@if (a) {}", extensions }).facet(language)?.name).toBe("angular"); + }); }); diff --git a/src/editor/core/cm/preview.ts b/src/editor/core/cm/preview.ts index 44f0ece8..ba7f3449 100644 --- a/src/editor/core/cm/preview.ts +++ b/src/editor/core/cm/preview.ts @@ -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 { +export async function previewExtensions( + path: string, + settings: EditorSettings | undefined, + backend?: DocBackend, +): Promise { 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), diff --git a/src/editor/core/index.ts b/src/editor/core/index.ts index 7330eed8..e584d2ac 100644 --- a/src/editor/core/index.ts +++ b/src/editor/core/index.ts @@ -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), diff --git a/src/editor/core/model/document.test.ts b/src/editor/core/model/document.test.ts index cc5f7e3d..6ee9a7d9 100644 --- a/src/editor/core/model/document.test.ts +++ b/src/editor/core/model/document.test.ts @@ -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": '

' }); + 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": "

" }); + 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": "

" }); + 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 { diff --git a/src/editor/core/model/document.ts b/src/editor/core/model/document.ts index 2a458aaa..9f81ad20 100644 --- a/src/editor/core/model/document.ts +++ b/src/editor/core/model/document.ts @@ -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 }); diff --git a/src/editor/core/model/documentLanguage.test.ts b/src/editor/core/model/documentLanguage.test.ts new file mode 100644 index 00000000..0618acf2 --- /dev/null +++ b/src/editor/core/model/documentLanguage.test.ts @@ -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((resolve) => { + finish = resolve; + }); + return { ready, finish, started: false }; +}); + +vi.mock("../cm/languages", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadSupport: async (...args: Parameters) => { + 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": "

{{ name }}

" }), + { 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(); +});