Editor/Prism/Text/CodeWindow.cs

Editor window and tabbed code editor UI. Implements CodeWindow which manages tabs (CodeTab), opening/saving/reloading files, diagnostics and outline docks, find/go-to, status bar and a painted CodeTabStrip; includes a simple CodeOutline scanner for symbols.

File Access
using Editor.Prism.Core;
using Editor.Prism.Integration;
using Editor.Prism.Ui;
using Margin = Sandbox.UI.Margin;
using System.IO;
using System.Text;
using PrismDiagnostic = Editor.Prism.Core.Diagnostic;

namespace Editor.Prism.Text;

/// <summary>One open file or buffer in the <see cref="CodeWindow"/>.</summary>
public sealed class CodeTab
{
	/// <summary>The editor widget showing this buffer.</summary>
	public CodeEditorWidget Editor { get; init; }

	/// <summary>The document. Shorthand for <c>Editor.Document</c>.</summary>
	public TextDocument Document => Editor?.Document;

	/// <summary>Absolute path on disk, or null for a synthetic buffer such as generated code.</summary>
	public string FilePath { get; set; }

	/// <summary>Tab caption.</summary>
	public string Title { get; set; } = "untitled";

	/// <summary>Language id driving highlighting.</summary>
	public string Language { get; set; } = "hlsl";

	/// <summary>Whether the buffer can be edited.</summary>
	public bool ReadOnly { get; set; }

	/// <summary>Whether the buffer differs from disk.</summary>
	public bool IsModified => Document is { IsModified: true };

	/// <summary>
	/// Set when the file changed on disk while this buffer had unsaved edits, so the tab can say the
	/// two have diverged. Cleared by a reload or a save.
	/// </summary>
	public bool ChangedOnDisk { get; set; }

	/// <summary>Caption with the modified and diverged markers, as drawn on the tab.</summary>
	public string DisplayTitle => ChangedOnDisk ? Title + " ⚠" : IsModified ? Title + " •" : Title;

	/// <summary>
	/// Completion, hover and background validation for this buffer. Owned by the tab and disposed with
	/// it, because every part of it holds a reference to the editor widget.
	/// </summary>
	internal Completion.CodeIntelligence Intelligence { get; set; }

	/// <summary>Cached tab width from the last paint, used for hit testing.</summary>
	internal Rect TabRect { get; set; }

	/// <summary>Diagnostic rendering.</summary>
	public override string ToString() => Title;
}

/// <summary>One entry in the outline dock.</summary>
public sealed record CodeSymbol( string Name, string Detail, int Line, string Icon, int Depth );

/// <summary>
/// The code editor window: a tab strip over a stack of <see cref="CodeEditorWidget"/>s, a find bar, a
/// diagnostics dock and an outline dock. This is the shell WP-11 owns; the graph window docks its own
/// generated-code panel separately.
/// </summary>
public sealed class CodeWindow : DockWindow
{
	static CodeWindow s_instance;

	readonly List<CodeTab> _tabs = new();

	CodeTabStrip _strip;
	FindReplaceBar _findBar;
	Widget _editorStack;
	ListView _diagnosticsList;
	ListView _outlineList;
	LineEdit _outlineFilter;
	Label _statusPosition;
	Label _statusSelection;
	Label _statusLanguage;
	Label _statusEncoding;
	Widget _diagnosticsPanel;
	Widget _outlinePanel;

	CodeTab _active;
	RealTimeSince _sinceOutlineRefresh;
	int _outlineVersion = -1;

	/// <summary>The live window, or null when it has never been opened or was closed.</summary>
	public static CodeWindow Instance => s_instance is { IsValid: true } ? s_instance : null;

	/// <summary>Opens the window, or raises it when it is already open.</summary>
	public static CodeWindow Open()
	{
		if ( Instance is not null )
		{
			Instance.Show();
			Instance.Focus();
			return Instance;
		}

		var window = new CodeWindow();
		window.Show();
		return window;
	}

	/// <summary>Opens a file in the window, creating the window if needed. Line and column are one-based.</summary>
	public static CodeWindow OpenFile( string absolutePath, int line = 0, int column = 1 )
	{
		var window = Open();

		if ( window is null )
			return null;

		var tab = window.OpenDocument( absolutePath );

		if ( tab is not null && line > 0 )
			tab.Editor.GoToLine( line, Math.Max( 1, column ) );

		return window;
	}

	/// <summary>Creates the window. Prefer <see cref="Open"/>.</summary>
	public CodeWindow()
	{
		s_instance = this;

		DeleteOnClose = true;
		Title = $"{PrismConstants.ProductName} — Code";
		Size = new Vector2( 1280, 820 );

		PrismLog.Guard( "Prism.Text: window icon", () => SetWindowIcon( "code" ) );

		BuildMenu();
		BuildStatusBar();

		var host = BuildHost();
		DockManager.SetCentralWidget( host );

		BuildDocks();

		// Assigning the cookie restores window geometry and the saved dock layout, so every dock has
		// to exist by now or the restore has nothing to place.
		StateCookie = "PrismCodeWindow";

		// External-change detection. AssetHooks watches the content folder; without this subscriber a
		// file edited in another program stayed stale in the buffer here and was silently overwritten
		// by the next save. Nothing reloads behind the user's back — an unmodified buffer refreshes in
		// place, a modified one is marked and says so.
		AssetHooks.ShaderSourceChangedOnDisk += OnFileChangedOnDisk;
		AssetHooks.DocumentChangedOnDisk += OnFileChangedOnDisk;

		UpdateStatus();
	}

	/// <summary>
	/// A file this window has open was changed by something else.
	/// <para>
	/// An untouched buffer is re-read on the spot: it has nothing to lose and showing stale text is
	/// strictly worse. A buffer with unsaved edits is left exactly as it is and the status bar says the
	/// file moved underneath it, because silently discarding the user's work — or silently keeping it
	/// and overwriting theirs — are both worse than telling them.
	/// </para>
	/// </summary>
	void OnFileChangedOnDisk( string absolutePath )
	{
		if ( string.IsNullOrWhiteSpace( absolutePath ) || !this.IsValid() ) return;

		PrismLog.Guard( "Handling an external file change", () =>
		{
			var full = Path.GetFullPath( absolutePath );

			foreach ( var tab in _tabs )
			{
				if ( tab?.FilePath is null ) continue;
				if ( !string.Equals( Path.GetFullPath( tab.FilePath ), full, StringComparison.OrdinalIgnoreCase ) ) continue;

				if ( tab.IsModified )
				{
					tab.ChangedOnDisk = true;

					_strip?.Update();
					StatusBar?.ShowMessage(
						$"\"{tab.Title}\" changed on disk and has unsaved edits — File ▸ Reload From Disk to take theirs" );

					continue;
				}

				ReloadTab( tab );
			}
		} );
	}

