Editor/Prism/Text/Completion/CompletionPopup.cs

UI and editor logic for code completion, signature help and overall code intelligence. Defines CompletionPopup (an overlay list drawn inside the editor), CompletionHost (glues the editor to the completion engine and handles keyboard/input, external language server merging), and CodeIntelligence (attaches completion, hover and diagnostics services to an editor).

External DownloadNetworking
using Editor.Prism.Core;
using Editor.Prism.Text.Diagnostics;
using Editor.Prism.Ui;

namespace Editor.Prism.Text.Completion;

/// <summary>
/// The completion list: an overlay drawn inside the code editor rather than a top-level popup window.
/// <para>
/// That choice is deliberate and load-bearing. A <c>PopupWidget</c> is a real Qt popup and takes
/// focus, which blurs the editor — and the editor dismisses completion on blur, so a popup-window list
/// would close itself the instant it opened and every keystroke after it would go to the wrong widget.
/// As a child of the editor with <see cref="FocusMode.None"/> the editor keeps focus, keys keep
/// arriving at <see cref="CompletionHost.HandleKey"/>, and the list is still painted above the text
/// because Qt draws children after their parent.
/// </para>
/// </summary>
public sealed class CompletionPopup : Widget
{
	/// <summary>Height of one row.</summary>
	public const float RowHeight = 22f;

	/// <summary>How many rows are visible before the list scrolls.</summary>
	public const int MaxVisibleRows = 12;

	/// <summary>Height of the documentation strip under the list.</summary>
	public const float FooterHeight = 26f;

	/// <summary>Narrowest the list is allowed to be.</summary>
	public const float MinWidth = 280f;

	/// <summary>Widest the list is allowed to be.</summary>
	public const float MaxWidth = 620f;

	const float Padding = 8f;
	const float IconWidth = 18f;
	const float KindWidth = 66f;

	readonly List<CompletionItem> _items = new();

	float _charWidth = 7f;
	int _scroll;
	int _hover = -1;

	TextPosition _anchor;
	Vector2 _anchorLocal;

	/// <summary>Creates the overlay as a child of an editor. It stays hidden until <see cref="Show"/>.</summary>
	public CompletionPopup( CodeEditorWidget editor ) : base( editor )
	{
		Editor = editor;
		FocusMode = FocusMode.None;
		MouseTracking = true;
		Visible = false;

		Measure();
	}

	/// <summary>The editor this list belongs to.</summary>
	public CodeEditorWidget Editor { get; }

	/// <summary>The entries on show, in rank order.</summary>
	public IReadOnlyList<CompletionItem> Items => _items;

	/// <summary>Index of the highlighted row, or -1.</summary>
	public int SelectedIndex { get; private set; } = -1;

	/// <summary>The highlighted entry, or null.</summary>
	public CompletionItem Selected =>
		SelectedIndex >= 0 && SelectedIndex < _items.Count ? _items[SelectedIndex] : null;

	/// <summary>True while the list is on screen.</summary>
	public bool IsOpen => Visible && _items.Count > 0;

	/// <summary>Raised when the user picks an entry with Enter, Tab or the mouse.</summary>
	public Action<CompletionItem> Committed { get; set; }

	/// <summary>
	/// Shows the list anchored to a document position, flipping above the caret and clamping to the
	/// editor when there is not enough room below. The anchor is a document position rather than a
	/// pixel so the list follows the text when the view scrolls.
	/// </summary>
	public void Show( IReadOnlyList<CompletionItem> items, TextPosition anchor )
	{
		_items.Clear();

		if ( items is not null )
			_items.AddRange( items );

		if ( _items.Count == 0 )
		{
			Dismiss();
			return;
		}

		SelectedIndex = 0;
		_scroll = 0;
		_hover = -1;
		_anchor = anchor;

		Reanchor();

		Visible = true;
		Raise();
		Update();
	}

