Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
<PackageVersion Include="Photino.Blazor" Version="4.0.13" />
<PackageVersion Include="R3" Version="1.3.0" />
<PackageVersion Include="SharpDbg" Version="0.1.0-preview8" />
<PackageVersion Include="TextMateSharp" Version="2.0.3" />
<PackageVersion Include="xunit.v3.mtp-v2" Version="3.2.0" />
<PackageVersion Include="XtermBlazor" Version="2.2.0" />
</ItemGroup>
Expand All @@ -72,4 +73,4 @@
<PackageVersion Include="Microsoft.CodeAnalysis.ExternalAccess.Razor.Features" Version="5.5.0-2.26120.116" />
<PackageVersion Include="Microsoft.CodeAnalysis.Remote.ServiceHub" Version="5.5.0-2.26120.116" />
</ItemGroup>
</Project>
</Project>
3 changes: 3 additions & 0 deletions src/SharpIDE.Application/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using SharpIDE.Application.Features.FilePersistence;
using SharpIDE.Application.Features.FileSystem;
using SharpIDE.Application.Features.FileWatching;
using SharpIDE.Application.Features.LanguageExtensions;
using SharpIDE.Application.Features.NavigationHistory;
using SharpIDE.Application.Features.Nuget;
using SharpIDE.Application.Features.Run;
Expand Down Expand Up @@ -46,6 +47,8 @@ public static IServiceCollection AddApplication(this IServiceCollection services
services.AddScoped<DotnetTemplateService>();
services.AddScoped<SharpIdeSolutionService>();
services.AddScoped<FileSystemService>();
services.AddSingleton<LanguageExtensionRegistry>();
services.AddScoped<ExtensionInstaller>();
services.AddLogging();
return services;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,18 @@ public async Task DeleteFile(SharpIdeFile file)
await _rootFolderModificationService.RemoveFile(file);
}

public async Task<SharpIdeFile> CreateGenericFile(SharpIdeFolder parentFolder, string fileName)
{
var newFilePath = Path.Combine(parentFolder.ChildNodeBasePath, fileName);
if (File.Exists(newFilePath)) throw new InvalidOperationException($"File {newFilePath} already exists.");
using var _ = await _ideFileExternalChangeHandler.IdeChangeLock.LockAsync();
await File.WriteAllTextAsync(newFilePath, string.Empty);
return await _rootFolderModificationService.CreateFile(parentFolder, newFilePath, fileName, string.Empty);
}