	/// <summary>Every open tab, in strip order.</summary>
	public IReadOnlyList<CodeTab> Tabs => _tabs;

	/// <summary>The tab currently showing, or null.</summary>
	public CodeTab ActiveTab => _active;

	/// <summary>The editor currently showing, or null.</summary>
	public CodeEditorWidget ActiveEditor => _active?.Editor;

	// ---- construction -----------------------------------------------------

	Widget BuildHost()
	{
		var host = new Widget( null );
		host.Layout = Layout.Column();
		host.Layout.Margin = 0;
		host.Layout.Spacing = 0;

		_strip = new CodeTabStrip( host );
		_strip.TabSelected = SetActiveTab;
		_strip.TabClosed = tab => CloseTab( tab );
		_strip.NewTabRequested = () => NewDocument();
		host.Layout.Add( _strip );

		_findBar = new FindReplaceBar( host );
		host.Layout.Add( _findBar );

		_editorStack = new Widget( host );
		_editorStack.Layout = Layout.Column();
		_editorStack.Layout.Margin = 0;
		host.Layout.Add( _editorStack, 1 );

		return host;
	}

	void BuildDocks()
	{
		_diagnosticsPanel = BuildDiagnosticsPanel();
		_outlinePanel = BuildOutlinePanel();

		DockManager.AddDock( "Diagnostics", "error_outline", _diagnosticsPanel, DockArea.Bottom );
		DockManager.AddDock( "Outline", "list", _outlinePanel, DockArea.Right );
	}

	Widget BuildDiagnosticsPanel()
	{
		var panel = new Widget( null );
		panel.Layout = Layout.Column();
		panel.Layout.Margin = 0;

		_diagnosticsList = new ListView( panel )
		{
			ItemSize = new Vector2( -1, 24 ),
			ItemPaint = PaintDiagnosticRow,
			ItemActivated = OnDiagnosticActivated,
			ItemClicked = OnDiagnosticActivated
		};

		panel.Layout.Add( _diagnosticsList, 1 );
		return panel;
	}

	Widget BuildOutlinePanel()
	{
		var panel = new Widget( null );
		panel.Layout = Layout.Column();
		panel.Layout.Margin = new Margin( 4, 4, 4, 4 );
		panel.Layout.Spacing = 4;

		_outlineFilter = new LineEdit( panel ) { PlaceholderText = "Filter symbols" };
		_outlineFilter.TextEdited += _ => RefreshOutline( true );
		panel.Layout.Add( _outlineFilter );

		_outlineList = new ListView( panel )
		{
			ItemSize = new Vector2( -1, 22 ),
			ItemPaint = PaintOutlineRow,
			ItemActivated = OnOutlineActivated,
			ItemClicked = OnOutlineActivated
		};

		panel.Layout.Add( _outlineList, 1 );
		return panel;
	}

	/// <summary>Places the docks in their default arrangement.</summary>
	protected override void BuildDefaultLayout()
	{
		var diagnostics = DockManager.OpenDock( "Diagnostics", DockArea.Bottom );
		var outline = DockManager.OpenDock( "Outline", DockArea.Right );

		PrismLog.Guard( "Prism.Text: default layout", () =>
		{
			DockManager.SetSplitterProportions( outline, 0.78f, 0.22f );
			DockManager.SetSplitterProportions( diagnostics, 0.76f, 0.24f );
		} );
	}

	void BuildStatusBar()
	{
		StatusBar = new StatusBar( this );

		_statusPosition = new Label( "Ln 1, Col 1" ) { Color = PrismTheme.TextSecondary };
		_statusSelection = new Label( "" ) { Color = PrismTheme.TextMuted };
		_statusLanguage = new Label( "" ) { Color = PrismTheme.TextSecondary };
		_statusEncoding = new Label( "" ) { Color = PrismTheme.TextMuted };

		StatusBar.AddWidgetLeft( _statusPosition );
		StatusBar.AddWidgetLeft( _statusSelection );
		StatusBar.AddWidgetRight( _statusLanguage );
		StatusBar.AddWidgetRight( _statusEncoding );
	}