	/// <summary>Repositions the list under its anchor. Runs every frame so scrolling cannot detach it.</summary>
	[EditorEvent.Frame]
	void Reanchor()
	{
		if ( !Visible || Editor is not { IsValid: true } )
			return;

		var local = Editor.PositionToLocal( _anchor );

		if ( local == _anchorLocal )
			return;

		_anchorLocal = local;

		// Scrolled off the top or bottom of the viewport: the list has nothing to point at any more.
		if ( local.y < -Editor.LineHeight || local.y > Editor.Height )
		{
			Visible = false;
			return;
		}

		Reposition( local, Editor.LineHeight );
		Update();
	}

	/// <summary>
	/// Replaces the entries without moving the list, keeping the selection on the same label when it
	/// survived the refilter. This is what makes typing another character feel continuous.
	/// </summary>
	public void Refresh( IReadOnlyList<CompletionItem> items )
	{
		if ( items is null || items.Count == 0 )
		{
			Dismiss();
			return;
		}

		var previous = Selected?.Label;

		_items.Clear();
		_items.AddRange( items );

		SelectedIndex = 0;

		if ( previous is not null )
		{
			for ( var i = 0; i < _items.Count; i++ )
			{
				if ( !string.Equals( _items[i].Label, previous, StringComparison.Ordinal ) )
					continue;

				SelectedIndex = i;
				break;
			}
		}

		_scroll = Math.Clamp( _scroll, 0, Math.Max( 0, _items.Count - MaxVisibleRows ) );

		EnsureVisible();
		Resize();

		if ( Editor is { IsValid: true } )
			Reposition( Editor.PositionToLocal( _anchor ), Editor.LineHeight );

		Visible = true;
		Update();
	}

	/// <summary>Hides the list and drops its contents.</summary>
	public void Dismiss()
	{
		if ( !Visible && _items.Count == 0 )
			return;

		_items.Clear();
		SelectedIndex = -1;
		_hover = -1;
		_scroll = 0;
		Visible = false;
	}

	/// <summary>Moves the selection by a number of rows, clamping at both ends.</summary>
	public void Move( int delta )
	{
		if ( _items.Count == 0 )
			return;

		SelectedIndex = Math.Clamp( SelectedIndex + delta, 0, _items.Count - 1 );
		EnsureVisible();
		Update();
	}

	/// <summary>Moves the selection by a page.</summary>
	public void MovePage( int direction ) => Move( direction * ( MaxVisibleRows - 1 ) );

	/// <summary>Jumps to the first or last entry.</summary>
	public void MoveToEdge( bool end )
	{
		if ( _items.Count == 0 )
			return;

		SelectedIndex = end ? _items.Count - 1 : 0;
		EnsureVisible();
		Update();
	}

	/// <summary>Commits the highlighted entry.</summary>
	public void Commit()
	{
		var item = Selected;

		if ( item is null )
			return;

		PrismLog.Guard( "Prism.Text: commit completion", () => Committed?.Invoke( item ) );
	}

	// ---- geometry ---------------------------------------------------------

	void EnsureVisible()
	{
		if ( SelectedIndex < _scroll )
			_scroll = SelectedIndex;
		else if ( SelectedIndex >= _scroll + MaxVisibleRows )
			_scroll = SelectedIndex - MaxVisibleRows + 1;

		_scroll = Math.Clamp( _scroll, 0, Math.Max( 0, _items.Count - MaxVisibleRows ) );
	}

	void Measure()
	{
		PrismLog.Guard( "Prism.Text: measure completion font", () =>
		{
			using ( Paint.ToPixmap( new Pixmap( 8, 8 ) ) )
			{
				Paint.SetFont( PrismTheme.MonospaceFamily, PrismTheme.BodySize, 400, false, true );

				var size = Paint.MeasureText( new string( '0', 64 ) );

				if ( size.x > 1f )
					_charWidth = size.x / 64f;
			}
		} );
	}

