Implement pretty TypeScript errors (#42494)

Closes #7844

This change uses tree-sitter highlights as a method of showing
typescript errors prettily, keeping regex as simple as possible:

<img width="832" height="446" alt="Screenshot 2025-11-11 at 3 40 24 PM"
src="https://github.com/user-attachments/assets/0b3b6cf1-4d4d-4398-b89b-ef5ec0df87ec"
/>

It covers three main areas:

1. Diagnostics

Diagnostics are now rendered with language-aware typescript, by
providing the project's language registry.

2. Vtsls

The LSP provider for typescript now implements the
`diagnostic_message_to_markdown` function in the `LspAdapter` trait, so
as to provide Diagnostics with \`\`\`typescript...\`\`\`-style code
blocks for any selection of typescript longer than one word. In the
single-word case, it simply wraps with \`\`

3. Typescript's `highlights.scm`

`vtsls` doesn't provide strictly valid typescript in much of its
messaging. Rather, it returns a message with snippets of typescript
values which are invalid. Tree-sitter was not properly highlighting
these snippets because it was expecting key-value formats. For instance:
```
type foo = { foo: string; bar: string; baz: number[] }
```
is valid, whereas simply
```
{ foo: string; bar: string; baz: number[] }
```
is not.

Therefore, highlights.scm needed to be adjusted in order to
pattern-match on literal values that might be returned from the vtsls
diagnostics messages. This was done by a) identifying arrow functions on
their own, and b) augmenting the `statment_block` pattern matching in
order to match on values which were clearly object literals.

This approach may not be exhaustive - I'm happy to work on any
additional cases we might identify from `vtsls` here - but hopefully
demonstrates an extensible approach to making these messages look nice,
without taking on the technical burden of extensive regex.

Release Notes:

- Show pretty TypeScript errors with language-aware Markdown.
This commit is contained in:
KyleBarton
2025-11-12 10:32:46 -08:00
committed by GitHub
parent e8daab15ab
commit 4adec27a3d
7 changed files with 159 additions and 12 deletions
@@ -370,11 +370,16 @@ impl BufferDiagnosticsEditor {
continue;
}
let languages = buffer_diagnostics_editor
.read_with(cx, |b, cx| b.project.read(cx).languages().clone())
.ok();
let diagnostic_blocks = cx.update(|_window, cx| {
DiagnosticRenderer::diagnostic_blocks_for_group(
group,
buffer_snapshot.remote_id(),
Some(Arc::new(buffer_diagnostics_editor.clone())),
languages,
cx,
)
})?;
+30 -5
View File
@@ -6,7 +6,7 @@ use editor::{
hover_popover::diagnostics_markdown_style,
};
use gpui::{AppContext, Entity, Focusable, WeakEntity};
use language::{BufferId, Diagnostic, DiagnosticEntryRef};
use language::{BufferId, Diagnostic, DiagnosticEntryRef, LanguageRegistry};
use lsp::DiagnosticSeverity;
use markdown::{Markdown, MarkdownElement};
use settings::Settings;
@@ -27,6 +27,7 @@ impl DiagnosticRenderer {
diagnostic_group: Vec<DiagnosticEntryRef<'_, Point>>,
buffer_id: BufferId,
diagnostics_editor: Option<Arc<dyn DiagnosticsToolbarEditor>>,
language_registry: Option<Arc<LanguageRegistry>>,
cx: &mut App,
) -> Vec<DiagnosticBlock> {
let Some(primary_ix) = diagnostic_group
@@ -75,11 +76,14 @@ impl DiagnosticRenderer {
))
}
}
results.push(DiagnosticBlock {
initial_range: primary.range.clone(),
severity: primary.diagnostic.severity,
diagnostics_editor: diagnostics_editor.clone(),
markdown: cx.new(|cx| Markdown::new(markdown.into(), None, None, cx)),
markdown: cx.new(|cx| {
Markdown::new(markdown.into(), language_registry.clone(), None, cx)
}),
});
} else {
if entry.range.start.row.abs_diff(primary.range.start.row) >= 5 {
@@ -91,7 +95,9 @@ impl DiagnosticRenderer {
initial_range: entry.range.clone(),
severity: entry.diagnostic.severity,
diagnostics_editor: diagnostics_editor.clone(),
markdown: cx.new(|cx| Markdown::new(markdown.into(), None, None, cx)),
markdown: cx.new(|cx| {
Markdown::new(markdown.into(), language_registry.clone(), None, cx)
}),
});
}
}
@@ -118,9 +124,16 @@ impl editor::DiagnosticRenderer for DiagnosticRenderer {
buffer_id: BufferId,
snapshot: EditorSnapshot,
editor: WeakEntity<Editor>,
language_registry: Option<Arc<LanguageRegistry>>,
cx: &mut App,
) -> Vec<BlockProperties<Anchor>> {
let blocks = Self::diagnostic_blocks_for_group(diagnostic_group, buffer_id, None, cx);
let blocks = Self::diagnostic_blocks_for_group(
diagnostic_group,
buffer_id,
None,
language_registry,
cx,
);
blocks
.into_iter()
@@ -146,9 +159,16 @@ impl editor::DiagnosticRenderer for DiagnosticRenderer {
diagnostic_group: Vec<DiagnosticEntryRef<'_, Point>>,
range: Range<Point>,
buffer_id: BufferId,
language_registry: Option<Arc<LanguageRegistry>>,
cx: &mut App,
) -> Option<Entity<Markdown>> {
let blocks = Self::diagnostic_blocks_for_group(diagnostic_group, buffer_id, None, cx);
let blocks = Self::diagnostic_blocks_for_group(
diagnostic_group,
buffer_id,
None,
language_registry,
cx,
);
blocks
.into_iter()
.find_map(|block| (block.initial_range == range).then(|| block.markdown))
@@ -206,6 +226,11 @@ impl DiagnosticBlock {
self.markdown.clone(),
diagnostics_markdown_style(bcx.window, cx),
)
.code_block_renderer(markdown::CodeBlockRenderer::Default {
copy_button: false,
copy_button_on_hover: false,
border: false,
})
.on_url_click({
move |link, window, cx| {
editor
+5 -1
View File
@@ -73,7 +73,7 @@ pub fn init(cx: &mut App) {
}
pub(crate) struct ProjectDiagnosticsEditor {
project: Entity<Project>,
pub project: Entity<Project>,
workspace: WeakEntity<Workspace>,
focus_handle: FocusHandle,
editor: Entity<Editor>,
@@ -545,11 +545,15 @@ impl ProjectDiagnosticsEditor {
if group_severity.is_none_or(|s| s > max_severity) {
continue;
}
let languages = this
.read_with(cx, |t, cx| t.project.read(cx).languages().clone())
.ok();
let more = cx.update(|_, cx| {
crate::diagnostic_renderer::DiagnosticRenderer::diagnostic_blocks_for_group(
group,
buffer_snapshot.remote_id(),
Some(diagnostics_toolbar_editor.clone()),
languages,
cx,
)
})?;
+17 -4
View File
@@ -117,8 +117,9 @@ use language::{
AutoindentMode, BlockCommentConfig, BracketMatch, BracketPair, Buffer, BufferRow,
BufferSnapshot, Capability, CharClassifier, CharKind, CharScopeContext, CodeLabel, CursorShape,
DiagnosticEntryRef, DiffOptions, EditPredictionsMode, EditPreview, HighlightedText, IndentKind,
IndentSize, Language, OffsetRangeExt, OutlineItem, Point, Runnable, RunnableRange, Selection,
SelectionGoal, TextObject, TransactionId, TreeSitterOptions, WordsQuery,
IndentSize, Language, LanguageRegistry, OffsetRangeExt, OutlineItem, Point, Runnable,
RunnableRange, Selection, SelectionGoal, TextObject, TransactionId, TreeSitterOptions,
WordsQuery,
language_settings::{
self, LspInsertMode, RewrapBehavior, WordsCompletionMode, all_language_settings,
language_settings,
@@ -371,6 +372,7 @@ pub trait DiagnosticRenderer {
buffer_id: BufferId,
snapshot: EditorSnapshot,
editor: WeakEntity<Editor>,
language_registry: Option<Arc<LanguageRegistry>>,
cx: &mut App,
) -> Vec<BlockProperties<Anchor>>;
@@ -379,6 +381,7 @@ pub trait DiagnosticRenderer {
diagnostic_group: Vec<DiagnosticEntryRef<'_, Point>>,
range: Range<Point>,
buffer_id: BufferId,
language_registry: Option<Arc<LanguageRegistry>>,
cx: &mut App,
) -> Option<Entity<markdown::Markdown>>;
@@ -17947,8 +17950,18 @@ impl Editor {
.diagnostic_group(buffer_id, diagnostic.diagnostic.group_id)
.collect::<Vec<_>>();
let blocks =
renderer.render_group(diagnostic_group, buffer_id, snapshot, cx.weak_entity(), cx);
let language_registry = self
.project()
.map(|project| project.read(cx).languages().clone());
let blocks = renderer.render_group(
diagnostic_group,
buffer_id,
snapshot,
cx.weak_entity(),
language_registry,
cx,
);
let blocks = self.display_map.update(cx, |display_map, cx| {
display_map.insert_blocks(blocks, cx).into_iter().collect()
+12 -1
View File
@@ -341,7 +341,13 @@ fn show_hover(
renderer
.as_ref()
.and_then(|renderer| {
renderer.render_hover(group, point_range, buffer_id, cx)
renderer.render_hover(
group,
point_range,
buffer_id,
language_registry.clone(),
cx,
)
})
.context("no rendered diagnostic")
})??;
@@ -986,6 +992,11 @@ impl DiagnosticPopover {
self.markdown.clone(),
diagnostics_markdown_style(window, cx),
)
.code_block_renderer(markdown::CodeBlockRenderer::Default {
copy_button: false,
copy_button_on_hover: false,
border: false,
})
.on_url_click(
move |link, window, cx| {
if let Some(renderer) = GlobalDiagnosticRenderer::global(cx)
@@ -9,6 +9,36 @@
(type_identifier) @type
(predefined_type) @type.builtin
;; Highlights object literals by hijacking the statement_block pattern, but only if
;; the statement block follows an object literal pattern
((statement_block
(labeled_statement
;; highlight the label like a property name
label: (statement_identifier) @property.name
body: [
;; match a terminating expression statement
(expression_statement
;; single identifier - treat as a type name
[(identifier) @type.name
;; object - treat as a property - type pair
(object
(pair
key: (_) @property.name
value: (_) @type.name))
;; subscript_expression - treat as an array declaration
(subscript_expression
object: (_) @type.name
index: (_)
)
;; templated string - treat each identifier contained as a type name
(template_string
(template_substitution
(identifier) @type.name))
])
;; match a nested statement block
(statement_block) @nested
])))
(import_specifier
"type"
name: (identifier) @type
@@ -79,6 +109,8 @@
left: (identifier) @function
right: [(function_expression) (arrow_function)])
(arrow_function) @function
; Literals
(this) @variable.special
+58 -1
View File
@@ -6,11 +6,12 @@ use language::{LanguageName, LspAdapter, LspAdapterDelegate, LspInstaller, Toolc
use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName};
use node_runtime::{NodeRuntime, VersionStrategy};
use project::{Fs, lsp_store::language_server_settings};
use regex::Regex;
use serde_json::Value;
use std::{
ffi::OsString,
path::{Path, PathBuf},
sync::Arc,
sync::{Arc, LazyLock},
};
use util::{ResultExt, maybe, merge_json_value_into};
@@ -56,6 +57,20 @@ impl VtslsLspAdapter {
None
}
}
pub fn enhance_diagnostic_message(message: &str) -> Option<String> {
static SINGLE_WORD_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"'([^\s']*)'").expect("Failed to create REGEX"));
static MULTI_WORD_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"'([^']+\s+[^']*)'").expect("Failed to create REGEX"));
let first = SINGLE_WORD_REGEX.replace_all(message, "`$1`").to_string();
let second = MULTI_WORD_REGEX
.replace_all(&first, "\n```typescript\n$1\n```\n")
.to_string();
Some(second)
}
}
pub struct TypeScriptVersions {
@@ -274,6 +289,10 @@ impl LspAdapter for VtslsLspAdapter {
Ok(default_workspace_configuration)
}
fn diagnostic_message_to_markdown(&self, message: &str) -> Option<String> {
VtslsLspAdapter::enhance_diagnostic_message(message)
}
fn language_ids(&self) -> HashMap<LanguageName, String> {
HashMap::from_iter([
(LanguageName::new("TypeScript"), "typescript".into()),
@@ -302,3 +321,41 @@ async fn get_cached_ts_server_binary(
.await
.log_err()
}
#[cfg(test)]
mod tests {
use crate::vtsls::VtslsLspAdapter;
#[test]
fn test_diagnostic_message_to_markdown() {
// Leaves simple messages unchanged
let message = "The expected type comes from the return type of this signature.";
let expected = "The expected type comes from the return type of this signature.";
assert_eq!(
VtslsLspAdapter::enhance_diagnostic_message(message).expect("Should be some"),
expected
);
// Parses both multi-word and single-word correctly
let message = "Property 'baz' is missing in type '{ foo: string; bar: string; }' but required in type 'User'.";
let expected = "Property `baz` is missing in type \n```typescript\n{ foo: string; bar: string; }\n```\n but required in type `User`.";
assert_eq!(
VtslsLspAdapter::enhance_diagnostic_message(message).expect("Should be some"),
expected
);
// Parses multi-and-single word in any order, and ignores existing newlines
let message = "Type '() => { foo: string; bar: string; }' is not assignable to type 'GetUserFunction'.\n Property 'baz' is missing in type '{ foo: string; bar: string; }' but required in type 'User'.";
let expected = "Type \n```typescript\n() => { foo: string; bar: string; }\n```\n is not assignable to type `GetUserFunction`.\n Property `baz` is missing in type \n```typescript\n{ foo: string; bar: string; }\n```\n but required in type `User`.";
assert_eq!(
VtslsLspAdapter::enhance_diagnostic_message(message).expect("Should be some"),
expected
);
}
}