	void BuildMenu()
	{
		var menu = new MenuBar( this );
		MenuBar = menu;

		menu.AddOption( "File/New", "note_add", () => NewDocument(), "Ctrl+N" );
		menu.AddOption( "File/Open…", "folder_open", PromptOpen, "Ctrl+O" );
		menu.AddSeparator();
		menu.AddOption( "File/Save", "save", () => SaveActive(), "Ctrl+S" );
		menu.AddOption( "File/Save As…", "save_as", PromptSaveAs );
		menu.AddOption( "File/Save All", "done_all", () => SaveAll(), "Ctrl+Shift+S" );
		menu.AddSeparator();
		menu.AddOption( "File/Reload From Disk", "refresh", () => ReloadTab( _active ) );
		menu.AddOption( "File/Open in External Editor", "open_in_new", OpenExternally );
		menu.AddSeparator();
		menu.AddOption( "File/Close Tab", "close", () => { if ( _active is not null ) CloseTab( _active ); }, "Ctrl+W" );
		menu.AddOption( "File/Close Window", "logout", Close );

		menu.AddOption( "Edit/Undo", "undo", () => WithEditor( e => { e.Controller.PerformUndo(); e.EnsureCaretVisible(); } ), "Ctrl+Z" );
		menu.AddOption( "Edit/Redo", "redo", () => WithEditor( e => { e.Controller.PerformRedo(); e.EnsureCaretVisible(); } ), "Ctrl+Shift+Z" );
		menu.AddSeparator();
		menu.AddOption( "Edit/Cut", "content_cut", () => WithEditor( e => e.Controller.Cut() ), "Ctrl+X" );
		menu.AddOption( "Edit/Copy", "content_copy", () => WithEditor( e => e.Controller.Copy() ), "Ctrl+C" );
		menu.AddOption( "Edit/Paste", "content_paste", () => WithEditor( e => e.Controller.Paste() ), "Ctrl+V" );
		menu.AddSeparator();
		menu.AddOption( "Edit/Find…", "search", () => ShowFind( false ), "Ctrl+F" );
		menu.AddOption( "Edit/Replace…", "find_replace", () => ShowFind( true ), "Ctrl+H" );
		menu.AddOption( "Edit/Go To Line…", "my_location", ShowGoToLine, "Ctrl+G" );
		menu.AddSeparator();
		menu.AddOption( "Edit/Toggle Comment", "comment", () => WithEditor( e => e.Controller.ToggleLineComment() ), "Ctrl+/" );
		menu.AddOption( "Edit/Toggle Block Comment", "notes", () => WithEditor( e => e.Controller.ToggleBlockComment() ) );
		menu.AddOption( "Edit/Trim Trailing Whitespace", "cleaning_services", () => WithEditor( e => e.Controller.TrimTrailingWhitespace() ) );

		AddToggle( menu, "View/Line Numbers", () => ActiveEditor?.ShowLineNumbers ?? true, value => ForEachEditor( e => e.ShowLineNumbers = value ) );
		AddToggle( menu, "View/Indent Guides", () => ActiveEditor?.ShowIndentGuides ?? true, value => ForEachEditor( e => e.ShowIndentGuides = value ) );
		AddToggle( menu, "View/Whitespace", () => ActiveEditor?.ShowWhitespace ?? false, value => ForEachEditor( e => e.ShowWhitespace = value ) );
		AddToggle( menu, "View/Current Line Highlight", () => ActiveEditor?.HighlightCurrentLine ?? true, value => ForEachEditor( e => e.HighlightCurrentLine = value ) );
		AddToggle( menu, "View/Occurrence Highlight", () => ActiveEditor?.HighlightOccurrences ?? true, value => ForEachEditor( e => e.HighlightOccurrences = value ) );
		AddToggle( menu, "View/Column Ruler", () => ActiveEditor?.ShowRuler ?? false, value => ForEachEditor( e => e.ShowRuler = value ) );
		menu.AddSeparator();
		menu.AddOption( "View/Zoom In", "zoom_in", () => ForEachEditor( e => e.FontSize++ ), "Ctrl++" );
		menu.AddOption( "View/Zoom Out", "zoom_out", () => ForEachEditor( e => e.FontSize-- ), "Ctrl+-" );
		menu.AddOption( "View/Reset Zoom", "search", () => ForEachEditor( e => e.FontSize = PrismTheme.CodeSize ) );
		menu.AddSeparator();
		menu.AddOption( "View/Fold All", "unfold_less", () => WithEditor( e => { e.Folding?.CollapseAll(); e.LayoutScrollbars(); e.Update(); } ) );
		menu.AddOption( "View/Unfold All", "unfold_more", () => WithEditor( e => { e.Folding?.ExpandAll(); e.LayoutScrollbars(); e.Update(); } ) );

		var view = menu.FindOrCreateMenu( "View" );

		if ( view is not null )
		{
			view.AddSeparator();
			var docks = view.AddMenu( "Panels", "dashboard" );
			docks.AboutToShow += () => CreateDynamicViewMenu( docks );
		}

		menu.AddOption( "Go/Next Problem", "arrow_downward", () => StepDiagnostic( 1 ), "F8" );
		menu.AddOption( "Go/Previous Problem", "arrow_upward", () => StepDiagnostic( -1 ), "Shift+F8" );
		menu.AddSeparator();
		menu.AddOption( "Go/Next Match", "navigate_next", () => _findBar?.FindNext(), "F3" );
		menu.AddOption( "Go/Previous Match", "navigate_before", () => _findBar?.FindNext( false ), "Shift+F3" );
	}

	static void AddToggle( MenuBar menu, string path, Func<bool> get, Action<bool> set )
	{
		var option = menu.AddOption( path, null, null );
		option.Checkable = true;
		option.FetchCheckedState = get;
		option.Toggled += set;
	}

	// ---- tabs -------------------------------------------------------------

	/// <summary>Creates an empty buffer and focuses it.</summary>
	public CodeTab NewDocument( string language = "hlsl" )
	{
		var tab = CreateTab( new TextDocument(), "untitled", null, language, false );
		SetActiveTab( tab );
		return tab;
	}

	/// <summary>
	/// Opens a file, focusing the existing tab when it is already open. Returns null when the file
	/// could not be read.
	/// </summary>
	public CodeTab OpenDocument( string absolutePath )
	{
		if ( string.IsNullOrWhiteSpace( absolutePath ) )
			return null;

		var full = PrismLog.Guard( "Prism.Text: resolve path", () => Path.GetFullPath( absolutePath ), absolutePath );

		for ( var i = 0; i < _tabs.Count; i++ )
		{
			if ( !string.Equals( _tabs[i].FilePath, full, StringComparison.OrdinalIgnoreCase ) )
				continue;

			SetActiveTab( _tabs[i] );
			return _tabs[i];
		}

		// Breadcrumbs. Opening a file walks straight into the engine's shader compiler, and a native fault
		// there takes the process down with no managed exception and nothing in the log — so the log has
		// to say how far we got before it happened.
		PrismLog.Info( $"Prism.Text: opening '{full}'" );

		var document = new TextDocument();

		if ( !document.Load( full ) )
		{
			StatusBar?.ShowMessage( $"Could not open {Path.GetFileName( full )}: {document.LoadError}" );
			return null;
		}

		var language = LanguageForPath( full );

		PrismLog.Info( $"Prism.Text: loaded {document.LineCount} line(s), language '{language}' — building the tab" );

		var tab = CreateTab( document, Path.GetFileName( full ), full, language, false );
		SetActiveTab( tab );

		PrismLog.Info( $"Prism.Text: '{Path.GetFileName( full )}' is open" );

		return tab;
	}

	/// <summary>Opens an in-memory buffer, such as generated shader text. Returns the new tab.</summary>
	public CodeTab OpenText( string title, string text, string language, bool readOnly = true )
	{
		for ( var i = 0; i < _tabs.Count; i++ )
		{
			if ( _tabs[i].FilePath is not null || !string.Equals( _tabs[i].Title, title, StringComparison.Ordinal ) )
				continue;

			_tabs[i].Editor.SetText( text, language );
			SetActiveTab( _tabs[i] );
			return _tabs[i];
		}

		var document = new TextDocument( text ?? string.Empty );
		document.MarkSaved();

		var tab = CreateTab( document, title, null, language, readOnly );
		SetActiveTab( tab );
		return tab;
	}