	float DesiredWidth()
	{
		var widest = 0;

		for ( var i = 0; i < _items.Count; i++ )
		{
			var item = _items[i];
			var length = item.Label.Length + 2 + ( item.Detail?.Length ?? 0 );

			if ( length > widest )
				widest = length;
		}

		var width = Padding * 2f + IconWidth + KindWidth + Math.Min( widest, 96 ) * _charWidth;

		return Math.Clamp( width, MinWidth, MaxWidth );
	}

	void Resize()
	{
		var rows = Math.Min( _items.Count, MaxVisibleRows );
		var height = rows * RowHeight + FooterHeight + 2f;
		var width = DesiredWidth();

		if ( Editor is { IsValid: true } )
		{
			width = Math.Min( width, Math.Max( MinWidth, Editor.Width - 16f ) );
			height = Math.Min( height, Math.Max( RowHeight + FooterHeight, Editor.Height - 16f ) );
		}

		Size = new Vector2( width, height );
	}

	void Reposition( Vector2 caretLocal, float caretHeight )
	{
		Resize();

		if ( Editor is not { IsValid: true } )
			return;

		var x = Math.Clamp( caretLocal.x - IconWidth - Padding, 4f, Math.Max( 4f, Editor.Width - Width - 4f ) );
		var below = caretLocal.y + caretHeight + 2f;
		var y = below;

		if ( below + Height > Editor.Height - 4f )
		{
			var above = caretLocal.y - Height - 2f;

			y = above >= 4f ? above : Math.Max( 4f, Editor.Height - Height - 4f );
		}

		Position = new Vector2( x, y );
	}

	int RowAt( Vector2 local )
	{
		if ( local.y < 1f )
			return -1;

		var row = (int)MathF.Floor( ( local.y - 1f ) / RowHeight ) + _scroll;

		return row >= 0 && row < _items.Count && row < _scroll + MaxVisibleRows ? row : -1;
	}

	// ---- input ------------------------------------------------------------

	protected override void OnMouseMove( MouseEvent e )
	{
		var row = RowAt( e.LocalPosition );

		if ( row == _hover )
			return;

		_hover = row;
		Update();
	}

	protected override void OnMouseLeave()
	{
		if ( _hover < 0 )
			return;

		_hover = -1;
		Update();
	}

	protected override void OnMousePress( MouseEvent e )
	{
		var row = RowAt( e.LocalPosition );

		if ( row < 0 )
			return;

		SelectedIndex = row;
		Update();

		if ( e.LeftMouseButton )
			Commit();
	}

	protected override void OnMouseWheel( WheelEvent e )
	{
		if ( _items.Count <= MaxVisibleRows )
			return;

		_scroll = Math.Clamp( _scroll - Math.Sign( e.Delta ), 0, _items.Count - MaxVisibleRows );
		_hover = -1;
		e.Accept();
		Update();
	}

	// ---- painting ---------------------------------------------------------

	protected override void OnPaint()
	{
		var rect = LocalRect;

		PrismPaint.DropShadow( rect, PrismTheme.RadiusPopup, PrismTheme.Shadow );

		Paint.SetBrushAndPen( PrismTheme.Elevated, PrismTheme.BorderStrong );
		Paint.DrawRect( rect, PrismTheme.RadiusPopup );

		var last = Math.Min( _items.Count, _scroll + MaxVisibleRows );

		for ( var i = _scroll; i < last; i++ )
			PaintRow( _items[i], i, new Rect( rect.Left + 1f, rect.Top + 1f + ( i - _scroll ) * RowHeight,
				rect.Width - 2f, RowHeight ) );

		PaintFooter( new Rect( rect.Left + 1f, rect.Bottom - FooterHeight - 1f,
			rect.Width - 2f, FooterHeight ) );

		PaintScrollbar( rect );
	}

