From 0b04fa131c175c92073ad0618c8bbcaccf0000b8 Mon Sep 17 00:00:00 2001 From: Tom Brewer Date: Fri, 9 Jan 2026 15:28:20 -0700 Subject: [PATCH] feat: add razor highlighting support --- Apollo.Analysis.Worker/Program.cs | 19 + Apollo.Analysis/Apollo.Analysis.csproj | 1 + Apollo.Analysis/MonacoService.cs | 72 +- Apollo.Analysis/RazorCodeExtractor.cs | 162 ++++ Apollo.Analysis/RazorSemanticTokenService.cs | 795 ++++++++++++++++++ .../Analysis/CodeAnalysisWorkerProxy.cs | 35 +- Apollo.Client/Layout/MainLayout.razor | 83 +- Apollo.Client/Program.cs | 2 + Apollo.Client/wwwroot/index.html | 1 + .../Analysis/CodeAnalysisState.cs | 54 +- .../Analysis/ICodeAnalysisWorker.cs | 2 + .../Editor/ApolloCodeEditor.razor | 79 +- .../Solutions/Consumers/SolutionBuilder.cs | 19 - Apollo.Components/Solutions/SolutionsState.cs | 2 +- Apollo.Components/Theme/CustomThemeService.cs | 10 +- Apollo.Components/wwwroot/apolloEditor.js | 229 +++++ .../Analysis/SemanticTokenContracts.cs | 160 ++++ .../Webcil/WebcilWasmWrapper.cs | 2 +- Directory.Packages.props | 21 +- 19 files changed, 1654 insertions(+), 94 deletions(-) create mode 100644 Apollo.Analysis/RazorCodeExtractor.cs create mode 100644 Apollo.Analysis/RazorSemanticTokenService.cs delete mode 100644 Apollo.Components/Solutions/Consumers/SolutionBuilder.cs create mode 100644 Apollo.Components/wwwroot/apolloEditor.js create mode 100644 Apollo.Contracts/Analysis/SemanticTokenContracts.cs diff --git a/Apollo.Analysis.Worker/Program.cs b/Apollo.Analysis.Worker/Program.cs index 4bd51a8..7542537 100644 --- a/Apollo.Analysis.Worker/Program.cs +++ b/Apollo.Analysis.Worker/Program.cs @@ -206,6 +206,25 @@ loggerBridge.LogTrace($"Error updating user assembly: {ex.Message}"); } break; + + case "get_semantic_tokens": + try + { + loggerBridge.LogDebug("Received semantic tokens request"); + var semanticTokensResult = await monacoService.GetSemanticTokensAsync(message.Payload); + var semanticTokensResponse = new WorkerMessage + { + Action = "semantic_tokens_response", + Payload = Convert.ToBase64String(semanticTokensResult) + }; + Imports.PostMessage(semanticTokensResponse.ToSerialized()); + loggerBridge.LogDebug("Semantic tokens response sent"); + } + catch (Exception ex) + { + loggerBridge.LogTrace($"Error getting semantic tokens: {ex.Message}"); + } + break; } } catch (Exception ex) diff --git a/Apollo.Analysis/Apollo.Analysis.csproj b/Apollo.Analysis/Apollo.Analysis.csproj index 2c29feb..d2745ae 100644 --- a/Apollo.Analysis/Apollo.Analysis.csproj +++ b/Apollo.Analysis/Apollo.Analysis.csproj @@ -29,6 +29,7 @@ + diff --git a/Apollo.Analysis/MonacoService.cs b/Apollo.Analysis/MonacoService.cs index 55c37c8..9697c18 100644 --- a/Apollo.Analysis/MonacoService.cs +++ b/Apollo.Analysis/MonacoService.cs @@ -25,12 +25,15 @@ public class MonacoService private readonly IMetadataReferenceResolver _resolver; private readonly ILoggerProxy _workerLogger; private readonly RoslynProjectService _projectService; - + private RoslynProject? _legacyCompletionProject; private OmniSharpCompletionService? _legacyCompletionService; private OmniSharpSignatureHelpService? _signatureService; private OmniSharpQuickInfoProvider? _quickInfoProvider; + private RazorCodeExtractor? _razorExtractor; + private RazorSemanticTokenService? _razorSemanticTokenService; + private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase @@ -76,7 +79,11 @@ public async Task Init(string uri) _signatureService = new OmniSharpSignatureHelpService(_projectService.Workspace); _quickInfoProvider = new OmniSharpQuickInfoProvider(_projectService.Workspace, formattingOptions, loggerFactory); - + + // Initialize Razor services + _razorExtractor = new RazorCodeExtractor(_workerLogger); + _razorSemanticTokenService = new RazorSemanticTokenService(_razorExtractor, _projectService, _workerLogger); + _workerLogger.Trace("RoslynProjectService initialized successfully"); } @@ -365,6 +372,67 @@ public async Task HandleUserAssemblyUpdateAsync(string requestJson) } } + public async Task GetSemanticTokensAsync(string requestJson) + { + try + { + var request = JsonSerializer.Deserialize(requestJson, _jsonOptions); + if (request == null) + { + _workerLogger.LogError("Failed to deserialize semantic tokens request"); + return []; + } + + _workerLogger.LogTrace($"Semantic tokens request for {request.DocumentUri}"); + + // Check if this is a Razor file + var isRazorFile = request.DocumentUri.EndsWith(".razor", StringComparison.OrdinalIgnoreCase) || + request.DocumentUri.EndsWith(".cshtml", StringComparison.OrdinalIgnoreCase); + + if (!isRazorFile) + { + _workerLogger.LogTrace($"Not a Razor file: {request.DocumentUri}"); + return Encoding.UTF8.GetBytes(JsonSerializer.Serialize( + new ResponsePayload(SemanticTokensResult.Empty, "GetSemanticTokensAsync"), + _jsonOptions)); + } + + if (_razorSemanticTokenService == null) + { + _workerLogger.LogError("Razor semantic token service not initialized"); + return Encoding.UTF8.GetBytes(JsonSerializer.Serialize( + new ResponsePayload(SemanticTokensResult.Empty, "GetSemanticTokensAsync"), + _jsonOptions)); + } + + if (string.IsNullOrEmpty(request.RazorContent)) + { + _workerLogger.LogTrace("No Razor content provided"); + return Encoding.UTF8.GetBytes(JsonSerializer.Serialize( + new ResponsePayload(SemanticTokensResult.Empty, "GetSemanticTokensAsync"), + _jsonOptions)); + } + + var result = await _razorSemanticTokenService.GetSemanticTokensAsync( + request.RazorContent, + request.DocumentUri); + + _workerLogger.LogTrace($"Returning {result.Data.Length / 5} semantic tokens"); + + return Encoding.UTF8.GetBytes(JsonSerializer.Serialize( + new ResponsePayload(result, "GetSemanticTokensAsync"), + _jsonOptions)); + } + catch (Exception ex) + { + _workerLogger.LogError($"Error getting semantic tokens: {ex.Message}"); + _workerLogger.LogTrace(ex.StackTrace ?? string.Empty); + return Encoding.UTF8.GetBytes(JsonSerializer.Serialize( + new ResponsePayload(SemanticTokensResult.Empty, "GetSemanticTokensAsync"), + _jsonOptions)); + } + } + private byte[] CreateSuccessResponse(string message) { var response = new { Success = true, Message = message }; diff --git a/Apollo.Analysis/RazorCodeExtractor.cs b/Apollo.Analysis/RazorCodeExtractor.cs new file mode 100644 index 0000000..fbf1433 --- /dev/null +++ b/Apollo.Analysis/RazorCodeExtractor.cs @@ -0,0 +1,162 @@ +using System.Collections.Immutable; +using Apollo.Infrastructure.Workers; +using Microsoft.AspNetCore.Razor.Language; + +namespace Apollo.Analysis; + +/// +/// Extracts C# code from Razor files using the Razor compiler. +/// Provides source mappings to translate positions between generated C# and original Razor. +/// +public class RazorCodeExtractor +{ + private readonly ILoggerProxy _logger; + + public RazorCodeExtractor(ILoggerProxy logger) + { + _logger = logger; + } + + /// + /// Parse a Razor file and return the extraction result containing generated C# and source mappings. + /// + public RazorExtractionResult Extract(string razorContent, string filePath) + { + try + { + // Create a minimal file system for the Razor engine + var fileSystem = new VirtualRazorProjectFileSystem(); + + // Determine file kind based on extension + var fileKind = filePath.EndsWith(".cshtml", StringComparison.OrdinalIgnoreCase) + ? FileKinds.Legacy + : FileKinds.Component; + + // Create the Razor project engine with component configuration + var projectEngine = RazorProjectEngine.Create( + RazorConfiguration.Default, + fileSystem, + builder => + { + // Configure for Blazor component compilation + builder.SetRootNamespace("Apollo.Generated"); + }); + + // Create a source document from the Razor content + var sourceDocument = RazorSourceDocument.Create(razorContent, filePath); + + // Create and process the code document + var codeDocument = projectEngine.Process( + sourceDocument, + fileKind, + ImmutableArray.Empty, + tagHelpers: null); + + // Get the generated C# document + var csharpDocument = codeDocument.GetCSharpDocument(); + if (csharpDocument == null) + { + _logger.LogTrace($"Failed to generate C# from Razor file: {filePath}"); + return RazorExtractionResult.Empty; + } + + // Log any diagnostics from Razor compilation + foreach (var diagnostic in csharpDocument.Diagnostics) + { + _logger.LogTrace($"Razor diagnostic in {filePath}: {diagnostic.GetMessage()}"); + } + + // Get syntax tree + var syntaxTree = codeDocument.GetSyntaxTree(); + + return new RazorExtractionResult + { + GeneratedCode = csharpDocument.GeneratedCode, + SourceMappings = csharpDocument.SourceMappings.ToList(), + SyntaxTree = syntaxTree + }; + } + catch (Exception ex) + { + _logger.LogTrace($"Error extracting C# from Razor file {filePath}: {ex.Message}"); + return RazorExtractionResult.Empty; + } + } +} + +/// +/// Result of Razor extraction containing generated C# code and source mappings. +/// +public class RazorExtractionResult +{ + /// + /// The generated C# code that can be analyzed by Roslyn. + /// + public string GeneratedCode { get; init; } = ""; + + /// + /// Source mappings that map spans in generated C# back to original Razor positions. + /// + public List SourceMappings { get; init; } = []; + + /// + /// The Razor syntax tree for component detection. + /// + public RazorSyntaxTree? SyntaxTree { get; init; } + + /// + /// An empty extraction result. + /// + public static RazorExtractionResult Empty => new(); + + /// + /// Whether this result has valid generated code. + /// + public bool IsEmpty => string.IsNullOrEmpty(GeneratedCode); +} + +/// +/// A virtual file system implementation for the Razor project engine. +/// Since we're processing in-memory content, we don't need actual file system access. +/// +internal class VirtualRazorProjectFileSystem : RazorProjectFileSystem +{ + public override IEnumerable EnumerateItems(string basePath) + { + return Enumerable.Empty(); + } + + public override RazorProjectItem GetItem(string path) + { + return new NotFoundProjectItem(string.Empty, path, FileKinds.Component); + } + + public override RazorProjectItem GetItem(string path, string? fileKind) + { + return new NotFoundProjectItem(string.Empty, path, fileKind ?? FileKinds.Component); + } +} + +/// +/// Represents a project item that was not found. +/// +internal class NotFoundProjectItem : RazorProjectItem +{ + public NotFoundProjectItem(string basePath, string path, string fileKind) + { + BasePath = basePath; + FilePath = path; + FileKind = fileKind; + } + + public override string BasePath { get; } + public override string FilePath { get; } + public override string FileKind { get; } + public override bool Exists => false; + public override string PhysicalPath => FilePath; + + public override Stream Read() + { + throw new InvalidOperationException("Item does not exist"); + } +} diff --git a/Apollo.Analysis/RazorSemanticTokenService.cs b/Apollo.Analysis/RazorSemanticTokenService.cs new file mode 100644 index 0000000..e3afe39 --- /dev/null +++ b/Apollo.Analysis/RazorSemanticTokenService.cs @@ -0,0 +1,795 @@ +using System.Text.RegularExpressions; +using Apollo.Contracts.Analysis; +using Apollo.Infrastructure.Workers; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Classification; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; + +namespace Apollo.Analysis; + +/// +/// Provides semantic token classification for Razor files. +/// Uses the Razor compiler to extract C#, classifies with Roslyn, and maps back to Razor positions. +/// +public partial class RazorSemanticTokenService +{ + private readonly RazorCodeExtractor _razorExtractor; + private readonly RoslynProjectService _projectService; + private readonly ILoggerProxy _logger; + + public RazorSemanticTokenService( + RazorCodeExtractor razorExtractor, + RoslynProjectService projectService, + ILoggerProxy logger) + { + _razorExtractor = razorExtractor; + _projectService = projectService; + _logger = logger; + } + + /// + /// Get semantic tokens for a Razor document. + /// + public async Task GetSemanticTokensAsync( + string razorContent, + string filePath, + CancellationToken cancellationToken = default) + { + _logger.LogTrace($"Getting semantic tokens for Razor file: {filePath}"); + + var extraction = _razorExtractor.Extract(razorContent, filePath); + if (extraction.IsEmpty) + { + _logger.LogTrace($"No generated code from Razor extraction for {filePath}"); + + return await GetRazorOnlyTokensAsync(razorContent); + } + + _logger.LogTrace($"Generated {extraction.GeneratedCode.Length} chars of C# with {extraction.SourceMappings.Count} source mappings"); + + var classifiedSpans = await ClassifyGeneratedCodeAsync( + extraction.GeneratedCode, + cancellationToken); + + _logger.LogTrace($"Got {classifiedSpans.Count} classified spans from Roslyn"); + + var razorTokens = MapToRazorPositions( + classifiedSpans, + extraction.SourceMappings, + extraction.GeneratedCode, + razorContent); + + _logger.LogTrace($"Mapped {razorTokens.Count} tokens to Razor positions"); + + var componentTokens = DetectRazorComponents(razorContent); + razorTokens.AddRange(componentTokens); + + _logger.LogTrace($"Detected {componentTokens.Count} Razor components"); + + var directiveTokens = DetectRazorDirectives(razorContent); + razorTokens.AddRange(directiveTokens); + + _logger.LogTrace($"Detected {directiveTokens.Count} Razor directives"); + + var attrExprTokens = DetectAttributeExpressions(razorContent); + razorTokens.AddRange(attrExprTokens); + + _logger.LogTrace($"Detected {attrExprTokens.Count} attribute expression tokens"); + + var sortedTokens = razorTokens + .OrderBy(t => t.Line) + .ThenBy(t => t.Character) + .ToList(); + + var encodedTokens = EncodeSemanticTokens(sortedTokens); + + _logger.LogTrace($"Encoded {encodedTokens.Length / 5} semantic tokens for Razor file"); + + return new SemanticTokensResult + { + Data = encodedTokens, + ResultId = Guid.NewGuid().ToString() + }; + } + + /// + /// Get tokens for Razor-specific syntax only (components, directives). + /// + private Task GetRazorOnlyTokensAsync(string razorContent) + { + var razorTokens = new List(); + + razorTokens.AddRange(DetectRazorComponents(razorContent)); + razorTokens.AddRange(DetectRazorDirectives(razorContent)); + razorTokens.AddRange(DetectAttributeExpressions(razorContent)); + + var sortedTokens = razorTokens + .OrderBy(t => t.Line) + .ThenBy(t => t.Character) + .ToList(); + + var encodedTokens = EncodeSemanticTokens(sortedTokens); + + return Task.FromResult(new SemanticTokensResult + { + Data = encodedTokens, + ResultId = Guid.NewGuid().ToString() + }); + } + + /// + /// Classify the generated C# code using Roslyn's Classifier API. + /// + private async Task> ClassifyGeneratedCodeAsync( + string generatedCode, + CancellationToken cancellationToken) + { + try + { + var workspace = _projectService.Workspace; + if (workspace == null) + { + _logger.LogTrace("Workspace not available for Razor classification"); + return []; + } + + var syntaxTree = CSharpSyntaxTree.ParseText( + generatedCode, + new CSharpParseOptions(LanguageVersion.Latest), + cancellationToken: cancellationToken); + + var references = _projectService.GetAllReferences(); + + var compilation = CSharpCompilation.Create( + "RazorGenerated", + [syntaxTree], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var sourceText = await syntaxTree.GetTextAsync(cancellationToken); + var textSpan = TextSpan.FromBounds(0, sourceText.Length); + + var project = workspace.CurrentSolution.Projects.FirstOrDefault(); + if (project == null) + { + _logger.LogTrace("No project available for Razor classification"); + return []; + } + + var documentId = DocumentId.CreateNewId(project.Id); + var solution = workspace.CurrentSolution.AddDocument( + documentId, + "RazorGenerated.cs", + sourceText); + var document = solution.GetDocument(documentId); + + if (document == null) + { + _logger.LogTrace("Failed to create document for Razor classification"); + return []; + } + + var classifiedSpans = await Classifier.GetClassifiedSpansAsync( + document, + textSpan, + cancellationToken); + + return classifiedSpans + .Where(s => IsSemanticClassification(s.ClassificationType)) + .ToList(); + } + catch (Exception ex) + { + _logger.LogTrace($"Error classifying generated C# code: {ex.Message}"); + return []; + } + } + + /// + /// Map classified spans from generated C# positions to original Razor positions. + /// + private List MapToRazorPositions( + List classifiedSpans, + List sourceMappings, + string generatedCode, + string razorContent) + { + var result = new List(); + var razorText = SourceText.From(razorContent); + + foreach (var span in classifiedSpans) + { + var mapping = FindContainingMapping(span.TextSpan, sourceMappings); + if (mapping == null) + continue; + + var generatedOffset = span.TextSpan.Start - mapping.GeneratedSpan.AbsoluteIndex; + + if (generatedOffset < 0 || generatedOffset >= mapping.OriginalSpan.Length) + continue; + + var mappedLength = Math.Min( + span.TextSpan.Length, + mapping.OriginalSpan.Length - generatedOffset); + + if (mappedLength <= 0) + continue; + + var razorStart = mapping.OriginalSpan.AbsoluteIndex + generatedOffset; + + if (razorStart < 0 || razorStart + mappedLength > razorContent.Length) + continue; + + var razorSpan = new TextSpan(razorStart, mappedLength); + var linePosition = razorText.Lines.GetLinePositionSpan(razorSpan); + + var tokenType = MapClassificationToTokenType(span.ClassificationType); + if (tokenType < 0) + continue; + + result.Add(new RazorSemanticToken + { + Line = linePosition.Start.Line, + Character = linePosition.Start.Character, + Length = mappedLength, + TokenType = tokenType, + TokenModifiers = 0 + }); + } + + return result; + } + + /// + /// Find a source mapping that contains the given span. + /// + private SourceMapping? FindContainingMapping(TextSpan span, List mappings) + { + foreach (var mapping in mappings) + { + var generatedStart = mapping.GeneratedSpan.AbsoluteIndex; + var generatedEnd = generatedStart + mapping.GeneratedSpan.Length; + + if (span.Start >= generatedStart && span.Start < generatedEnd) + { + return mapping; + } + } + return null; + } + + /// + /// Detect Razor components (PascalCase tags) and their attributes in the Razor content. + /// + private List DetectRazorComponents(string razorContent) + { + var components = new List(); + var razorText = SourceText.From(razorContent); + + // Use regex to find PascalCase tags: + /// Detect @expressions inside attribute values (e.g., ="@typeof(App).Assembly"). + /// Uses Roslyn to properly parse and classify C# expressions. + /// + private List DetectAttributeExpressions(string razorContent) + { + var tokens = new List(); + var razorText = SourceText.From(razorContent); + + // Match attribute expressions: ="@..." or ="@(...)" + var attrExprPattern = new Regex( + @"=""(@)([^""]+)""", + RegexOptions.Compiled); + + foreach (Match match in attrExprPattern.Matches(razorContent)) + { + var atSymbol = match.Groups[1]; + var expression = match.Groups[2]; + + // Emit token for @ symbol (purple) + var atPos = razorText.Lines.GetLinePositionSpan(new TextSpan(atSymbol.Index, 1)); + tokens.Add(new RazorSemanticToken + { + Line = atPos.Start.Line, + Character = atPos.Start.Character, + Length = 1, + TokenType = SemanticTokenTypes.RazorTransition, + TokenModifiers = 0 + }); + + // Parse the C# expression with Roslyn and emit tokens + var exprContent = expression.Value; + + // Handle explicit expressions: @(...) - strip the outer parentheses for parsing + if (exprContent.StartsWith("(") && exprContent.EndsWith(")")) + { + exprContent = exprContent.Substring(1, exprContent.Length - 2); + var exprTokens = ParseCSharpExpression(exprContent, expression.Index + 1, razorText); + tokens.AddRange(exprTokens); + } + else + { + var exprTokens = ParseCSharpExpression(exprContent, expression.Index, razorText); + tokens.AddRange(exprTokens); + } + } + + return tokens; + } + + /// + /// Parse a C# expression using Roslyn and return semantic tokens. + /// + private List ParseCSharpExpression( + string expression, + int startIndex, + SourceText razorText) + { + var tokens = new List(); + + try + { + // Parse as a complete expression by wrapping it + var wrappedCode = $"var __expr = {expression};"; + var syntaxTree = CSharpSyntaxTree.ParseText( + wrappedCode, + new CSharpParseOptions(LanguageVersion.Latest)); + + var root = syntaxTree.GetRoot(); + + // Offset to account for "var __expr = " prefix + var prefixLength = "var __expr = ".Length; + + // Walk syntax tokens and classify them + foreach (var token in root.DescendantTokens()) + { + // Skip tokens that are part of our wrapper + if (token.SpanStart < prefixLength) + continue; + + // Calculate the position in the original expression + var exprOffset = token.SpanStart - prefixLength; + + // Skip if this token extends past our expression (e.g., the trailing semicolon) + if (exprOffset >= expression.Length) + continue; + + // Map the syntax kind to a token type + var tokenType = MapSyntaxKindToTokenType(token.Kind(), token.Text); + if (tokenType < 0) + continue; + + // Calculate position in razor source + var razorStart = startIndex + exprOffset; + + // Ensure we don't exceed bounds + var tokenLength = Math.Min(token.Span.Length, expression.Length - exprOffset); + if (razorStart < 0 || razorStart + tokenLength > razorText.Length) + continue; + + var linePos = razorText.Lines.GetLinePositionSpan(new TextSpan(razorStart, tokenLength)); + + tokens.Add(new RazorSemanticToken + { + Line = linePos.Start.Line, + Character = linePos.Start.Character, + Length = tokenLength, + TokenType = tokenType, + TokenModifiers = 0 + }); + } + } + catch (Exception) + { + // If parsing fails, fall back to simple identifier detection + tokens.AddRange(ParseSimpleExpression(expression, startIndex, razorText)); + } + + return tokens; + } + + /// + /// Fallback: parse a simple expression using basic pattern matching. + /// + private List ParseSimpleExpression( + string expression, + int startIndex, + SourceText razorText) + { + var tokens = new List(); + + // Split by dots and highlight each identifier + var identifierPattern = new Regex( + @"([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?", + RegexOptions.Compiled); + + foreach (Match match in identifierPattern.Matches(expression)) + { + var identifier = match.Groups[1].Value; + var hasParens = match.Groups[2].Success; + var identStart = startIndex + match.Groups[1].Index; + + var linePos = razorText.Lines.GetLinePositionSpan(new TextSpan(identStart, identifier.Length)); + + // Determine token type based on context + int tokenType; + if (IsKnownKeyword(identifier)) + tokenType = SemanticTokenTypes.Keyword; + else if (hasParens) + tokenType = SemanticTokenTypes.Method; + else if (char.IsUpper(identifier[0])) + tokenType = SemanticTokenTypes.Class; // Assume PascalCase = type + else + tokenType = SemanticTokenTypes.Variable; + + tokens.Add(new RazorSemanticToken + { + Line = linePos.Start.Line, + Character = linePos.Start.Character, + Length = identifier.Length, + TokenType = tokenType, + TokenModifiers = 0 + }); + } + + return tokens; + } + + /// + /// Map C# SyntaxKind to semantic token type. + /// + private static int MapSyntaxKindToTokenType(SyntaxKind kind, string text) + { + // Check for keywords first + if (SyntaxFacts.IsKeywordKind(kind)) + return SemanticTokenTypes.Keyword; + + return kind switch + { + // Identifiers - need context to determine type + SyntaxKind.IdentifierToken => ClassifyIdentifier(text), + + // Literals + SyntaxKind.NumericLiteralToken => SemanticTokenTypes.Number, + SyntaxKind.StringLiteralToken => SemanticTokenTypes.String, + SyntaxKind.CharacterLiteralToken => SemanticTokenTypes.String, + + // Operators and punctuation (don't highlight these by default) + SyntaxKind.DotToken => -1, + SyntaxKind.OpenParenToken => -1, + SyntaxKind.CloseParenToken => -1, + SyntaxKind.CommaToken => -1, + + _ => -1 + }; + } + + /// + /// Classify an identifier based on its text. + /// + private static int ClassifyIdentifier(string text) + { + // Check for known type keywords + if (IsKnownKeyword(text)) + return SemanticTokenTypes.Keyword; + + // PascalCase typically indicates a type + if (char.IsUpper(text[0]) && text.Length > 1 && text.Any(char.IsLower)) + return SemanticTokenTypes.Class; + + // Otherwise treat as a variable/property + return SemanticTokenTypes.Property; + } + + /// + /// Check if the identifier is a known C# keyword that looks like an identifier. + /// + private static bool IsKnownKeyword(string text) + { + return text switch + { + "typeof" => true, + "nameof" => true, + "sizeof" => true, + "default" => true, + "true" => true, + "false" => true, + "null" => true, + "this" => true, + "base" => true, + "new" => true, + "await" => true, + "async" => true, + _ => false + }; + } + + /// + /// Check if a string is PascalCase (starts with uppercase, has at least one lowercase). + /// + private static bool IsPascalCase(string name) + { + if (string.IsNullOrEmpty(name) || name.Length < 2) + return false; + + // Must start with uppercase + if (!char.IsUpper(name[0])) + return false; + + // Must have at least one lowercase letter (to distinguish from ALL_CAPS) + return name.Any(char.IsLower); + } + + /// + /// Encode semantic tokens in Monaco's delta format. + /// Each token is 5 integers: [deltaLine, deltaStartChar, length, tokenType, tokenModifiers] + /// + private int[] EncodeSemanticTokens(List tokens) + { + var result = new List(); + var previousLine = 0; + var previousChar = 0; + + foreach (var token in tokens) + { + var deltaLine = token.Line - previousLine; + var deltaChar = deltaLine == 0 + ? token.Character - previousChar + : token.Character; + + result.Add(deltaLine); + result.Add(deltaChar); + result.Add(token.Length); + result.Add(token.TokenType); + result.Add(token.TokenModifiers); + + previousLine = token.Line; + previousChar = token.Character; + } + + return result.ToArray(); + } + + /// + /// Check if a classification type represents a semantic token we want to highlight. + /// + private static bool IsSemanticClassification(string classificationType) + { + return classificationType switch + { + // Types + ClassificationTypeNames.ClassName => true, + ClassificationTypeNames.RecordClassName => true, + ClassificationTypeNames.RecordStructName => true, + ClassificationTypeNames.DelegateName => true, + ClassificationTypeNames.EnumName => true, + ClassificationTypeNames.InterfaceName => true, + ClassificationTypeNames.ModuleName => true, + ClassificationTypeNames.StructName => true, + ClassificationTypeNames.TypeParameterName => true, + + // Members + ClassificationTypeNames.ParameterName => true, + ClassificationTypeNames.LocalName => true, + ClassificationTypeNames.FieldName => true, + ClassificationTypeNames.ConstantName => true, + ClassificationTypeNames.PropertyName => true, + ClassificationTypeNames.EventName => true, + ClassificationTypeNames.MethodName => true, + ClassificationTypeNames.ExtensionMethodName => true, + ClassificationTypeNames.NamespaceName => true, + ClassificationTypeNames.LabelName => true, + ClassificationTypeNames.EnumMemberName => true, + ClassificationTypeNames.StaticSymbol => true, + + // Keywords (all types) + ClassificationTypeNames.Keyword => true, + ClassificationTypeNames.ControlKeyword => true, + ClassificationTypeNames.PreprocessorKeyword => true, + ClassificationTypeNames.PreprocessorText => true, + + // Other + ClassificationTypeNames.StringEscapeCharacter => true, + ClassificationTypeNames.Operator => true, + ClassificationTypeNames.NumericLiteral => true, + ClassificationTypeNames.StringLiteral => true, + ClassificationTypeNames.VerbatimStringLiteral => true, + + _ => false + }; + } + + /// + /// Map Roslyn classification type to Monaco semantic token type index. + /// + private static int MapClassificationToTokenType(string classificationType) + { + return classificationType switch + { + // Types + ClassificationTypeNames.ClassName => SemanticTokenTypes.Class, + ClassificationTypeNames.RecordClassName => SemanticTokenTypes.Class, + ClassificationTypeNames.RecordStructName => SemanticTokenTypes.Struct, + ClassificationTypeNames.DelegateName => SemanticTokenTypes.Type, + ClassificationTypeNames.EnumName => SemanticTokenTypes.Enum, + ClassificationTypeNames.InterfaceName => SemanticTokenTypes.Interface, + ClassificationTypeNames.ModuleName => SemanticTokenTypes.Namespace, + ClassificationTypeNames.StructName => SemanticTokenTypes.Struct, + ClassificationTypeNames.TypeParameterName => SemanticTokenTypes.TypeParameter, + + // Members + ClassificationTypeNames.ParameterName => SemanticTokenTypes.Parameter, + ClassificationTypeNames.LocalName => SemanticTokenTypes.Variable, + ClassificationTypeNames.FieldName => SemanticTokenTypes.Property, + ClassificationTypeNames.ConstantName => SemanticTokenTypes.Variable, + ClassificationTypeNames.PropertyName => SemanticTokenTypes.Property, + ClassificationTypeNames.EventName => SemanticTokenTypes.Event, + ClassificationTypeNames.MethodName => SemanticTokenTypes.Method, + ClassificationTypeNames.ExtensionMethodName => SemanticTokenTypes.Method, + ClassificationTypeNames.EnumMemberName => SemanticTokenTypes.EnumMember, + + // Other + ClassificationTypeNames.NamespaceName => SemanticTokenTypes.Namespace, + ClassificationTypeNames.LabelName => SemanticTokenTypes.Label, + ClassificationTypeNames.StaticSymbol => SemanticTokenTypes.Variable, + + // Keywords + ClassificationTypeNames.Keyword => SemanticTokenTypes.Keyword, + ClassificationTypeNames.ControlKeyword => SemanticTokenTypes.Keyword, + ClassificationTypeNames.PreprocessorKeyword => SemanticTokenTypes.Macro, + ClassificationTypeNames.PreprocessorText => SemanticTokenTypes.String, + + // Literals and operators + ClassificationTypeNames.StringEscapeCharacter => SemanticTokenTypes.Regexp, + ClassificationTypeNames.Operator => SemanticTokenTypes.Operator, + ClassificationTypeNames.NumericLiteral => SemanticTokenTypes.Number, + ClassificationTypeNames.StringLiteral => SemanticTokenTypes.String, + ClassificationTypeNames.VerbatimStringLiteral => SemanticTokenTypes.String, + + _ => -1 // Not mapped + }; + } + + [GeneratedRegex(@"])", RegexOptions.Compiled)] + private static partial Regex MyRegex(); +} + +/// +/// Represents a semantic token in Razor source coordinates. +/// +public record RazorSemanticToken +{ + public int Line { get; init; } + public int Character { get; init; } + public int Length { get; init; } + public int TokenType { get; init; } + public int TokenModifiers { get; init; } +} diff --git a/Apollo.Client/Analysis/CodeAnalysisWorkerProxy.cs b/Apollo.Client/Analysis/CodeAnalysisWorkerProxy.cs index 09cf856..a7a10c7 100644 --- a/Apollo.Client/Analysis/CodeAnalysisWorkerProxy.cs +++ b/Apollo.Client/Analysis/CodeAnalysisWorkerProxy.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using System.Text.Json; using Apollo.Analysis; using Apollo.Compilation; @@ -88,9 +87,12 @@ internal async Task InitializeMessageListener() case "user_assembly_update_response": _userAssemblyUpdateResponse = Convert.FromBase64String(message.Payload); break; + case "semantic_tokens_response": + _semanticTokensResponse = Convert.FromBase64String(message.Payload); + break; default: - Console.WriteLine($"Unknown event: {message.Action}", LogSeverity.Debug); - Console.WriteLine(JsonSerializer.Serialize(message.Payload)); + _console.AddDebug($"Unknown event: {message.Action}"); + _console.AddDebug(JsonSerializer.Serialize(message.Payload)); break; } } @@ -187,6 +189,7 @@ await _worker.PostMessageAsync(JsonSerializer.Serialize(new WorkerMessage private byte[]? _documentUpdateResponse; private byte[]? _setCurrentDocumentResponse; private byte[]? _userAssemblyUpdateResponse; + private byte[]? _semanticTokensResponse; public async Task UpdateDocumentAsync(string documentUpdateRequest) { @@ -265,4 +268,30 @@ await _worker.PostMessageAsync(JsonSerializer.Serialize(new WorkerMessage return _userAssemblyUpdateResponse ?? []; } + + public async Task GetSemanticTokensAsync(string semanticTokensRequest) + { + _semanticTokensResponse = null; + + await _worker.PostMessageAsync(JsonSerializer.Serialize(new WorkerMessage + { + Action = "get_semantic_tokens", + Payload = semanticTokensRequest + })); + + for (int i = 0; i < 50; i++) + { + if (_semanticTokensResponse == null) + { + await Task.Delay(50); + await Task.Yield(); + } + else + { + return _semanticTokensResponse; + } + } + + return _semanticTokensResponse ?? []; + } } diff --git a/Apollo.Client/Layout/MainLayout.razor b/Apollo.Client/Layout/MainLayout.razor index 2cd01c6..be9917a 100644 --- a/Apollo.Client/Layout/MainLayout.razor +++ b/Apollo.Client/Layout/MainLayout.razor @@ -21,66 +21,59 @@ - - - + + + @code { -[Inject] public IJSRuntime JsRuntime { get; set; } + [Inject] public IJSRuntime JsRuntime { get; set; } -[Inject] public AppState State { get; set; } = default!; + [Inject] public AppState State { get; set; } = default!; -[Inject] public SettingsState Settings { get; set; } = default!; + [Inject] public SettingsState Settings { get; set; } = default!; -[Inject] public KeyBindingsState KeyBindings { get; set; } = default!; + [Inject] public KeyBindingsState KeyBindings { get; set; } = default!; -[Inject] public NuGetState NuGetState { get; set; } = default!; + [Inject] public NuGetState NuGetState { get; set; } = default!; -bool _drawerOpen = true; + bool _drawerOpen = true; -void DrawerToggle() -{ - _drawerOpen = !_drawerOpen; -} - -protected override void OnInitialized() -{ - State.AppStateChanged += HandleAppStateChanged; -} + void DrawerToggle() + { + _drawerOpen = !_drawerOpen; + } -public void Dispose() -{ - State.AppStateChanged -= HandleAppStateChanged; -} + protected override void OnInitialized() + { + State.AppStateChanged += HandleAppStateChanged; + } -protected void HandleAppStateChanged() -{ - BlazorMonaco.Editor.Global.SetTheme(JsRuntime, State.IsDarkMode ? "vs-dark" : "vs"); - StateHasChanged(); -} + public void Dispose() + { + State.AppStateChanged -= HandleAppStateChanged; + } -protected override void OnAfterRender(bool firstRender) -{ - if (firstRender) + protected async void HandleAppStateChanged() { - BlazorMonaco.Editor.Global.SetTheme(JsRuntime, "vs-dark"); + await JsRuntime.InvokeVoidAsync("apolloEditor.setTheme", State.IsDarkMode); + StateHasChanged(); } -} -protected override async Task OnAfterRenderAsync(bool firstRender) -{ - if (firstRender) + protected override async Task OnAfterRenderAsync(bool firstRender) { - await Settings.TryLoadSettingsFromStorageAsync(); - await Settings.TrySetSystemThemeAsync(); - await KeyBindings.LoadFromStorageAsync(); - await NuGetState.InitializeAsync(); + if (firstRender) + { + await JsRuntime.InvokeVoidAsync("apolloEditor.setTheme", State.IsDarkMode); + await Settings.TryLoadSettingsFromStorageAsync(); + await Settings.TrySetSystemThemeAsync(); + await KeyBindings.LoadFromStorageAsync(); + await NuGetState.InitializeAsync(); + } } -} -private MudTheme GetCurrentTheme() -{ - return Settings.CurrentTheme.BaseTheme; -} + private MudTheme GetCurrentTheme() + { + return Settings.CurrentTheme.BaseTheme; + } -} +} \ No newline at end of file diff --git a/Apollo.Client/Program.cs b/Apollo.Client/Program.cs index 4389eac..fd74e07 100644 --- a/Apollo.Client/Program.cs +++ b/Apollo.Client/Program.cs @@ -13,6 +13,7 @@ using Apollo.Components.Infrastructure.Environment; using Mythetech.Framework.Infrastructure.MessageBus; using Apollo.Infrastructure.Resources; +using Mythetech.Framework.Infrastructure.Mcp; var builder = WebAssemblyHostBuilder.CreateDefault(args); builder.RootComponents.Add("#app"); @@ -25,6 +26,7 @@ builder.Services.AddSingleton(); builder.Services.AddComponentsAndServices(); +builder.Services.AddMcp(); //ToDO - fix in framework, shouldn't be required builder.Services.AddSingleton(); diff --git a/Apollo.Client/wwwroot/index.html b/Apollo.Client/wwwroot/index.html index 73933b3..f8f8f10 100644 --- a/Apollo.Client/wwwroot/index.html +++ b/Apollo.Client/wwwroot/index.html @@ -61,6 +61,7 @@ + diff --git a/Apollo.Components/Analysis/CodeAnalysisState.cs b/Apollo.Components/Analysis/CodeAnalysisState.cs index 450fa0f..2e0f710 100644 --- a/Apollo.Components/Analysis/CodeAnalysisState.cs +++ b/Apollo.Components/Analysis/CodeAnalysisState.cs @@ -58,8 +58,7 @@ private async Task HandleDisabledChanged() private async Task NotifyCodeAnalysisStatusChanged() { - if (OnCodeAnalysisStateChanged?.GetInvocationList()?.Length > 0) - await OnCodeAnalysisStateChanged?.Invoke()!; + await (OnCodeAnalysisStateChanged?.Invoke() ?? Task.CompletedTask); } private ICodeAnalysisWorker? _workerProxy; @@ -260,6 +259,57 @@ public async Task UpdateUserAssemblyAsync(byte[]? assemblyBytes) } } + public bool IsReady => _workerReady && !Disabled && _workerProxy != null; + + public async Task RequestSemanticTokensAsync( + string documentUri, + string? razorContent = null, + CancellationToken cancellationToken = default) + { + if (Disabled || !_workerReady || _workerProxy == null) + { + _console.AddTrace("Code analysis not ready for semantic tokens"); + return SemanticTokensResult.Empty; + } + + var request = new SemanticTokensRequest + { + DocumentUri = documentUri, + RazorContent = razorContent + }; + + try + { + var bytes = await _workerProxy.GetSemanticTokensAsync(JsonSerializer.Serialize(request, JsonOptions)); + if (bytes == null || bytes.Length == 0) + { + return SemanticTokensResult.Empty; + } + + var response = JsonSerializer.Deserialize( + System.Text.Encoding.UTF8.GetString(bytes), + JsonOptions + ); + + if (response?.Payload == null) + { + return SemanticTokensResult.Empty; + } + + var result = JsonSerializer.Deserialize( + response.Payload.ToString()!, + JsonOptions + ); + + return result ?? SemanticTokensResult.Empty; + } + catch (Exception ex) + { + _console.AddError($"Error requesting semantic tokens: {ex.Message}"); + return SemanticTokensResult.Empty; + } + } + public async Task StartAsync() { if (Disabled || _workerProxy != null) diff --git a/Apollo.Components/Analysis/ICodeAnalysisWorker.cs b/Apollo.Components/Analysis/ICodeAnalysisWorker.cs index 174dc63..abc7a90 100644 --- a/Apollo.Components/Analysis/ICodeAnalysisWorker.cs +++ b/Apollo.Components/Analysis/ICodeAnalysisWorker.cs @@ -19,4 +19,6 @@ public interface ICodeAnalysisWorker : IWorkerProxy Task SetCurrentDocumentAsync(string setCurrentDocumentRequest); Task UpdateUserAssemblyAsync(string userAssemblyUpdateRequest); + + Task GetSemanticTokensAsync(string semanticTokensRequest); } diff --git a/Apollo.Components/Editor/ApolloCodeEditor.razor b/Apollo.Components/Editor/ApolloCodeEditor.razor index b0ce15b..8c8cf1b 100644 --- a/Apollo.Components/Editor/ApolloCodeEditor.razor +++ b/Apollo.Components/Editor/ApolloCodeEditor.razor @@ -38,6 +38,7 @@ private bool _editorReady = false; private string? _lastKnownContent; + private DotNetObjectReference? _dotNetRef; private StandaloneEditorConstructionOptions DefaultOptions(StandaloneCodeEditor editor) { @@ -282,10 +283,10 @@ await Global.RegisterDocumentFormattingEditProvider(JsRuntime, "csharp", async (uri, options) => { Logger.LogInformation("Formatting document..."); - + var current = SolutionsState.ActiveFile?.Data; var formatted = await CSharpier.CodeFormatter.FormatAsync(current); - + var model = await _editor.GetModel(); var lineCount = await model.GetLineCount(); var lastLineLength = (await model.GetLineContent(lineCount)).Length; @@ -308,6 +309,79 @@ return edits; }); + // Initialize semantic tokens for Razor syntax highlighting + await InitializeSemanticTokensAsync(); + } + + private async Task InitializeSemanticTokensAsync() + { + try + { + _dotNetRef = DotNetObjectReference.Create(this); + + // Initialize semantic tokens support for Razor files + await JsRuntime.InvokeVoidAsync( + "apolloEditor.initializeSemanticTokens", + "apollo-editor", + _dotNetRef, + Settings.IsDarkMode); + + Logger.LogInformation("Semantic tokens initialized for Razor files"); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to initialize semantic tokens - Razor highlighting may be limited"); + } + } + + [JSInvokable] + public async Task ProvideSemanticTokens(string modelUri) + { + try + { + if (!CodeAnalysisState.IsReady) + { + return null; + } + + var activeFile = SolutionsState.ActiveFile; + if (activeFile == null) + { + return null; + } + + // Only provide semantic tokens for Razor files + if (!activeFile.Name.EndsWith(".razor", StringComparison.OrdinalIgnoreCase) && + !activeFile.Name.EndsWith(".cshtml", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + Logger.LogDebug("Semantic tokens requested for {Uri}", activeFile.Uri); + + var result = await CodeAnalysisState.RequestSemanticTokensAsync( + activeFile.Uri, + activeFile.Data); + + if (result.Data.Length == 0) + { + Logger.LogDebug("No semantic tokens found for {Uri}", activeFile.Uri); + return null; + } + + Logger.LogDebug("Found {Count} semantic tokens for {Uri}", result.Data.Length / 5, activeFile.Uri); + + return new + { + data = result.Data, + resultId = result.ResultId + }; + } + catch (Exception ex) + { + Logger.LogError(ex, "Semantic tokens error"); + return null; + } } private CompletionItemKind MapCompletionKind(OmniSharp.Models.v1.Completion.CompletionItemKind kind) @@ -513,6 +587,7 @@ { _editor?.Dispose(); _diagnosticsCancellation?.Dispose(); + _dotNetRef?.Dispose(); SolutionsState.BuildRequested -= HandleBuildRequested; SolutionsState.ActiveFileChanged -= async (file) => await HandleFileChanged(file); SolutionsState.ActiveFileChangeRequested -= async (file) => await HandleBeforeFileChanged(file); diff --git a/Apollo.Components/Solutions/Consumers/SolutionBuilder.cs b/Apollo.Components/Solutions/Consumers/SolutionBuilder.cs deleted file mode 100644 index d3e8ffb..0000000 --- a/Apollo.Components/Solutions/Consumers/SolutionBuilder.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Mythetech.Framework.Infrastructure.MessageBus; -using Apollo.Components.Solutions.Commands; - -namespace Apollo.Components.Solutions.Consumers; - -public class SolutionBuilder : IConsumer -{ - private readonly SolutionsState _state; - - public SolutionBuilder(SolutionsState state) - { - _state = state; - } - - public async Task Consume(BuildSolution message) - { - // await _state.BuildAsync(); - } -} \ No newline at end of file diff --git a/Apollo.Components/Solutions/SolutionsState.cs b/Apollo.Components/Solutions/SolutionsState.cs index 3b36f49..7c97d7c 100644 --- a/Apollo.Components/Solutions/SolutionsState.cs +++ b/Apollo.Components/Solutions/SolutionsState.cs @@ -70,7 +70,7 @@ public SolutionsState( public async Task SaveProjectFilesAsync() { - await SaveProjectRequested.Invoke(); + await (SaveProjectRequested?.Invoke() ?? Task.CompletedTask); } public void SwitchFile(SolutionFile file) diff --git a/Apollo.Components/Theme/CustomThemeService.cs b/Apollo.Components/Theme/CustomThemeService.cs index 5ae5b19..12f8e48 100644 --- a/Apollo.Components/Theme/CustomThemeService.cs +++ b/Apollo.Components/Theme/CustomThemeService.cs @@ -40,12 +40,12 @@ public async Task LoadFromStorageAsync() OnThemesChanged?.Invoke(); } - catch (Exception ex) + catch (Exception) { - System.Console.WriteLine($"Failed to load custom themes: {ex.Message}"); + // Silently fail on load - themes will just be empty } } - + public async Task SaveToStorageAsync() { try @@ -54,9 +54,9 @@ public async Task SaveToStorageAsync() var json = CustomThemeSerializer.SerializeMany(dataList); await _localStorage.SetItemAsStringAsync(StorageKey, json); } - catch (Exception ex) + catch (Exception) { - System.Console.WriteLine($"Failed to save custom themes: {ex.Message}"); + _snackbar.Add("Failed to save custom themes", Severity.Error); } } diff --git a/Apollo.Components/wwwroot/apolloEditor.js b/Apollo.Components/wwwroot/apolloEditor.js new file mode 100644 index 0000000..71ba737 --- /dev/null +++ b/Apollo.Components/wwwroot/apolloEditor.js @@ -0,0 +1,229 @@ +// Apollo Editor - Custom Monaco Language Providers for Razor syntax highlighting +// These fill gaps where BlazorMonaco doesn't expose certain Monaco APIs yet +window.apolloEditor = window.apolloEditor || {}; + +window.apolloEditor.defineSemanticThemes = function () { + monaco.editor.defineTheme('apollo-dark', { + base: 'vs-dark', + inherit: true, + rules: [ + + // Types + { token: 'namespace', foreground: '4EC9B0' }, + { token: 'type', foreground: '4EC9B0' }, + { token: 'class', foreground: '4EC9B0' }, + { token: 'enum', foreground: '4EC9B0' }, + { token: 'interface', foreground: 'B8D7A3' }, + { token: 'struct', foreground: '86C691' }, + { token: 'typeParameter', foreground: '4EC9B0' }, + // Members + { token: 'parameter', foreground: '9CDCFE' }, + { token: 'variable', foreground: '9CDCFE' }, + { token: 'property', foreground: '9CDCFE' }, + { token: 'enumMember', foreground: '4FC1FF' }, + { token: 'event', foreground: '9CDCFE' }, + { token: 'function', foreground: 'DCDCAA' }, + { token: 'method', foreground: 'DCDCAA' }, + // Other + { token: 'macro', foreground: 'BD63C5' }, + { token: 'keyword', foreground: '569CD6' }, + { token: 'modifier', foreground: '569CD6' }, + { token: 'comment', foreground: '6A9955' }, + { token: 'string', foreground: 'CE9178' }, + { token: 'number', foreground: 'B5CEA8' }, + { token: 'regexp', foreground: 'D16969' }, + { token: 'operator', foreground: 'D4D4D4' }, + { token: 'decorator', foreground: 'DCDCAA' }, + { token: 'label', foreground: 'C8C8C8' }, + { token: 'component', foreground: '3D8A6E' }, // Darker green for Razor components + { token: 'razorTransition', foreground: 'BD63C5' }, // Purple for Razor @ symbol + ], + colors: {}, + semanticHighlighting: true + }); + + + monaco.editor.defineTheme('apollo-light', { + base: 'vs', + inherit: true, + rules: [ + + // Types + { token: 'namespace', foreground: '267F99' }, + { token: 'type', foreground: '267F99' }, + { token: 'class', foreground: '267F99' }, + { token: 'enum', foreground: '267F99' }, + { token: 'interface', foreground: '267F99' }, + { token: 'struct', foreground: '267F99' }, + { token: 'typeParameter', foreground: '267F99' }, + // Members + { token: 'parameter', foreground: '001080' }, + { token: 'variable', foreground: '001080' }, + { token: 'property', foreground: '001080' }, + { token: 'enumMember', foreground: '0070C1' }, + { token: 'event', foreground: '001080' }, + { token: 'function', foreground: '795E26' }, + { token: 'method', foreground: '795E26' }, + // Other + { token: 'macro', foreground: 'AF00DB' }, + { token: 'keyword', foreground: '0000FF' }, + { token: 'modifier', foreground: '0000FF' }, + { token: 'comment', foreground: '008000' }, + { token: 'string', foreground: 'A31515' }, + { token: 'number', foreground: '098658' }, + { token: 'regexp', foreground: '811F3F' }, + { token: 'operator', foreground: '000000' }, + { token: 'decorator', foreground: '795E26' }, + { token: 'label', foreground: '000000' }, + { token: 'component', foreground: '2E7D32' }, // Darker green for Razor components (light mode) + { token: 'razorTransition', foreground: 'AF00DB' }, // Purple for Razor @ symbol + ], + colors: {}, + semanticHighlighting: true + }); + + console.info('Defined apollo-dark and apollo-light themes with semantic token colors'); +}; + + +window.apolloEditor.applySemanticTheme = function (editorId, isDarkMode = true) { + const editor = blazorMonaco.editor.getEditor(editorId); + if (!editor) { + console.warn('Editor not found for theme:', editorId); + return false; + } + + + window.apolloEditor.defineSemanticThemes(); + + + const themeName = isDarkMode ? 'apollo-dark' : 'apollo-light'; + monaco.editor.setTheme(themeName); + console.info('Applied', themeName, 'theme to editor'); + return true; +}; + + +window.apolloEditor.setTheme = function (isDarkMode) { + window.apolloEditor.defineSemanticThemes(); + + const themeName = isDarkMode ? 'apollo-dark' : 'apollo-light'; + monaco.editor.setTheme(themeName); + console.info('Switched to', themeName, 'theme'); + return true; +}; + +window.apolloEditor.enableSemanticHighlighting = function (editorId) { + const editor = blazorMonaco.editor.getEditor(editorId); + if (!editor) { + console.warn('Editor not found for semantic highlighting:', editorId); + return false; + } + + console.info('Enabling semantic highlighting for editor:', editorId); + editor.updateOptions({ + 'semanticHighlighting.enabled': true + }); + console.info('Semantic highlighting enabled'); + return true; +}; + +window.apolloEditor.registerSemanticTokensProvider = async function (language, semanticTokensProviderRef, legend) { + console.info('Registering semantic tokens provider for', language, 'with legend:', legend); + + await monaco.languages.registerDocumentSemanticTokensProvider(language, { + getLegend: () => { + console.debug('Semantic tokens legend requested'); + return { + tokenTypes: legend.tokenTypes, + tokenModifiers: legend.tokenModifiers + }; + }, + provideDocumentSemanticTokens: (model, lastResultId, cancellationToken) => { + console.debug('Semantic tokens requested for:', model.uri.toString()); + return semanticTokensProviderRef.invokeMethodAsync("ProvideSemanticTokens", decodeURI(model.uri.toString())) + .then(result => { + if (result == null || result.data == null || result.data.length === 0) { + console.debug('No semantic tokens returned'); + return null; + } + console.debug('Received', result.data.length / 5, 'semantic tokens'); + return { + data: new Uint32Array(result.data), + resultId: result.resultId + }; + }) + .catch(error => { + console.warn('Semantic tokens error:', error); + return null; + }); + }, + releaseDocumentSemanticTokens: (resultId) => { + // TODO: notify Blazor to release cached tokens if needed? + } + }); + + console.info('Semantic tokens provider registered for', language); +}; + + +window.apolloEditor.initializeSemanticTokens = async function (editorId, semanticTokensProviderRef, isDarkMode = true) { + try { + window.apolloEditor.defineSemanticThemes(); + + const legend = { + tokenTypes: [ + 'namespace', + 'type', + 'class', + 'enum', + 'interface', + 'struct', + 'typeParameter', + 'parameter', + 'variable', + 'property', + 'enumMember', + 'event', + 'function', + 'method', + 'macro', + 'keyword', + 'modifier', + 'comment', + 'string', + 'number', + 'regexp', + 'operator', + 'decorator', + 'label', + 'component', + 'razorTransition' + ], + tokenModifiers: [ + 'declaration', + 'definition', + 'readonly', + 'static', + 'deprecated', + 'abstract', + 'async', + 'modification', + 'documentation', + 'defaultLibrary' + ] + }; + + await window.apolloEditor.registerSemanticTokensProvider('razor', semanticTokensProviderRef, legend); + + window.apolloEditor.applySemanticTheme(editorId, isDarkMode); + + window.apolloEditor.enableSemanticHighlighting(editorId); + + console.info('Semantic tokens fully initialized for editor:', editorId); + return true; + } catch (error) { + console.error('Failed to initialize semantic tokens:', error); + return false; + } +}; diff --git a/Apollo.Contracts/Analysis/SemanticTokenContracts.cs b/Apollo.Contracts/Analysis/SemanticTokenContracts.cs new file mode 100644 index 0000000..f0d8387 --- /dev/null +++ b/Apollo.Contracts/Analysis/SemanticTokenContracts.cs @@ -0,0 +1,160 @@ +namespace Apollo.Contracts.Analysis; + +/// +/// Request for semantic tokens for a document. +/// +public record SemanticTokensRequest +{ + /// + /// The document URI to get semantic tokens for. + /// + public string DocumentUri { get; init; } = ""; + + /// + /// Optional Razor file content (for .razor files that may not be in the Roslyn workspace). + /// + public string? RazorContent { get; init; } +} + +/// +/// Result containing encoded semantic tokens for Monaco. +/// Monaco expects delta-encoded tokens: each token is represented by 5 integers: +/// [deltaLine, deltaStartChar, length, tokenType, tokenModifiers] +/// +public record SemanticTokensResult +{ + /// + /// The encoded token data as a flat array of integers. + /// Every 5 integers represent one token: + /// [deltaLine, deltaStartChar, length, tokenType, tokenModifiers] + /// + public int[] Data { get; init; } = []; + + /// + /// Optional result ID for incremental updates. + /// + public string? ResultId { get; init; } + + public static SemanticTokensResult Empty => new() { Data = [] }; +} + +/// +/// The semantic token legend defining token types and modifiers. +/// This must match what's registered with Monaco. +/// +public record SemanticTokensLegend +{ + /// + /// List of token type names (e.g., "class", "method", "parameter"). + /// + public string[] TokenTypes { get; init; } = []; + + /// + /// List of token modifier names (e.g., "static", "readonly", "async"). + /// + public string[] TokenModifiers { get; init; } = []; +} + +/// +/// Roslyn classification types mapped to Monaco semantic token types. +/// +public static class SemanticTokenTypes +{ + // Types + public const int Namespace = 0; + public const int Type = 1; + public const int Class = 2; + public const int Enum = 3; + public const int Interface = 4; + public const int Struct = 5; + public const int TypeParameter = 6; + public const int Parameter = 7; + public const int Variable = 8; + public const int Property = 9; + public const int EnumMember = 10; + public const int Event = 11; + public const int Function = 12; + public const int Method = 13; + public const int Macro = 14; + public const int Keyword = 15; + public const int Modifier = 16; + public const int Comment = 17; + public const int String = 18; + public const int Number = 19; + public const int Regexp = 20; + public const int Operator = 21; + public const int Decorator = 22; + public const int Label = 23; + public const int Component = 24; // Razor component type (PascalCase tags like ) + public const int RazorTransition = 25; // Razor @ transition character + + /// + /// The token type names in order (index = token type ID). + /// Must match Monaco's semantic token legend. + /// + public static readonly string[] TokenTypeNames = + [ + "namespace", + "type", + "class", + "enum", + "interface", + "struct", + "typeParameter", + "parameter", + "variable", + "property", + "enumMember", + "event", + "function", + "method", + "macro", + "keyword", + "modifier", + "comment", + "string", + "number", + "regexp", + "operator", + "decorator", + "label", + "component", + "razorTransition" + ]; +} + +/// +/// Semantic token modifiers (bit flags). +/// +public static class SemanticTokenModifiers +{ + public const int None = 0; + public const int Declaration = 1 << 0; + public const int Definition = 1 << 1; + public const int Readonly = 1 << 2; + public const int Static = 1 << 3; + public const int Deprecated = 1 << 4; + public const int Abstract = 1 << 5; + public const int Async = 1 << 6; + public const int Modification = 1 << 7; + public const int Documentation = 1 << 8; + public const int DefaultLibrary = 1 << 9; + + /// + /// The modifier names in order (index = bit position). + /// Must match Monaco's semantic token legend. + /// + public static readonly string[] ModifierNames = + [ + "declaration", + "definition", + "readonly", + "static", + "deprecated", + "abstract", + "async", + "modification", + "documentation", + "defaultLibrary" + ]; +} diff --git a/Apollo.Infrastructure/Webcil/WebcilWasmWrapper.cs b/Apollo.Infrastructure/Webcil/WebcilWasmWrapper.cs index 9ffc163..f3584dd 100644 --- a/Apollo.Infrastructure/Webcil/WebcilWasmWrapper.cs +++ b/Apollo.Infrastructure/Webcil/WebcilWasmWrapper.cs @@ -161,7 +161,7 @@ private void WriteDataSection(BinaryWriter writer) byte[] ulebSectionSize = ULEB128Encode(dataSectionSize); if (putativeULEBDataSectionSize.Length != ulebSectionSize.Length) - throw new InvalidOperationException ("adding padding would cause data section's encoded length to chane"); // TODO: fixme: there's upto one extra byte to encode the section length - take away a padding byte. + throw new InvalidOperationException("adding padding would cause data section's encoded length to change"); // TODO: fixme: there's up to one extra byte to encode the section length - take away a padding byte. writer.Write((byte)11); // section Data writer.Write(ulebSectionSize, 0, ulebSectionSize.Length); diff --git a/Directory.Packages.props b/Directory.Packages.props index 6ee7520..6afd2e7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,23 +2,21 @@ true - - - - + + - - + + - + @@ -30,27 +28,22 @@ - - - + - - - @@ -65,4 +58,4 @@ - + \ No newline at end of file