	CodeTab CreateTab( TextDocument document, string title, string path, string language, bool readOnly )
	{
		var editor = new CodeEditorWidget( _editorStack )
		{
			ReadOnly = readOnly
		};

		editor.SetDocument( document, language );
		editor.ReadOnly = readOnly;

		var tab = new CodeTab
		{
			Editor = editor,
			FilePath = path,
			Title = string.IsNullOrEmpty( title ) ? "untitled" : title,
			Language = language,
			ReadOnly = readOnly
		};

		editor.UnhandledKey = ( _, key ) => HandleWindowKey( key );
		editor.SaveRequested += _ => SaveTab( tab );
		editor.FindRequested += ( _, replace ) => ShowFind( replace );
		editor.GoToLineRequested += _ => ShowGoToLine();
		editor.FindStepRequested += ( _, direction ) => _findBar?.FindNext( direction >= 0 );
		editor.CaretMoved += _ => UpdateStatus();
		editor.TextChanged += _ =>
		{
			_strip?.Update();
			UpdateStatus();
		};

		// Completion, signature help, hover and background validation, all in one attach. A read-only
		// buffer still gets hover and highlighting, but not the compiler tier: it is generated text the
		// user cannot fix, and probe-compiling it on every keystroke it will never receive is waste.
		tab.Intelligence = PrismLog.Guard( "Prism.Text: attach code intelligence",
			() => Completion.CodeIntelligence.Attach( editor, path, !readOnly ), null );

		_tabs.Add( tab );
		_editorStack.Layout.Add( editor, 1 );
		editor.Visible = false;

		_strip.SetTabs( _tabs );
		return tab;
	}

	/// <summary>Shows one tab and hides the rest.</summary>
	public void SetActiveTab( CodeTab tab )
	{
		if ( tab is null || !_tabs.Contains( tab ) )
			return;

		_active = tab;

		for ( var i = 0; i < _tabs.Count; i++ )
			_tabs[i].Editor.Visible = ReferenceEquals( _tabs[i], tab );

		_findBar.Editor = tab.Editor;

		if ( _findBar.Visible )
			_findBar.Refresh();

		_strip.SetActive( tab );
		_outlineVersion = -1;

		RefreshDiagnostics();
		RefreshOutline( true );
		UpdateStatus();

		tab.Editor.Focus();
		tab.Editor.Update();
	}

	/// <summary>
	/// Closes a tab. A modified buffer raises a non-blocking prompt and returns false; answering the
	/// prompt closes the tab. Pass <paramref name="force"/> to skip the prompt.
	/// </summary>
	public bool CloseTab( CodeTab tab, bool force = false )
	{
		if ( tab is null || !_tabs.Contains( tab ) )
			return false;

		if ( !force && tab.IsModified && !tab.ReadOnly )
		{
			PromptUnsaved( $"\"{tab.Title}\" has unsaved changes.",
				() => { if ( SaveTab( tab ) ) CloseTab( tab, true ); },
				() => CloseTab( tab, true ) );

			return false;
		}

		var index = _tabs.IndexOf( tab );
		_tabs.Remove( tab );

		PrismLog.Guard( "Prism.Text: destroy editor", () =>
		{
			// Before the widget, not after: the completion popup, the hover watcher and the validator
			// all hold the editor and all unsubscribe from it on dispose.
			tab.Intelligence?.Dispose();
			tab.Intelligence = null;

			tab.Editor.Teardown();
			tab.Editor.Destroy();
		} );

		_strip.SetTabs( _tabs );

		if ( ReferenceEquals( _active, tab ) )
		{
			_active = null;

			if ( _tabs.Count > 0 )
				SetActiveTab( _tabs[Math.Clamp( index, 0, _tabs.Count - 1 )] );
			else
				UpdateStatus();
		}

		return true;
	}

	/// <summary>Saves the active tab.</summary>
	public bool SaveActive() => SaveTab( _active );

	/// <summary>Saves one tab, prompting for a path when it has none.</summary>
	public bool SaveTab( CodeTab tab )
	{
		if ( tab is null || tab.ReadOnly )
			return false;

		if ( string.IsNullOrEmpty( tab.FilePath ) )
			return SaveTabAs( tab );

		if ( !tab.Document.Save( tab.FilePath ) )
		{
			StatusBar?.ShowMessage( $"Could not save {tab.Title}: {tab.Document.SaveError}" );
			return false;
		}

		// Whatever the file said before, this buffer is now what is on disk.
		tab.ChangedOnDisk = false;

		StatusBar?.ShowMessage( $"Saved {tab.Title}" );
		_strip?.Update();
		UpdateStatus();
		return true;
	}

	/// <summary>Saves one tab to a path chosen by the user.</summary>
	public bool SaveTabAs( CodeTab tab )
	{
		if ( tab is null )
			return false;

		var dialog = new FileDialog( this ) { Title = "Save Shader Source" };
		dialog.SetModeSave();
		dialog.SetNameFilter( "Shader source (*.hlsl *.slang *.shader *.fxc *.h *.txt)" );
		dialog.DefaultSuffix = tab.Language == "slang" ? PrismConstants.SlangExtension : PrismConstants.HlslExtension;

		if ( !string.IsNullOrEmpty( tab.FilePath ) )
			dialog.SelectFile( tab.FilePath );

		if ( !dialog.Execute() )
			return false;

		var path = dialog.SelectedFile;

		if ( string.IsNullOrWhiteSpace( path ) )
			return false;

		if ( !tab.Document.Save( path ) )
		{
			StatusBar?.ShowMessage( $"Could not save: {tab.Document.SaveError}" );
			return false;
		}

		tab.FilePath = path;
		tab.Title = Path.GetFileName( path );
		tab.Language = LanguageForPath( path );
		tab.Editor.Language = tab.Language;

		// Include resolution is relative to the including file's own directory, so a buffer that just
		// moved resolves its includes from somewhere else now.
		if ( tab.Intelligence is not null ) tab.Intelligence.FilePath = path;

		_strip.SetTabs( _tabs );
		UpdateStatus();
		return true;
	}

	/// <summary>
	/// Re-reads a tab from disk, keeping the viewport. A modified buffer prompts first; answering the
	/// prompt performs the reload.
	/// </summary>
	public bool ReloadTab( CodeTab tab )
	{
		if ( tab?.FilePath is null )
			return false;

		if ( tab.IsModified )
		{
			PromptUnsaved( $"\"{tab.Title}\" has unsaved changes that reloading will discard.",
				() => { if ( SaveTab( tab ) ) ReloadTab( tab ); },
				() => { tab.Document.MarkSaved(); ReloadTab( tab ); } );

			return false;
		}

		var fresh = new TextDocument();

		if ( !fresh.Load( tab.FilePath ) )
		{
			StatusBar?.ShowMessage( $"Could not reload {tab.Title}: {fresh.LoadError}" );
			return false;
		}

		tab.Document.LineEnding = fresh.LineEnding;
		tab.Document.Encoding = fresh.Encoding;
		tab.Document.HasByteOrderMark = fresh.HasByteOrderMark;
		tab.Editor.SetText( fresh.Text, tab.Language );
		tab.ChangedOnDisk = false;

		StatusBar?.ShowMessage( $"Reloaded {tab.Title}" );
		_strip?.Update();
		UpdateStatus();
		return true;
	}