	void PaintRow( CompletionItem item, int index, Rect row )
	{
		var selected = index == SelectedIndex;
		var hovered = index == _hover;

		if ( selected )
		{
			PrismPaint.Pill( row.Shrink( 2f, 0f ), PrismTheme.AccentSoft, PrismTheme.RadiusChip );

			Paint.ClearPen();
			Paint.SetBrush( PrismTheme.Accent );
			Paint.DrawRect( new Rect( row.Left + 2f, row.Top + 3f, 2f, row.Height - 6f ), 1f );
		}
		else if ( hovered )
		{
			PrismPaint.Pill( row.Shrink( 2f, 0f ), PrismTheme.Code.Occurrence, PrismTheme.RadiusChip );
		}

		var inner = row.Shrink( Padding, 0f, Padding, 0f );

		if ( !string.IsNullOrEmpty( item.Icon ) )
		{
			Paint.SetPen( item.Deprecated ? PrismTheme.TextDisabled : item.Accent );
			Paint.DrawIcon( new Rect( inner.Left, inner.Top, IconWidth, inner.Height ), item.Icon, 13f,
				TextFlag.LeftCenter );
		}

		var text = new Rect( inner.Left + IconWidth, inner.Top,
			Math.Max( 10f, inner.Width - IconWidth - KindWidth ), inner.Height );

		var labelColour = item.Deprecated || item.Unavailable is not null
			? PrismTheme.TextMuted
			: selected ? PrismTheme.TextPrimary : PrismTheme.TextPrimary.WithAlpha( 0.92f );

		Paint.SetFont( PrismTheme.MonospaceFamily, PrismTheme.BodySize, selected ? 600 : 400, false, true );
		Paint.SetPen( labelColour );

		var labelWidth = Paint.MeasureText( item.Label ).x;

		Paint.DrawText( text, item.Label, TextFlag.LeftCenter );

		if ( item.Deprecated )
		{
			Paint.SetPen( PrismTheme.Error.WithAlpha( 0.7f ), 1f );
			Paint.DrawLine( new Vector2( text.Left, text.Center.y ),
				new Vector2( text.Left + labelWidth, text.Center.y ) );
		}

		if ( !string.IsNullOrEmpty( item.Detail ) )
		{
			var detail = new Rect( text.Left + labelWidth + 8f, text.Top,
				Math.Max( 0f, text.Width - labelWidth - 8f ), text.Height );

			if ( detail.Width > 16f )
			{
				Paint.SetFont( PrismTheme.MonospaceFamily, PrismTheme.BodySize - 1, 400, false, true );
				Paint.SetPen( PrismTheme.TextMuted );
				Paint.DrawText( detail, Paint.GetElidedText( item.Detail, detail.Width, ElideMode.Right,
					TextFlag.LeftCenter ), TextFlag.LeftCenter );
			}
		}

		var kind = new Rect( row.Right - Padding - KindWidth, row.Top, KindWidth, row.Height );

		Paint.SetFont( PrismTheme.FontFamily, 9, 500, false, true );
		Paint.SetPen( item.Accent.WithAlpha( selected ? 0.9f : 0.55f ) );
		Paint.DrawText( kind, item.KindLabel, TextFlag.RightCenter );
	}

	void PaintFooter( Rect footer )
	{
		PrismPaint.Divider( footer, PrismTheme.BorderSubtle );

		var item = Selected;

		if ( item is null )
			return;

		var inner = footer.Shrink( Padding, 0f, Padding, 0f );
		var error = item.Unavailable is not null;
		var text = error ? item.Unavailable : item.Documentation;

		if ( string.IsNullOrEmpty( text ) )
		{
			text = item.Origin;

			if ( string.IsNullOrEmpty( text ) )
				return;

			PrismPaint.Text( inner, text, PrismTheme.TextDisabled, 10, 400, TextFlag.LeftCenter, true );
			return;
		}

		if ( error )
		{
			Paint.SetPen( PrismTheme.Error );
			Paint.DrawIcon( new Rect( inner.Left, inner.Top, 14f, inner.Height ), "error", 12f,
				TextFlag.LeftCenter );

			inner.Left += 18f;
		}

		PrismPaint.Text( inner, text, error ? PrismTheme.Error : PrismTheme.TextSecondary, 10, 400 );
	}