public async Task<SharpIdeFile> CreateCsFile(SharpIdeFolder parentFolder, string newFileName, string typeKeyword)
{
var newFilePath = Path.Combine(parentFolder.Path, newFileName);
var newFilePath = Path.Combine(parentFolder.ChildNodeBasePath, newFileName);
if (File.Exists(newFilePath)) throw new InvalidOperationException($"File {newFilePath} already exists.");
var className = Path.GetFileNameWithoutExtension(newFileName);
var @namespace = NewFileTemplates.ComputeNamespace(parentFolder);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
using System.IO.Compression;
using Microsoft.Extensions.Logging;

namespace SharpIDE.Application.Features.LanguageExtensions;

/// <summary>
/// Installs and uninstalls VS Code and Visual Studio language extensions (.vsix packages).
///
/// Install:
/// 1. Parse the .vsix via VsixPackageParser
/// 2. Extract grammar files + optional LSP server to %APPDATA%/SharpIDE/extensions/<id>/
/// 3. Update grammar file paths to absolute paths
/// 4. Register in LanguageExtensionRegistry + persist
///
/// Uninstall:
/// 1. Unregister from LanguageExtensionRegistry
/// 2. Delete the extracted directory
/// 3. Persist updated registry
/// </summary>
public class ExtensionInstaller(LanguageExtensionRegistry registry, ILogger<ExtensionInstaller> logger)
{
private static readonly string[] GrammarExtensions =
[".tmLanguage", ".tmGrammar", ".tmLanguage.json", ".tmGrammar.json", ".json"];

/// <summary>
/// Installs a .vsix file. Returns the registered InstalledExtension.
/// Throws on parse errors; logs and swaps to partial-success on extraction errors.
/// </summary>
public InstalledExtension Install(string vsixPath)
{
logger.LogInformation("Installing extension from {VsixPath}", vsixPath);

// 1. Parse metadata (still relative paths inside ZIP)
var parsed = VsixPackageParser.Parse(vsixPath);

// 2. Prepare extraction directory
var extensionsBase = LanguageExtensionPersistence.GetExtensionsBaseDirectory();
var extractedPath = Path.Combine(extensionsBase, SanitizeId(parsed.Id));
Directory.CreateDirectory(extractedPath);

// 3. Extract relevant files from the ZIP
ExtractFiles(vsixPath, extractedPath, parsed);

// 4. Resolve grammar file paths to absolute
var resolvedGrammars = parsed.Grammars
.Select(g => new GrammarContribution
{
LanguageId = g.LanguageId,
ScopeName = g.ScopeName,
GrammarFilePath = Path.Combine(extractedPath, NormalizePath(g.GrammarFilePath))
})
.Where(g => File.Exists(g.GrammarFilePath))
.ToList();

if (resolvedGrammars.Count == 0 && parsed.Grammars.Count > 0)
{
// Grammar assets declared but not found after extraction — try scanning for .tmLanguage files
resolvedGrammars = ScanForGrammars(extractedPath, parsed);
logger.LogWarning(
"Grammar assets from manifest not found after extraction for {Id}; found {Count} by scanning",
parsed.Id, resolvedGrammars.Count);
}

if (resolvedGrammars.Count == 0)
{
TryDeleteDirectory(extractedPath);
throw new InvalidOperationException(
$"'{parsed.DisplayName}' does not contain any importable TextMate syntax files. " +
"SharpIDE can only import .vsix packages that bundle a TextMate grammar right now.");
}

// 5. Build the final InstalledExtension with absolute paths
var installed = new InstalledExtension
{
Id = parsed.Id,
Version = parsed.Version,
Publisher = parsed.Publisher,
DisplayName = parsed.DisplayName,
ExtractedPath = extractedPath,
PackageKind = parsed.PackageKind,
Languages = parsed.Languages,
Grammars = resolvedGrammars,
};

// 7. Register + persist
registry.Register(installed);
LanguageExtensionPersistence.Save(registry.GetAllExtensions());

logger.LogInformation(
"Installed '{DisplayName}' ({Id} v{Version}): {GrammarCount} grammar(s), {LangCount} extension(s)",
installed.DisplayName, installed.Id, installed.Version,
installed.Grammars.Count, installed.Languages.Sum(l => l.FileExtensions.Length));

return installed;
}

/// <summary>
/// Uninstalls an extension by ID, removing it from the registry and deleting its extracted files.
/// </summary>
public void Uninstall(string extensionId)
{
logger.LogInformation("Uninstalling extension {ExtensionId}", extensionId);

var existing = registry.GetAllExtensions()
.FirstOrDefault(e => string.Equals(e.Id, extensionId, StringComparison.OrdinalIgnoreCase));

if (existing == null)
{
logger.LogWarning("Extension {ExtensionId} not found; nothing to uninstall", extensionId);
return;
}

registry.Unregister(extensionId);
LanguageExtensionPersistence.Save(registry.GetAllExtensions());

if (Directory.Exists(existing.ExtractedPath))
{
try
{
Directory.Delete(existing.ExtractedPath, recursive: true);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to delete extension directory {Path}", existing.ExtractedPath);
}
}

logger.LogInformation("Uninstalled {ExtensionId}", extensionId);
}

private static void ExtractFiles(string vsixPath, string extractedPath, InstalledExtension parsed)
{
using var zip = ZipFile.OpenRead(vsixPath);

// Collect the set of paths to extract:
// - All grammar assets declared in manifest
var grammarPaths = new HashSet<string>(
parsed.Grammars.Select(g => NormalizePath(g.GrammarFilePath)),
StringComparer.OrdinalIgnoreCase);

foreach (var entry in zip.Entries)
{
if (entry.FullName.EndsWith('/')) continue; // directory entry

var shouldExtract =
grammarPaths.Contains(entry.FullName) ||
HasGrammarExtension(entry.Name);

if (!shouldExtract) continue;

var destinationPath = Path.Combine(extractedPath, entry.FullName.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
entry.ExtractToFile(destinationPath, overwrite: true);
}
}

private static List<GrammarContribution> ScanForGrammars(string directory, InstalledExtension parsed)
{
var languageId = parsed.Languages.FirstOrDefault()?.LanguageId ?? "unknown";

return Directory
.EnumerateFiles(directory, "*.tmLanguage*", SearchOption.AllDirectories)
.Where(f => f.EndsWith(".tmLanguage", StringComparison.OrdinalIgnoreCase) ||
f.EndsWith(".tmLanguage.json", StringComparison.OrdinalIgnoreCase))
.Select(f => new GrammarContribution
{
LanguageId = languageId,
GrammarFilePath = f
})
.ToList();
}

private static bool HasGrammarExtension(string filename) =>
filename.EndsWith(".tmLanguage", StringComparison.OrdinalIgnoreCase) ||
filename.EndsWith(".tmLanguage.json", StringComparison.OrdinalIgnoreCase) ||
filename.EndsWith(".tmGrammar", StringComparison.OrdinalIgnoreCase) ||
filename.EndsWith(".tmGrammar.json", StringComparison.OrdinalIgnoreCase);

private static string NormalizePath(string path) =>
path.Replace('/', Path.DirectorySeparatorChar).TrimStart(Path.DirectorySeparatorChar);

private static string SanitizeId(string id) =>
string.Concat(id.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c));

private static void TryDeleteDirectory(string path)
{
try
{
if (Directory.Exists(path))
Directory.Delete(path, recursive: true);
}
catch
{
// Best-effort cleanup only.
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
namespace SharpIDE.Application.Features.LanguageExtensions;

/// <summary>
/// Represents an installed VS Code or Visual Studio language extension (.vsix package).
/// </summary>
public class InstalledExtension
{
public required string Id { get; init; }
public required string Version { get; init; }
public required string Publisher { get; init; }
public required string DisplayName { get; init; }
public required string ExtractedPath { get; init; } // absolute path to extracted dir
public ExtensionPackageKind PackageKind { get; init; } = ExtensionPackageKind.VisualStudio;
public List<LanguageContribution> Languages { get; init; } = [];
public List<GrammarContribution> Grammars { get; init; } = [];
}

public enum ExtensionPackageKind
{
VisualStudio = 0,
VSCode = 1
}

/// <summary>
/// Associates file extensions (and optional filename/shebang patterns) with a language ID.
/// Discovered from .pkgdef entries like [$RootKey$\Languages\File Extensions\.axaml].
/// </summary>
public class LanguageContribution
{
public required string LanguageId { get; init; } // e.g. "axaml"
public string[] FileExtensions { get; init; } = []; // e.g. [".axaml"]
public string[] FileNames { get; init; } = []; // e.g. ["Makefile"]
public string? FirstLinePattern { get; init; } // regex for shebang detection
}

/// <summary>
/// Associates a language ID with a TextMate grammar file.
/// Discovered from vsixmanifest Asset Type="Microsoft.VisualStudio.TextMate.Grammar".
/// </summary>
public class GrammarContribution
{
public required string LanguageId { get; init; }
public string ScopeName { get; init; } = string.Empty;
public required string GrammarFilePath { get; init; } // absolute path after extraction
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System.Text.Json;
using System.Text.Json.Serialization;

namespace SharpIDE.Application.Features.LanguageExtensions;

/// <summary>
/// Loads and saves the installed extensions registry to disk.
/// Storage: %APPDATA%/SharpIDE/extensions/registry.json
/// </summary>
public static class LanguageExtensionPersistence
{
private const string ExtensionsBaseDirectoryOverrideEnvironmentVariable = "SHARPIDE_EXTENSIONS_BASE_DIRECTORY";

private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};

private static string GetRegistryPath()
{
return Path.Combine(GetExtensionsBaseDirectory(), "registry.json");
}

public static string GetExtensionsBaseDirectory()
{
var overrideDirectory = Environment.GetEnvironmentVariable(ExtensionsBaseDirectoryOverrideEnvironmentVariable);
if (!string.IsNullOrWhiteSpace(overrideDirectory))
{
return overrideDirectory;
}

var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
return Path.Combine(appData, "SharpIDE", "extensions");
}

public static List<InstalledExtension> Load()
{
var registryPath = GetRegistryPath();
if (!File.Exists(registryPath)) return [];

try
{
using var stream = File.OpenRead(registryPath);
return JsonSerializer.Deserialize<List<InstalledExtension>>(stream, JsonOptions) ?? [];
}
catch
{
// Corrupt registry — start fresh
return [];
}
}

public static void Save(IReadOnlyList<InstalledExtension> extensions)
{
var registryPath = GetRegistryPath();
Directory.CreateDirectory(Path.GetDirectoryName(registryPath)!);

using var stream = File.Create(registryPath);
JsonSerializer.Serialize(stream, extensions, JsonOptions);
}
}
Loading