	/// <summary>Saves every modified tab that has a path.</summary>
	public int SaveAll()
	{
		var saved = 0;

		for ( var i = 0; i < _tabs.Count; i++ )
		{
			if ( _tabs[i].IsModified && !_tabs[i].ReadOnly && SaveTab( _tabs[i] ) )
				saved++;
		}

		return saved;
	}

	void PromptOpen()
	{
		var dialog = new FileDialog( this ) { Title = "Open Shader Source" };
		dialog.SetModeOpen();
		dialog.SetFindExistingFile();
		dialog.SetNameFilter( "Shader source (*.hlsl *.slang *.shader *.fxc *.h *.txt)" );

		if ( dialog.Execute() )
			OpenDocument( dialog.SelectedFile );
	}

	void PromptSaveAs() => SaveTabAs( _active );

	void OpenExternally()
	{
		var tab = _active;

		if ( tab?.FilePath is null )
			return;

		PrismLog.Guard( "Prism.Text: external editor",
			() => CodeEditor.OpenFile( tab.FilePath, tab.Editor.CaretPosition.Line + 1, tab.Editor.CaretPosition.Column + 1 ) );
	}

	/// <summary>Maps a file extension to a lexer language id.</summary>
	public static string LanguageForPath( string path )
	{
		if ( string.IsNullOrEmpty( path ) )
			return "hlsl";

		var extension = Path.GetExtension( path ).ToLowerInvariant();

		return extension switch
		{
			".slang" => "slang",
			".shader" => "vfx",
			".vfx" => "vfx",
			_ => "hlsl"
		};
	}

	// ---- find and navigation ----------------------------------------------

	/// <summary>Shows the find bar, optionally with the replace row.</summary>
	public void ShowFind( bool replace )
	{
		if ( _active is null )
			return;

		_findBar.Editor = _active.Editor;
		_findBar.Open( replace );
	}

	/// <summary>Opens the go-to-line prompt.</summary>
	public void ShowGoToLine()
	{
		var editor = ActiveEditor;

		if ( editor is null )
			return;

		var popup = new PopupWidget( this );
		popup.Layout = Layout.Row();
		popup.Layout.Margin = new Margin( 8, 6, 8, 6 );
		popup.Layout.Spacing = 6;

		popup.Layout.Add( new Label( $"Go to line (1 – {editor.Document.LineCount}):" ) );

		var entry = new LineEdit( popup ) { PlaceholderText = "line[:column]" };
		entry.MinimumWidth = 120;

		entry.ReturnPressed += () =>
		{
			var parts = (entry.Text ?? string.Empty).Split( ':', StringSplitOptions.RemoveEmptyEntries );

			if ( parts.Length > 0 && int.TryParse( parts[0].Trim(), out var line ) )
			{
				var column = 1;

				if ( parts.Length > 1 )
					int.TryParse( parts[1].Trim(), out column );

				editor.GoToLine( line, Math.Max( 1, column ) );
			}

			popup.Destroy();
		};

		popup.Layout.Add( entry );
		popup.OpenAtCursor();
		entry.Focus();
	}

	void StepDiagnostic( int direction )
	{
		var editor = ActiveEditor;

		if ( editor is null || editor.Diagnostics.Count == 0 )
			return;

		var ordered = new List<CodeDiagnostic>( editor.Diagnostics );
		ordered.Sort( static ( a, b ) => a.Range.Min.CompareTo( b.Range.Min ) );

		var caret = editor.CaretPosition;
		var target = -1;

		if ( direction >= 0 )
		{
			for ( var i = 0; i < ordered.Count; i++ )
			{
				if ( ordered[i].Range.Min > caret )
				{
					target = i;
					break;
				}
			}

			if ( target < 0 )
				target = 0;
		}
		else
		{
			for ( var i = ordered.Count - 1; i >= 0; i-- )
			{
				if ( ordered[i].Range.Min < caret )
				{
					target = i;
					break;
				}
			}

			if ( target < 0 )
				target = ordered.Count - 1;
		}

		editor.Reveal( ordered[target].Range, true, 4 );
		_diagnosticsList?.SelectItem( ordered[target] );
	}

	// ---- diagnostics ------------------------------------------------------

	/// <summary>Pushes pipeline diagnostics onto a tab and refreshes the dock.</summary>
	public void SetDiagnostics( CodeTab tab, IEnumerable<PrismDiagnostic> diagnostics )
	{
		if ( tab is null )
			return;

		tab.Editor.SetDiagnostics( diagnostics );

		if ( ReferenceEquals( tab, _active ) )
			RefreshDiagnostics();
	}

	/// <summary>Rebuilds the diagnostics dock from the active tab.</summary>
	public void RefreshDiagnostics()
	{
		if ( _diagnosticsList is not { IsValid: true } )
			return;

		var editor = ActiveEditor;

		if ( editor is null )
		{
			_diagnosticsList.SetItems( Array.Empty<object>() );
			return;
		}

		var items = new List<CodeDiagnostic>( editor.Diagnostics );
		items.Sort( static ( a, b ) =>
		{
			var bySeverity = b.Severity.CompareTo( a.Severity );
			return bySeverity != 0 ? bySeverity : a.Range.Min.CompareTo( b.Range.Min );
		} );

		_diagnosticsList.SetItems( items );
	}

	void OnDiagnosticActivated( object item )
	{
		if ( item is not CodeDiagnostic diagnostic || ActiveEditor is null )
			return;

		ActiveEditor.Reveal( diagnostic.Range, true, 4 );
		ActiveEditor.Focus();
	}

	void PaintDiagnosticRow( VirtualWidget item )
	{
		if ( item.Object is not CodeDiagnostic diagnostic )
			return;

		var rect = item.Rect;

		if ( item.Selected )
			item.PaintBackground( PrismTheme.AccentSoft, 3f );
		else if ( item.Hovered )
			item.PaintBackground( PrismTheme.PanelAlt, 3f );

		var color = PrismTheme.ForSeverity( diagnostic.Severity );

		Paint.SetPen( color );
		Paint.DrawIcon( new Rect( rect.Left + 4f, rect.Top, 18f, rect.Height ), IconFor( diagnostic.Severity ), 13f, TextFlag.Center );

		Paint.SetDefaultFont( PrismTheme.BodySize, 400, false, true );

		var lineText = $"{diagnostic.Range.Min.Line + 1}";
		Paint.SetPen( PrismTheme.TextMuted );
		Paint.DrawText( new Rect( rect.Left + 24f, rect.Top, 42f, rect.Height ), lineText, TextFlag.LeftCenter | TextFlag.SingleLine );

		if ( !string.IsNullOrEmpty( diagnostic.Code ) )
		{
			Paint.SetPen( PrismTheme.TextDisabled );
			Paint.DrawText( new Rect( rect.Left + 66f, rect.Top, 54f, rect.Height ), diagnostic.Code, TextFlag.LeftCenter | TextFlag.SingleLine );
		}

		Paint.SetPen( item.Selected ? PrismTheme.TextPrimary : PrismTheme.TextSecondary );
		Paint.DrawText( new Rect( rect.Left + 124f, rect.Top, rect.Width - 130f, rect.Height ),
			diagnostic.Message ?? string.Empty, TextFlag.LeftCenter | TextFlag.SingleLine );
	}

