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 Razor directives and special syntax (@using, @code, @inject, [attributes], etc.)
+ ///
+ private List DetectRazorDirectives(string razorContent)
+ {
+ var tokens = new List();
+ var razorText = SourceText.From(razorContent);
+
+ // Detect @directive keywords: @using, @code, @inject, @inherits, @implements, @page, @namespace, etc.
+ var directivePattern = new Regex(
+ @"(@)(using|code|inject|inherits|implements|page|namespace|typeparam|attribute|layout|rendermode)\b",
+ RegexOptions.Compiled);
+
+ foreach (Match match in directivePattern.Matches(razorContent))
+ {
+ // Highlight the @ symbol (purple)
+ var atStart = match.Groups[1].Index;
+ var atPos = razorText.Lines.GetLinePositionSpan(new TextSpan(atStart, 1));
+
+ tokens.Add(new RazorSemanticToken
+ {
+ Line = atPos.Start.Line,
+ Character = atPos.Start.Character,
+ Length = 1,
+ TokenType = SemanticTokenTypes.RazorTransition,
+ TokenModifiers = 0
+ });
+
+ // Highlight the directive keyword
+ var keywordStart = match.Groups[2].Index;
+ var keywordLength = match.Groups[2].Length;
+
+ var linePosition = razorText.Lines.GetLinePositionSpan(new TextSpan(keywordStart, keywordLength));
+
+ tokens.Add(new RazorSemanticToken
+ {
+ Line = linePosition.Start.Line,
+ Character = linePosition.Start.Character,
+ Length = keywordLength,
+ TokenType = SemanticTokenTypes.Keyword,
+ TokenModifiers = 0
+ });
+ }
+
+ // Detect C# attributes: [Parameter], [Inject], [CascadingParameter], etc.
+ var attributePattern = new Regex(
+ @"\[([A-Z][a-zA-Z0-9]*)\]",
+ RegexOptions.Compiled);
+
+ foreach (Match match in attributePattern.Matches(razorContent))
+ {
+ var attrName = match.Groups[1].Value;
+ var attrStart = match.Groups[1].Index;
+ var attrLength = attrName.Length;
+
+ var linePosition = razorText.Lines.GetLinePositionSpan(new TextSpan(attrStart, attrLength));
+
+ // Attributes get Class coloring (teal)
+ tokens.Add(new RazorSemanticToken
+ {
+ Line = linePosition.Start.Line,
+ Character = linePosition.Start.Character,
+ Length = attrLength,
+ TokenType = SemanticTokenTypes.Class,
+ TokenModifiers = 0
+ });
+ }
+
+ return tokens;
+ }
+
+ ///
+ /// 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(@"?([A-Z][a-zA-Z0-9]*)(?=[\s/>])", 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