	void PaintScrollbar( Rect rect )
	{
		if ( _items.Count <= MaxVisibleRows )
			return;

		var track = new Rect( rect.Right - 4f, rect.Top + 2f, 2f,
			rect.Height - FooterHeight - 4f );

		var fraction = MaxVisibleRows / (float)_items.Count;
		var offset = _scroll / (float)_items.Count;

		Paint.ClearPen();
		Paint.SetBrush( PrismTheme.BorderSubtle );
		Paint.DrawRect( track, 1f );

		Paint.SetBrush( PrismTheme.BorderStrong );
		Paint.DrawRect( new Rect( track.Left, track.Top + track.Height * offset, track.Width,
			Math.Max( 12f, track.Height * fraction ) ), 1f );
	}
}

/// <summary>
/// The glue between a <see cref="CodeEditorWidget"/>, a <see cref="CompletionEngine"/> and the two
/// popups. It is the only class that knows which key does what, so the popups stay dumb and the engine
/// stays pure.
/// <para>
/// Attach it once per editor and forget it: <c>CompletionHost.Attach( editor, path )</c> sets
/// <see cref="CodeEditorWidget.Completion"/> and the editor drives everything from there.
/// </para>
/// </summary>
public sealed class CompletionHost : ICodeCompletionHost, IDisposable
{
	readonly CodeEditorWidget _editor;

	CompletionPopup _popup;
	SignatureHelpPopup _signature;
	CompletionContext _context;
	CancellationTokenSource _external;
	int _generation;

	/// <summary>Creates a host over an editor. Prefer <see cref="Attach"/>, which also wires it up.</summary>
	public CompletionHost( CodeEditorWidget editor )
	{
		_editor = editor;
		Engine = new CompletionEngine();
		Engine.SetLanguage( editor?.Language );
	}

	/// <summary>
	/// Creates a host, points it at a file and installs it on the editor. Returns null for an invalid
	/// editor so a caller can always write <c>CompletionHost.Attach( … )</c> without a guard.
	/// </summary>
	public static CompletionHost Attach( CodeEditorWidget editor, string filePath = null )
	{
		if ( editor is not { IsValid: true } )
			return null;

		var host = new CompletionHost( editor )
		{
			FilePath = filePath ?? editor.Document?.FilePath
		};

		// The include search roots are built from mounted projects, which is editor state. Warm them
		// here so the first keystroke does not pay for it.
		PrismLog.Guard( "Prism.Text: warm include roots", IncludeResolver.Warm );

		// A Slang buffer gets the compiler's own opinion on top of ours, when the user happens to have
		// installed the language server. When they have not, this is null and nothing changes.
		if ( string.Equals( editor.Language, "slang", StringComparison.OrdinalIgnoreCase ) )
		{
			host.Engine.External = PrismLog.Guard( "Prism.Text: locate slangd",
				SlangdCompletionSource.CreateIfAvailable, null );
		}

		editor.Completion = host;

		return host;
	}

	/// <summary>The ranking engine. Exposed so a caller can retune it or plug in a language server.</summary>
	public CompletionEngine Engine { get; }

	/// <summary>The buffer's path, used to resolve includes. Keep it in step with Save As.</summary>
	public string FilePath
	{
		get => Engine.FilePath;
		set => Engine.FilePath = value;
	}

	/// <summary>Whether the list opens while typing. Ctrl+Space always works either way.</summary>
	public bool AutoOpen { get; set; } = true;

	/// <summary>How many characters must be typed before the list opens on its own.</summary>
	public int MinimumPrefix { get; set; } = 2;

	/// <summary>Whether signature help appears when a call is opened.</summary>
	public bool SignatureHelpEnabled { get; set; } = true;

	/// <inheritdoc/>
	public bool IsOpen => _popup is { IsOpen: true };

	/// <summary>True while the signature card is showing.</summary>
	public bool IsSignatureOpen => _signature is { IsOpen: true };

	// ---- ICodeCompletionHost ----------------------------------------------