	static string IconFor( DiagnosticSeverity severity ) => severity switch
	{
		DiagnosticSeverity.Error => "error",
		DiagnosticSeverity.Warning => "warning",
		_ => "info"
	};

	// ---- outline ----------------------------------------------------------

	/// <summary>Rebuilds the outline dock from the active document.</summary>
	public void RefreshOutline( bool force = false )
	{
		if ( _outlineList is not { IsValid: true } )
			return;

		var editor = ActiveEditor;

		if ( editor is null )
		{
			_outlineList.SetItems( Array.Empty<object>() );
			return;
		}

		if ( !force && _outlineVersion == editor.Document.Version )
			return;

		_outlineVersion = editor.Document.Version;

		// The real parser rather than the regex fallback: it runs over the token stream the lexer has
		// already produced, so it is not fooled by a declaration inside a comment or a string, and it
		// knows about containers and parameters the regex pass cannot see.
		var symbols = PrismLog.Guard( "Prism.Text: outline",
			() => Completion.DocumentSymbols.For( editor.Document, editor.Language ).ToOutline(),
			null ) ?? CodeOutline.Scan( editor.Document );
		var filter = _outlineFilter?.Text;

		if ( !string.IsNullOrWhiteSpace( filter ) )
		{
			var narrowed = new List<CodeSymbol>();

			for ( var i = 0; i < symbols.Count; i++ )
			{
				if ( symbols[i].Name is not null &&
				     symbols[i].Name.Contains( filter, StringComparison.OrdinalIgnoreCase ) )
					narrowed.Add( symbols[i] );
			}

			symbols = narrowed;
		}

		_outlineList.SetItems( symbols );
	}

	void OnOutlineActivated( object item )
	{
		if ( item is not CodeSymbol symbol || ActiveEditor is null )
			return;

		ActiveEditor.GoToLine( symbol.Line + 1 );
	}

	void PaintOutlineRow( VirtualWidget item )
	{
		if ( item.Object is not CodeSymbol symbol )
			return;

		var rect = item.Rect;

		if ( item.Selected )
			item.PaintBackground( PrismTheme.AccentSoft, 3f );
		else if ( item.Hovered )
			item.PaintBackground( PrismTheme.PanelAlt, 3f );

		var indent = 6f + symbol.Depth * 12f;

		Paint.SetPen( PrismTheme.TextMuted );
		Paint.DrawIcon( new Rect( rect.Left + indent, rect.Top, 16f, rect.Height ), symbol.Icon, 12f, TextFlag.Center );

		Paint.SetDefaultFont( PrismTheme.BodySize, 400, false, true );
		Paint.SetPen( item.Selected ? PrismTheme.TextPrimary : PrismTheme.TextSecondary );
		Paint.DrawText( new Rect( rect.Left + indent + 20f, rect.Top, rect.Width - indent - 26f, rect.Height ),
			symbol.Name ?? string.Empty, TextFlag.LeftCenter | TextFlag.SingleLine );

		if ( string.IsNullOrEmpty( symbol.Detail ) )
			return;

		Paint.SetPen( PrismTheme.TextDisabled );
		Paint.DrawText( new Rect( rect.Left, rect.Top, rect.Width - 8f, rect.Height ),
			symbol.Detail, TextFlag.RightCenter | TextFlag.SingleLine );
	}

	// ---- status -----------------------------------------------------------

	void UpdateStatus()
	{
		if ( _statusPosition is not { IsValid: true } )
			return;

		var editor = ActiveEditor;

		if ( editor is null )
		{
			_statusPosition.Text = "";
			_statusSelection.Text = "";
			_statusLanguage.Text = "";
			_statusEncoding.Text = "";
			Title = $"{PrismConstants.ProductName} — Code";
			return;
		}

		var caret = editor.CaretPosition;
		_statusPosition.Text = $"Ln {caret.Line + 1}, Col {caret.Column + 1}";

		var selection = editor.Selection;
		var selected = 0;

		for ( var i = 0; i < selection.Count; i++ )
			selected += editor.Document.GetText( selection[i].Selection ).Length;

		if ( selection.Count > 1 )
			_statusSelection.Text = $"{selection.Count} carets · {selected} selected";
		else if ( selected > 0 )
			_statusSelection.Text = $"{selected} selected";
		else
			_statusSelection.Text = "";

		var indent = editor.Controller.UseTabs ? "Tabs" : $"Spaces: {editor.Controller.IndentSize}";
		_statusLanguage.Text = $"{editor.Language.ToUpperInvariant()} · {indent}";

		var ending = editor.Document.LineEnding switch
		{
			LineEndingStyle.Lf => "LF",
			LineEndingStyle.Cr => "CR",
			_ => "CRLF"
		};

		_statusEncoding.Text = $"{ending} · {(editor.Document.HasByteOrderMark ? "UTF-8 BOM" : "UTF-8")}";

		var title = _active?.DisplayTitle ?? string.Empty;
		Title = string.IsNullOrEmpty( title )
			? $"{PrismConstants.ProductName} — Code"
			: $"{title} — {PrismConstants.ProductName}";
	}

	/// <summary>
	/// Window-level accelerators, routed from the focused editor because it consumes every shortcut.
	/// Returns true when the key was consumed.
	/// </summary>
	bool HandleWindowKey( CodeKeyInfo key )
	{
		if ( key.Ctrl && !key.Alt )
		{
			switch ( key.Key )
			{
				case KeyCode.N when !key.Shift:
					NewDocument();
					return true;

				case KeyCode.O when !key.Shift:
					PromptOpen();
					return true;

				case KeyCode.W when !key.Shift:
					if ( _active is not null )
						CloseTab( _active );

					return true;

				case KeyCode.S when key.Shift:
					SaveAll();
					return true;

				case KeyCode.Tab:
				case KeyCode.Backtab:
					StepTab( key.Shift ? -1 : 1 );
					return true;
			}
		}

		if ( key.Key == KeyCode.F8 )
		{
			StepDiagnostic( key.Shift ? -1 : 1 );
			return true;
		}

		return false;
	}

	/// <summary>Moves to the next or previous tab, wrapping around.</summary>
	public void StepTab( int direction )
	{
		if ( _tabs.Count < 2 || _active is null )
			return;

		var index = _tabs.IndexOf( _active );

		if ( index < 0 )
			return;

		index = (index + direction + _tabs.Count) % _tabs.Count;
		SetActiveTab( _tabs[index] );
	}