	/// <inheritdoc/>
	public bool HandleKey( CodeEditorWidget editor, CodeKeyInfo key )
	{
		return PrismLog.Guard( "Prism.Text: completion key", () => HandleKeyCore( editor, key ), false );
	}

	bool HandleKeyCore( CodeEditorWidget editor, CodeKeyInfo key )
	{
		if ( IsOpen )
		{
			switch ( key.Key )
			{
				case KeyCode.Escape:
					_popup.Dismiss();
					return true;

				case KeyCode.Up:
					_popup.Move( -1 );
					return true;

				case KeyCode.Down:
					_popup.Move( 1 );
					return true;

				case KeyCode.PageUp:
					_popup.MovePage( -1 );
					return true;

				case KeyCode.PageDown:
					_popup.MovePage( 1 );
					return true;

				case KeyCode.Return:
				case KeyCode.Enter:
					Commit();
					return true;

				case KeyCode.Tab:
					if ( key.Shift )
						break;

					Commit();
					return true;

				case KeyCode.Left:
				case KeyCode.Right:
				case KeyCode.Home:
				case KeyCode.End:
					_popup.Dismiss();
					return false;
			}

			return false;
		}

		if ( IsSignatureOpen )
		{
			if ( key.Key == KeyCode.Escape )
			{
				_signature.Dismiss();
				return true;
			}

			if ( key.Ctrl && key.Key is KeyCode.Up or KeyCode.Down )
			{
				_signature.Cycle( key.Key == KeyCode.Up ? -1 : 1 );
				return true;
			}
		}

		return false;
	}

	/// <inheritdoc/>
	public void OnTextInserted( CodeEditorWidget editor, string text )
	{
		PrismLog.Guard( "Prism.Text: completion typed", () => TextInserted( text ) );
	}

	void TextInserted( string text )
	{
		if ( string.IsNullOrEmpty( text ) )
			return;

		if ( text.Contains( '\n' ) )
		{
			Dismiss();
			return;
		}

		var last = text[^1];

		switch ( last )
		{
			case '(':
			case ',':
				_popup?.Dismiss();
				ShowSignature();
				return;

			case ')':
				_signature?.Dismiss();
				_popup?.Dismiss();
				return;

			case ';':
			case '}':
				Dismiss();
				return;

			case '.':
			case '#':
			case ':':
			case '<':
			case '"':
			case '/':
				Open( false );
				UpdateSignature();
				return;
		}

		if ( TextEditController.IsWordChar( last ) )
		{
			if ( IsOpen )
				Refresh();
			else if ( AutoOpen )
				Open( false );

			UpdateSignature();
			return;
		}

		_popup?.Dismiss();
		UpdateSignature();
	}

	/// <inheritdoc/>
	public void OnCaretMoved( CodeEditorWidget editor )
	{
		PrismLog.Guard( "Prism.Text: completion caret", CaretMoved );
	}

	void CaretMoved()
	{
		if ( IsOpen )
		{
			var context = Engine.Classify( _editor );

			if ( !context.IsValid || context.Kind != _context?.Kind ||
				 context.Replace.Start != _context.Replace.Start )
			{
				_popup.Dismiss();
			}
			else
			{
				Refresh();
			}
		}

		UpdateSignature();
	}

	/// <inheritdoc/>
	public void RequestCompletion( CodeEditorWidget editor )
	{
		PrismLog.Guard( "Prism.Text: completion requested", () => Open( true ) );
	}

	/// <inheritdoc/>
	public void Dismiss()
	{
		_popup?.Dismiss();
		_signature?.Dismiss();

		CancelExternal();
	}

	// ---- driving ----------------------------------------------------------

	CompletionPopup Popup
	{
		get
		{
			if ( _popup is not { IsValid: true } )
			{
				_popup = new CompletionPopup( _editor ) { Committed = _ => Commit() };
			}

			return _popup;
		}
	}

	SignatureHelpPopup Signature
	{
		get
		{
			if ( _signature is not { IsValid: true } )
				_signature = new SignatureHelpPopup( _editor );

			return _signature;
		}
	}