	void WithEditor( Action<CodeEditorWidget> action )
	{
		var editor = ActiveEditor;

		if ( editor is null )
			return;

		PrismLog.Guard( "Prism.Text: command", () => action( editor ) );
		editor.Update();
	}

	void ForEachEditor( Action<CodeEditorWidget> action )
	{
		for ( var i = 0; i < _tabs.Count; i++ )
		{
			var editor = _tabs[i].Editor;

			if ( editor is { IsValid: true } )
				PrismLog.Guard( "Prism.Text: view option", () => action( editor ) );
		}
	}

	[EditorEvent.Frame]
	void CodeWindowFrame()
	{
		if ( !IsValid || _sinceOutlineRefresh < 0.75f )
			return;

		_sinceOutlineRefresh = 0;
		RefreshOutline();
	}

	/// <summary>Shows the non-blocking save / discard / cancel prompt.</summary>
	void PromptUnsaved( string message, Action onSave, Action onDiscard )
	{
		PrismLog.Guard( "Prism.Text: unsaved prompt", () =>
		{
			var popup = new PopupDialogWidget( "❓" );
			popup.WindowTitle = "Unsaved Changes";
			popup.MessageLabel.Text = message;

			popup.ButtonLayout.AddStretchCell();
			popup.ButtonLayout.Add( new Button( "Cancel" ) { Clicked = () => popup.Destroy() } );
			popup.ButtonLayout.Add( new Button( "Discard" ) { Clicked = () => { popup.Destroy(); onDiscard?.Invoke(); } } );
			popup.ButtonLayout.Add( new Button.Primary( "Save" ) { Clicked = () => { popup.Destroy(); onSave?.Invoke(); } } );

			popup.SetModal( true, true );
			popup.Hide();
			popup.Show();
		} );
	}

	bool _forceClose;

	protected override bool OnClose()
	{
		if ( _forceClose )
			return base.OnClose();

		var modified = 0;

		for ( var i = 0; i < _tabs.Count; i++ )
		{
			if ( _tabs[i].IsModified && !_tabs[i].ReadOnly )
				modified++;
		}

		if ( modified == 0 )
			return base.OnClose();

		PromptUnsaved( $"{modified} file(s) have unsaved changes.",
			() => { SaveAll(); _forceClose = true; Close(); },
			() => { _forceClose = true; Close(); } );

		return false;
	}

	protected override void OnClosed()
	{
		// AssetHooks is static and would otherwise pin this window, its tabs and every document in them
		// for the rest of the session.
		AssetHooks.ShaderSourceChangedOnDisk -= OnFileChangedOnDisk;
		AssetHooks.DocumentChangedOnDisk -= OnFileChangedOnDisk;

		if ( ReferenceEquals( s_instance, this ) )
			s_instance = null;

		base.OnClosed();
	}
}

/// <summary>
/// The tab strip. Painted rather than composed, so it matches <see cref="PrismTheme"/> exactly — the
/// engine's <c>TabBar</c> binding exposes no managed API at all.
/// </summary>
internal sealed class CodeTabStrip : Widget
{
	readonly List<CodeTab> _tabs = new();

	CodeTab _active;
	CodeTab _hovered;
	bool _hoverClose;
	float _scroll;

	public CodeTabStrip( Widget parent ) : base( parent )
	{
		FixedHeight = 30f;
		MouseTracking = true;
		Cursor = CursorShape.Finger;
	}

	public Action<CodeTab> TabSelected { get; set; }
	public Action<CodeTab> TabClosed { get; set; }
	public Action NewTabRequested { get; set; }

	public void SetTabs( IReadOnlyList<CodeTab> tabs )
	{
		_tabs.Clear();

		if ( tabs is not null )
			_tabs.AddRange( tabs );

		Update();
	}

	public void SetActive( CodeTab tab )
	{
		_active = tab;
		Update();
	}

	protected override void OnPaint()
	{
		Paint.ClearPen();
		Paint.SetBrush( PrismTheme.Panel );
		Paint.DrawRect( LocalRect );

		Paint.SetPen( PrismTheme.BorderSubtle, 1f );
		Paint.DrawLine( new Vector2( 0f, LocalRect.Bottom - 0.5f ), new Vector2( LocalRect.Right, LocalRect.Bottom - 0.5f ) );

		Paint.SetDefaultFont( PrismTheme.BodySize, 400, false, true );

		var x = 4f - _scroll;

		for ( var i = 0; i < _tabs.Count; i++ )
		{
			var tab = _tabs[i];
			var caption = tab.DisplayTitle;
			var width = Math.Clamp( Paint.MeasureText( caption ).x + 46f, 90f, 240f );

			var rect = new Rect( x, 3f, width, LocalRect.Height - 3f );
			tab.TabRect = rect;
			x += width + 2f;

			if ( rect.Right < 0f || rect.Left > LocalRect.Right )
				continue;

			var isActive = ReferenceEquals( tab, _active );
			var isHovered = ReferenceEquals( tab, _hovered );

			Paint.ClearPen();
			Paint.SetBrush( isActive ? PrismTheme.Code.Background : isHovered ? PrismTheme.PanelAlt : PrismTheme.Panel );
			Paint.DrawRect( rect, PrismTheme.RadiusChip );

			if ( isActive )
			{
				Paint.SetBrush( PrismTheme.Accent );
				Paint.DrawRect( new Rect( rect.Left, rect.Top, rect.Width, 2f ), 1f );
			}

			Paint.SetPen( isActive ? PrismTheme.TextPrimary : PrismTheme.TextSecondary );
			Paint.DrawText( new Rect( rect.Left + 10f, rect.Top, rect.Width - 34f, rect.Height ),
				caption, TextFlag.LeftCenter | TextFlag.SingleLine );

			var closeRect = CloseRect( rect );

			Paint.SetPen( isHovered && _hoverClose ? PrismTheme.Error : PrismTheme.TextMuted );
			Paint.DrawIcon( closeRect, "close", 12f, TextFlag.Center );
		}

		var plus = new Rect( x + 4f, 4f, 22f, LocalRect.Height - 8f );

		Paint.SetPen( PrismTheme.TextMuted );
		Paint.DrawIcon( plus, "add", 14f, TextFlag.Center );
	}

	static Rect CloseRect( Rect tabRect ) => new( tabRect.Right - 24f, tabRect.Top + 4f, 18f, tabRect.Height - 8f );

	protected override void OnMouseMove( MouseEvent e )
	{
		var previous = _hovered;
		var previousClose = _hoverClose;

		_hovered = HitTest( e.LocalPosition, out _hoverClose );

		if ( !ReferenceEquals( previous, _hovered ) || previousClose != _hoverClose )
			Update();
	}

	protected override void OnMouseLeave()
	{
		_hovered = null;
		_hoverClose = false;
		Update();
	}

	protected override void OnMousePress( MouseEvent e )
	{
		var tab = HitTest( e.LocalPosition, out var onClose );

		if ( tab is null )
		{
			if ( e.LeftMouseButton && e.LocalPosition.x > LastTabRight() )
				NewTabRequested?.Invoke();

			e.Accepted = true;
			return;
		}

		if ( e.MiddleMouseButton || (e.LeftMouseButton && onClose) )
			TabClosed?.Invoke( tab );
		else if ( e.LeftMouseButton )
			TabSelected?.Invoke( tab );

		e.Accepted = true;
	}

	protected override void OnMouseWheel( WheelEvent e )
	{
		_scroll = Math.Max( 0f, _scroll + (e.Delta > 0 ? -40f : 40f) );
		Update();
		e.Accept();
	}

	float LastTabRight() => _tabs.Count == 0 ? 4f : _tabs[^1].TabRect.Right;

	CodeTab HitTest( Vector2 local, out bool onClose )
	{
		onClose = false;

		for ( var i = 0; i < _tabs.Count; i++ )
		{
			if ( !_tabs[i].TabRect.IsInside( local ) )
				continue;

			onClose = CloseRect( _tabs[i].TabRect ).IsInside( local );
			return _tabs[i];
		}

		return null;
	}
}

/// <summary>
/// A deliberately small structural scanner that feeds the outline dock: VFX block headers, macros,
/// structs, constant buffers and top-level function definitions. It is a placeholder for the richer
/// document-symbol parser the language-intelligence package owns; swapping it out only changes this
/// file.
/// </summary>
internal static class CodeOutline
{
	static readonly string[] s_blocks =
	{
		"HEADER", "MODES", "FEATURES", "COMMON", "VS", "PS", "GS", "CS", "PS_RENDER_STATE", "RTX"
	};

	static readonly HashSet<string> s_notFunctions = new( StringComparer.Ordinal )
	{
		"if", "for", "while", "switch", "return", "else", "do", "case", "sizeof", "defined"
	};

	public static List<CodeSymbol> Scan( TextDocument document )
	{
		var symbols = new List<CodeSymbol>();

		if ( document is null )
			return symbols;

		var depth = 0;

		for ( var line = 0; line < document.LineCount; line++ )
		{
			var raw = document.GetLine( line );
			var text = raw.Trim();
			var startDepth = depth;

			depth += CountUnquoted( raw, '{' ) - CountUnquoted( raw, '}' );

			if ( text.Length == 0 || text.StartsWith( "//", StringComparison.Ordinal ) )
				continue;

			if ( startDepth == 0 && TryBlock( text, out var block ) )
			{
				symbols.Add( new CodeSymbol( block, "block", line, "widgets", 0 ) );
				continue;
			}

			if ( text.StartsWith( "#define ", StringComparison.Ordinal ) )
			{
				var name = ReadIdentifier( text, 8 );

				if ( !string.IsNullOrEmpty( name ) )
					symbols.Add( new CodeSymbol( name, "define", line, "tag", startDepth > 0 ? 1 : 0 ) );

				continue;
			}

			if ( text.StartsWith( "struct ", StringComparison.Ordinal ) )
			{
				var name = ReadIdentifier( text, 7 );

				if ( !string.IsNullOrEmpty( name ) )
					symbols.Add( new CodeSymbol( name, "struct", line, "data_object", startDepth > 0 ? 1 : 0 ) );

				continue;
			}

			if ( text.StartsWith( "cbuffer ", StringComparison.Ordinal ) )
			{
				var name = ReadIdentifier( text, 8 );

				if ( !string.IsNullOrEmpty( name ) )
					symbols.Add( new CodeSymbol( name, "cbuffer", line, "view_list", startDepth > 0 ? 1 : 0 ) );

				continue;
			}

			if ( startDepth > 1 )
				continue;

			if ( TryFunction( text, out var function, out var signature ) )
				symbols.Add( new CodeSymbol( function, signature, line, "functions", startDepth > 0 ? 1 : 0 ) );
		}

		return symbols;
	}

	static bool TryBlock( string text, out string block )
	{
		block = null;

		var candidate = text.EndsWith( "{", StringComparison.Ordinal ) ? text[..^1].Trim() : text;

		for ( var i = 0; i < s_blocks.Length; i++ )
		{
			if ( !string.Equals( candidate, s_blocks[i], StringComparison.Ordinal ) )
				continue;

			block = s_blocks[i];
			return true;
		}

		return false;
	}

	static bool TryFunction( string text, out string name, out string signature )
	{
		name = null;
		signature = null;

		if ( text.StartsWith( "#", StringComparison.Ordinal ) )
			return false;

		var open = text.IndexOf( '(' );

		if ( open <= 0 )
			return false;

		if ( text.EndsWith( ";", StringComparison.Ordinal ) )
			return false;

		var head = text[..open].Trim();

		if ( head.Length == 0 )
			return false;

		var lastSpace = head.LastIndexOfAny( new[] { ' ', '\t', '*', '&', ':' } );

		if ( lastSpace <= 0 || lastSpace >= head.Length - 1 )
			return false;

		var candidate = head[(lastSpace + 1)..].Trim();

		if ( candidate.Length == 0 || s_notFunctions.Contains( candidate ) )
			return false;

		for ( var i = 0; i < candidate.Length; i++ )
		{
			if ( !char.IsLetterOrDigit( candidate[i] ) && candidate[i] != '_' )
				return false;
		}

		name = candidate;
		signature = head[..lastSpace].Trim();
		return true;
	}

	static string ReadIdentifier( string text, int start )
	{
		var index = start;

		while ( index < text.Length && char.IsWhiteSpace( text[index] ) )
			index++;

		var builder = new StringBuilder();

		while ( index < text.Length && (char.IsLetterOrDigit( text[index] ) || text[index] == '_') )
			builder.Append( text[index++] );

		return builder.ToString();
	}

	static int CountUnquoted( string text, char target )
	{
		var count = 0;
		var inString = false;

		for ( var i = 0; i < text.Length; i++ )
		{
			var c = text[i];

			if ( c == '"' && (i == 0 || text[i - 1] != '\\') )
			{
				inString = !inString;
				continue;
			}

			if ( inString )
				continue;

			if ( c == '/' && i + 1 < text.Length && text[i + 1] == '/' )
				break;

			if ( c == target )
				count++;
		}

		return count;
	}
}