	void Open( bool explicitRequest )
	{
		if ( _editor is not { IsValid: true } || _editor.ReadOnly )
			return;

		Engine.SetLanguage( _editor.Language );

		var context = Engine.Classify( _editor, explicitRequest );

		if ( !context.IsValid )
		{
			_popup?.Dismiss();
			return;
		}

		if ( !explicitRequest && RequiresPrefix( context.Kind ) && context.Prefix.Length < MinimumPrefix )
		{
			_popup?.Dismiss();
			return;
		}

		var items = Engine.Complete( _editor.Document, context );

		if ( items.Count == 0 )
		{
			_popup?.Dismiss();
			return;
		}

		_context = context;

		Popup.Show( items, context.Replace.Start );

		RequestExternal( context );
	}

	static bool RequiresPrefix( CompletionContextKind kind ) =>
		kind is CompletionContextKind.Global or CompletionContextKind.TypePosition;

	void Refresh()
	{
		if ( _editor is not { IsValid: true } || _popup is not { IsValid: true } )
			return;

		var context = Engine.Classify( _editor );

		if ( !context.IsValid )
		{
			_popup.Dismiss();
			return;
		}

		_context = context;

		var items = Engine.Complete( _editor.Document, context );

		_popup.Refresh( items );
	}

	void Commit()
	{
		var item = _popup?.Selected;

		if ( item is null || _editor is not { IsValid: true } )
			return;

		// Re-classify rather than trusting the range the list was built with: the buffer may have moved
		// under it, and an include path replaces more than the word the editor would guess.
		var context = Engine.Classify( _editor, true );
		var range = context.IsValid ? context.Replace : _editor.CompletionReplaceRange;
		var reopen = item.Kind == CompletionItemKind.Folder;

		_popup.Dismiss();
		CancelExternal();

		if ( !_editor.CommitCompletion( range, item.InsertText, item.CaretBack ) )
			return;

		if ( reopen )
			Open( true );
		else
			UpdateSignature();
	}

	void ShowSignature()
	{
		if ( !SignatureHelpEnabled || _editor is not { IsValid: true } )
			return;

		var help = Engine.SignatureAt( _editor.Document, _editor.CaretPosition, _editor.Language );

		if ( help is not { IsValid: true } )
		{
			_signature?.Dismiss();
			return;
		}

		Signature.Show( help, _editor.CaretPosition );
	}

	void UpdateSignature()
	{
		if ( !SignatureHelpEnabled )
			return;

		if ( _signature is not { IsOpen: true } )
			return;

		ShowSignature();
	}

	// ---- external source --------------------------------------------------

	void CancelExternal()
	{
		var cancellation = _external;

		_external = null;

		PrismLog.Guard( "Prism.Text: cancel external completion", () =>
		{
			cancellation?.Cancel();
			cancellation?.Dispose();
		} );
	}

	void RequestExternal( CompletionContext context )
	{
		var source = Engine.External;

		if ( source is null || !source.Available || _editor is not { IsValid: true } )
			return;

		if ( !string.Equals( _editor.Language, "slang", StringComparison.OrdinalIgnoreCase ) )
			return;

		CancelExternal();

		var cancellation = new CancellationTokenSource();

		_external = cancellation;

		var generation = ++_generation;
		var text = _editor.Document.Text;
		var caret = _editor.CaretPosition;
		var path = FilePath;
		var prefix = context.Prefix;

		_ = Task.Run( async () =>
		{
			IReadOnlyList<CompletionItem> extra;

			try
			{
				extra = await source.Complete( text, path, caret, cancellation.Token ).ConfigureAwait( false );
			}
			catch ( Exception )
			{
				// A language server is a bonus tier. It never gets to break typing.
				return;
			}

			if ( extra is not { Count: > 0 } || cancellation.IsCancellationRequested )
				return;

			MainThread.Queue( () => MergeExternal( generation, prefix, extra ) );
		} );
	}

	void MergeExternal( int generation, string prefix, IReadOnlyList<CompletionItem> extra )
	{
		if ( generation != _generation || !IsOpen || _context is null )
			return;

		if ( !string.Equals( _context.Prefix, prefix, StringComparison.Ordinal ) )
			return;

		PrismLog.Guard( "Prism.Text: merge external completions",
			() => _popup.Refresh( Engine.Merge( _popup.Items, extra, prefix ) ) );
	}

	/// <summary>Tears the host down and detaches it from the editor.</summary>
	public void Dispose()
	{
		CancelExternal();

		if ( _editor is { IsValid: true } && ReferenceEquals( _editor.Completion, this ) )
			_editor.Completion = null;

		if ( _popup is { IsValid: true } )
			_popup.Destroy();

		if ( _signature is { IsValid: true } )
			_signature.Destroy();

		_popup = null;
		_signature = null;

		if ( Engine.External is IDisposable disposable )
			PrismLog.Guard( "Prism.Text: dispose completion source", disposable.Dispose );
	}
}

/// <summary>
/// Everything the text editor's language intelligence gives one buffer, attached and detached as a
/// unit: completion and signature help, hover documentation, and debounced diagnostics.
/// <para>
/// One call turns a bare <see cref="CodeEditorWidget"/> into an editor that completes, explains and
/// checks: <c>var intel = CodeIntelligence.Attach( editor, path );</c>. Dispose it with the tab.
/// </para>
/// </summary>
public sealed class CodeIntelligence : IDisposable
{
	CodeIntelligence()
	{
	}

	/// <summary>Completion, signature help and the keyboard behaviour around them.</summary>
	public CompletionHost Completion { get; private set; }

	/// <summary>Hover documentation.</summary>
	public HoverController Hover { get; private set; }

	/// <summary>Debounced validation, pushed onto the editor's squiggles.</summary>
	public TextDiagnosticService Diagnostics { get; private set; }

	/// <summary>
	/// The buffer's path. Setting it keeps completion, hover and diagnostics in step after Save As,
	/// which is the one thing that is easy to forget and silently breaks include resolution.
	/// </summary>
	public string FilePath
	{
		get => Completion?.FilePath;
		set
		{
			if ( Completion is not null )
				Completion.FilePath = value;

			if ( Diagnostics is not null )
				Diagnostics.FilePath = value;

			if ( Hover is not null )
				Hover.Engine.FilePath = value;
		}
	}

	/// <summary>
	/// Attaches the whole stack to an editor. Returns null for an invalid editor, so a caller never
	/// needs a guard. Every part is independent: if one throws while starting, the others still work.
	/// </summary>
	public static CodeIntelligence Attach( CodeEditorWidget editor, string filePath = null,
		bool diagnostics = true )
	{
		if ( editor is not { IsValid: true } )
			return null;

		var intelligence = new CodeIntelligence();

		intelligence.Completion = PrismLog.Guard( "Prism.Text: attach completion",
			() => CompletionHost.Attach( editor, filePath ), null );

		intelligence.Hover = PrismLog.Guard( "Prism.Text: attach hover",
			() => HoverController.Attach( editor, intelligence.Completion?.Engine ), null );

		if ( diagnostics && !editor.ReadOnly )
		{
			intelligence.Diagnostics = PrismLog.Guard( "Prism.Text: attach diagnostics",
				() => TextDiagnosticService.Attach( editor, filePath ), null );
		}

		return intelligence;
	}

	/// <summary>Detaches everything. Safe to call more than once.</summary>
	public void Dispose()
	{
		PrismLog.Guard( "Prism.Text: detach diagnostics", () => Diagnostics?.Dispose() );
		PrismLog.Guard( "Prism.Text: detach hover", () => Hover?.Dispose() );
		PrismLog.Guard( "Prism.Text: detach completion", () => Completion?.Dispose() );

		Diagnostics = null;
		Hover = null;
		Completion = null;
	}
}