From bc8400a039da697f0ade251e590b1de6c8a251fe Mon Sep 17 00:00:00 2001 From: Curtis Wensley Date: Wed, 23 Sep 2026 07:11:54 -0700 Subject: [PATCH] Add autocomplete from project assemblies Fixes #25 --- src/Eto.Designer/Completion/Completion.cs | 28 +- src/Eto.Designer/Completion/CompletionItem.cs | 3 + .../Completion/DocumentCompletion.cs | 86 ++++++- .../Completion/GeneralCompletion.cs | 7 + .../Completion/ProjectTypeCompletion.cs | 86 +++++++ src/Eto.Designer/Completion/ProjectTypes.cs | 101 ++++++++ src/Eto.Designer/Completion/TypeCompletion.cs | 147 +++++++---- src/Eto.Designer/Completion/XmlParser.cs | 4 +- .../CompletionService.cs | 15 +- .../Lsp/Protocol.cs | 1 + .../ProjectAssemblyLoader.cs | 124 +++++++++ .../ProjectAssemblyLocator.cs | 139 ++++++++++ .../XetoLanguageServer.cs | 36 ++- src/Eto.DevExtension.VSCode/README.md | 3 + .../Editor/EditorFactory.cs | 2 +- ...o.DevExtension.VisualStudio.Windows.csproj | 14 + .../Intellisense/EtoLanguageServer.cs | 239 ++++++++++++++++++ .../Intellisense/JetoCompletionSource.cs | 6 +- .../Intellisense/ProjectAssemblyPaths.cs | 71 ++++++ .../Intellisense/XamlCompletionManager.cs | 28 +- .../Intellisense/XamlCompletionSource.cs | 122 +++++---- 21 files changed, 1113 insertions(+), 149 deletions(-) create mode 100644 src/Eto.Designer/Completion/ProjectTypeCompletion.cs create mode 100644 src/Eto.Designer/Completion/ProjectTypes.cs create mode 100644 src/Eto.DevExtension.LanguageServer/ProjectAssemblyLoader.cs create mode 100644 src/Eto.DevExtension.LanguageServer/ProjectAssemblyLocator.cs create mode 100644 src/Eto.DevExtension.VisualStudio.Windows/Intellisense/EtoLanguageServer.cs create mode 100644 src/Eto.DevExtension.VisualStudio.Windows/Intellisense/ProjectAssemblyPaths.cs diff --git a/src/Eto.Designer/Completion/Completion.cs b/src/Eto.Designer/Completion/Completion.cs index f117835..6e60dc6 100644 --- a/src/Eto.Designer/Completion/Completion.cs +++ b/src/Eto.Designer/Completion/Completion.cs @@ -36,7 +36,7 @@ public class CompletionPathNode public string Name { get { - return (Prefix ?? "") + LocalName; + return string.IsNullOrEmpty(Prefix) ? LocalName : Prefix + ":" + LocalName; } } @@ -97,11 +97,11 @@ public virtual bool HandlesPrefix(string prefix) public const string EtoFormsNamespace = "http://schema.picoe.ca/eto.forms"; public const string XamlNamespace2006 = "http://schemas.microsoft.com/winfx/2006/xaml"; - public static IEnumerable GetCompletionItems(IEnumerable namespaces, CompletionMode mode, IEnumerable path, CompletionPathNode context, CompletionFormat format = CompletionFormat.Xaml) + public static IEnumerable GetCompletionItems(IEnumerable namespaces, CompletionMode mode, IEnumerable path, CompletionPathNode context, CompletionFormat format = CompletionFormat.Xaml, IList projectAssemblies = null) { if (mode == CompletionMode.None) return Enumerable.Empty(); - var completions = GetCompletions(namespaces, format).ToList(); + var completions = GetCompletions(namespaces, format, projectAssemblies).ToList(); IEnumerable items; if (mode == CompletionMode.Property && context != null) { @@ -158,21 +158,32 @@ static IEnumerable GetEffectiveNamespaces(IEnumerable GetCompletions(IEnumerable namespaces, CompletionFormat format = CompletionFormat.Xaml) + /// Built assemblies of the project being edited, its own first. + public static IEnumerable GetCompletions(IEnumerable namespaces, CompletionFormat format = CompletionFormat.Xaml, IList projectAssemblies = null) { + projectAssemblies = projectAssemblies ?? Array.Empty(); if (format == CompletionFormat.Json) { - // json has no namespace declarations, everything resolves against Eto.Forms + // json has no namespace declarations, Eto.Forms types go by name and anything else by full name yield return new JsonCompletion(); yield return new TypeCompletion { Assembly = typeof(Eto.Widget).Assembly, Namespace = "Eto.Forms" }; yield return new TypeCompletion { Assembly = typeof(Eto.Widget).Assembly, Namespace = "Eto" }; + foreach (var assembly in projectAssemblies) + yield return new TypeCompletion { Assembly = assembly, UseFullName = true }; yield break; } - yield return new GeneralCompletion(); + yield return new GeneralCompletion { ProjectAssemblies = projectAssemblies }; - foreach (var ns in GetEffectiveNamespaces(namespaces)) + var effective = GetEffectiveNamespaces(namespaces).ToList(); + foreach (var ns in effective) { + if (ProjectTypes.TryParseClrNamespace(ns.Namespace, out var clrNamespace, out var assemblyName)) + { + var assembly = ProjectTypes.FindAssembly(assemblyName, projectAssemblies); + if (assembly != null) + yield return new TypeCompletion { Prefix = ns.Prefix, Assembly = assembly, Namespace = clrNamespace }; + } if (ns.Namespace == EtoFormsNamespace) { yield return new TypeCompletion @@ -199,6 +210,9 @@ public static IEnumerable GetCompletions(IEnumerable 0) + yield return new ProjectTypeCompletion(effective, projectAssemblies); } } } \ No newline at end of file diff --git a/src/Eto.Designer/Completion/CompletionItem.cs b/src/Eto.Designer/Completion/CompletionItem.cs index 8fd8888..b3e0d8f 100644 --- a/src/Eto.Designer/Completion/CompletionItem.cs +++ b/src/Eto.Designer/Completion/CompletionItem.cs @@ -26,6 +26,9 @@ public class CompletionItem public string Suffix { get; set; } public CompletionBehavior Behavior { get; set; } + + /// Namespace the document must declare for this item to resolve, when it hasn't yet. + public CompletionNamespace Namespace { get; set; } } } diff --git a/src/Eto.Designer/Completion/DocumentCompletion.cs b/src/Eto.Designer/Completion/DocumentCompletion.cs index 06cacaa..b719eac 100644 --- a/src/Eto.Designer/Completion/DocumentCompletion.cs +++ b/src/Eto.Designer/Completion/DocumentCompletion.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; namespace Eto.Designer.Completion { @@ -12,6 +14,9 @@ public class DocumentCompletionItem public string Label { get; set; } public string InsertText { get; set; } + + /// Declaration to add to the root element so the item resolves, or null when none is needed. + public DocumentTextEdit NamespaceEdit { get; set; } } /// What the cursor sits on, and the span of text a completion should replace. @@ -27,6 +32,18 @@ public class DocumentCompletionContext /// True when the cursor is already inside a quoted value. public bool Quoted { get; set; } + + public IList ProjectAssemblies { get; set; } + + public string Text { get; set; } + } + + /// Text to insert at an offset of the document the completion was computed for. + public class DocumentTextEdit + { + public int Offset { get; set; } + + public string Text { get; set; } } /// @@ -44,7 +61,7 @@ public static CompletionFormat GetFormat(string path) => : CompletionFormat.Xaml; /// The context, or null when there is nothing to complete at the offset. - public static DocumentCompletionContext GetContext(string text, int offset, CompletionFormat format, string rootTypeName = null) + public static DocumentCompletionContext GetContext(string text, int offset, CompletionFormat format, string rootTypeName = null, IList projectAssemblies = null) { offset = Math.Min(Math.Max(offset, 0), text.Length); @@ -71,7 +88,7 @@ public static DocumentCompletionContext GetContext(string text, int offset, Comp ? ScanForward(text, offset, IsValueChar) : ScanForward(text, offset, format == CompletionFormat.Json ? (Func)IsJsonTokenChar : IsTokenChar); - return new DocumentCompletionContext { Info = info, Format = format, Start = start, End = end, Quoted = quoted }; + return new DocumentCompletionContext { Info = info, Format = format, Start = start, End = end, Quoted = quoted, Text = text, ProjectAssemblies = projectAssemblies }; } public static List GetItems(DocumentCompletionContext context) @@ -83,7 +100,7 @@ public static List GetItems(DocumentCompletionContext co var addColon = addQuotes && info.Mode == CompletionMode.Property; var results = new List(); - foreach (var item in Completion.GetCompletionItems(info.Namespaces, info.Mode, info.Path, info.Context, format).OrderBy(r => r.Name)) + foreach (var item in Completion.GetCompletionItems(info.Namespaces, info.Mode, info.Path, info.Context, format, context.ProjectAssemblies).OrderBy(r => r.Name)) { var label = item.Name; var insert = label; @@ -103,19 +120,20 @@ public static List GetItems(DocumentCompletionContext co insert += ": "; } } - results.Add(new DocumentCompletionItem { Item = item, Label = label, InsertText = insert }); + var namespaceEdit = item.Namespace != null ? GetNamespaceDeclaration(context.Text, item.Namespace) : null; + results.Add(new DocumentCompletionItem { Item = item, Label = label, InsertText = insert, NamespaceEdit = namespaceEdit }); } return results; } - public static List GetCompletions(string text, int offset, CompletionFormat format, string rootTypeName = null) + public static List GetCompletions(string text, int offset, CompletionFormat format, string rootTypeName = null, IList projectAssemblies = null) { - var context = GetContext(text, offset, format, rootTypeName); + var context = GetContext(text, offset, format, rootTypeName, projectAssemblies); return context == null ? new List() : GetItems(context); } /// The completion item for the word under the cursor, used for hover text. - public static CompletionItem FindItemAt(string text, int offset, CompletionFormat format, string rootTypeName, out int start, out int end) + public static CompletionItem FindItemAt(string text, int offset, CompletionFormat format, string rootTypeName, out int start, out int end, IList projectAssemblies = null) { offset = Math.Min(Math.Max(offset, 0), text.Length); start = GetTokenStart(text, offset); @@ -130,10 +148,62 @@ public static CompletionItem FindItemAt(string text, int offset, CompletionForma if (info.Mode == CompletionMode.None) return null; - return Completion.GetCompletionItems(info.Namespaces, info.Mode, info.Path, info.Context, format) + return Completion.GetCompletionItems(info.Namespaces, info.Mode, info.Path, info.Context, format, projectAssemblies) .FirstOrDefault(r => r.Name == word); } + static readonly Regex rootTagReg = new Regex(@"<(?![?!])[A-Za-z_][\w.:-]*", RegexOptions.Compiled); + static readonly Regex xmlnsAttributeReg = new Regex(@"\s+xmlns(:[\w.-]+)?\s*=\s*(""[^""]*""|'[^']*')", RegexOptions.Compiled); + + /// + /// Where to declare a namespace on the root element: after its last xmlns, or its name when it has none. + /// + /// The edit, or null when there is no complete root start tag to add it to. + public static DocumentTextEdit GetNamespaceDeclaration(string text, CompletionNamespace ns) + { + var root = rootTagReg.Match(text); + if (!root.Success) + return null; + + var tagEnd = FindTagEnd(text, root.Index + root.Length); + if (tagEnd < 0) + return null; + + var offset = root.Index + root.Length; + var tag = text.Substring(offset, tagEnd - offset); + var last = xmlnsAttributeReg.Matches(tag).Cast().LastOrDefault(); + if (last != null) + offset += last.Index + last.Length; + + return new DocumentTextEdit + { + Offset = offset, + Text = " xmlns:" + ns.Prefix + "=\"" + ns.Namespace + "\"" + }; + } + + /// Index of the '>' closing a start tag, skipping quoted attribute values. + static int FindTagEnd(string text, int start) + { + var quote = '\0'; + for (var i = start; i < text.Length; i++) + { + var ch = text[i]; + if (quote != '\0') + { + if (ch == quote) + quote = '\0'; + } + else if (ch == '"' || ch == '\'') + quote = ch; + else if (ch == '>') + return i; + else if (ch == '<') + return -1; + } + return -1; + } + static int ScanBack(string text, int offset, Func include) { var i = Math.Min(offset, text.Length); diff --git a/src/Eto.Designer/Completion/GeneralCompletion.cs b/src/Eto.Designer/Completion/GeneralCompletion.cs index 65f863e..a45f540 100644 --- a/src/Eto.Designer/Completion/GeneralCompletion.cs +++ b/src/Eto.Designer/Completion/GeneralCompletion.cs @@ -12,6 +12,8 @@ namespace Eto.Designer.Completion class GeneralCompletion : Completion { + public IList ProjectAssemblies { get; set; } + public override IEnumerable GetClasses(IEnumerable path, Func filter) { yield break; @@ -38,6 +40,11 @@ public override IEnumerable GetPropertyValues(string objectName, yield return new CompletionItem { Name = XamlNamespace2006, Type = CompletionType.Literal }; yield return new CompletionItem { Name = EtoFormsNamespace, Type = CompletionType.Literal }; yield return new CompletionItem { Name = "clr-namespace:[namespace];assembly=[assembly]", Type = CompletionType.Literal }; + foreach (var assembly in ProjectAssemblies ?? Enumerable.Empty()) + { + foreach (var ns in ProjectTypes.GetNamespaces(assembly)) + yield return new CompletionItem { Name = ProjectTypes.GetClrNamespace(ns, assembly), Type = CompletionType.Literal }; + } } } } diff --git a/src/Eto.Designer/Completion/ProjectTypeCompletion.cs b/src/Eto.Designer/Completion/ProjectTypeCompletion.cs new file mode 100644 index 0000000..b546fd2 --- /dev/null +++ b/src/Eto.Designer/Completion/ProjectTypeCompletion.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; + +namespace Eto.Designer.Completion +{ + /// + /// Offers project types from namespaces the xaml document hasn't declared, each carrying the xmlns to add. + /// + class ProjectTypeCompletion : Completion + { + readonly List> sources = new List>(); + + public ProjectTypeCompletion(IList declared, IList projectAssemblies) + { + var usedPrefixes = new HashSet(declared.Select(r => r.Prefix ?? string.Empty)); + foreach (var assembly in projectAssemblies) + { + foreach (var ns in ProjectTypes.GetNamespaces(assembly)) + { + if (declared.Any(r => IsDeclaration(r, ns, assembly, projectAssemblies))) + continue; + + var prefix = CreatePrefix(ns, usedPrefixes); + sources.Add(new KeyValuePair( + new TypeCompletion { Prefix = prefix, Assembly = assembly, Namespace = ns }, + new CompletionNamespace { Prefix = prefix, Namespace = ProjectTypes.GetClrNamespace(ns, assembly) })); + } + } + } + + static bool IsDeclaration(CompletionNamespace declared, string ns, Assembly assembly, IList projectAssemblies) => + ProjectTypes.TryParseClrNamespace(declared.Namespace, out var declaredNs, out var assemblyName) + && declaredNs == ns + && ProjectTypes.FindAssembly(assemblyName, projectAssemblies) == assembly; + + /// Prefix from the last part of the namespace, eg. "controls" for MyApp.Controls. + static string CreatePrefix(string ns, HashSet used) + { + var name = ns.Substring(ns.LastIndexOf('.') + 1); + var sb = new StringBuilder(); + foreach (var ch in name) + { + if (char.IsLetterOrDigit(ch) || ch == '_') + sb.Append(char.ToLowerInvariant(ch)); + } + if (sb.Length == 0 || !char.IsLetter(sb[0])) + sb.Insert(0, "local"); + + var baseName = sb.ToString(); + var prefix = baseName; + // "x" is taken implicitly by Eto's reader even when not declared + for (var i = 2; prefix == "x" || !used.Add(prefix); i++) + prefix = baseName + i; + return prefix; + } + + public override IEnumerable GetClasses(IEnumerable path, Func filter) + { + // with no content type to match there is nothing sensible to offer + if (filter == null) + yield break; + + foreach (var source in sources) + { + foreach (var item in source.Key.GetClasses(path, filter)) + { + if (item.Type != CompletionType.Class) + continue; + item.Namespace = source.Value; + item.Suffix = source.Key.Namespace; + yield return item; + } + } + } + + public override IEnumerable GetProperties(string objectName, IEnumerable path) + { + yield break; + } + + public override bool HandlesPrefix(string prefix) => true; + } +} diff --git a/src/Eto.Designer/Completion/ProjectTypes.cs b/src/Eto.Designer/Completion/ProjectTypes.cs new file mode 100644 index 0000000..721d502 --- /dev/null +++ b/src/Eto.Designer/Completion/ProjectTypes.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; + +namespace Eto.Designer.Completion +{ + /// + /// Type lookups over the assemblies of the project being edited, tolerant of types that fail to load. + /// + static class ProjectTypes + { + const string ClrNamespacePrefix = "clr-namespace:"; + + static readonly ConditionalWeakTable> types = new ConditionalWeakTable>(); + static readonly ConditionalWeakTable> namespaces = new ConditionalWeakTable>(); + + /// Public types of the assembly, skipping any whose dependencies can't be found. + public static List GetTypes(Assembly assembly) => types.GetValue(assembly, LoadTypes); + + static List LoadTypes(Assembly assembly) + { + try + { + return assembly.GetExportedTypes().ToList(); + } + catch (Exception) + { + try + { + return assembly.GetTypes().Where(r => r.IsVisible).ToList(); + } + catch (ReflectionTypeLoadException ex) + { + return ex.Types.Where(r => r != null && r.IsVisible).ToList(); + } + catch (Exception) + { + return new List(); + } + } + } + + /// Namespaces holding at least one type a designer file could create. + public static List GetNamespaces(Assembly assembly) => + namespaces.GetValue(assembly, a => GetTypes(a) + .Where(r => IsCreatable(r) && !string.IsNullOrEmpty(r.Namespace)) + .Select(r => r.Namespace) + .Distinct() + .OrderBy(r => r, StringComparer.Ordinal) + .ToList()); + + public static bool IsCreatable(Type type) => !type.IsGenericType && !type.IsAbstract && !type.IsNested; + + public static string GetAssemblyName(Assembly assembly) => assembly.GetName().Name; + + public static string GetClrNamespace(string ns, Assembly assembly) => + ClrNamespacePrefix + ns + ";assembly=" + GetAssemblyName(assembly); + + /// Splits an xmlns value such as "clr-namespace:My.Controls;assembly=MyApp". + public static bool TryParseClrNamespace(string value, out string ns, out string assemblyName) + { + ns = assemblyName = null; + if (value == null || !value.StartsWith(ClrNamespacePrefix, StringComparison.Ordinal)) + return false; + + foreach (var part in value.Substring(ClrNamespacePrefix.Length).Split(';')) + { + var trimmed = part.Trim(); + if (trimmed.StartsWith("assembly=", StringComparison.Ordinal)) + assemblyName = trimmed.Substring("assembly=".Length).Trim(); + else if (ns == null) + ns = trimmed; + } + return !string.IsNullOrEmpty(ns); + } + + /// + /// Assembly an xmlns refers to. Without an assembly name it is the project's own, as Eto's + /// xaml reader uses the assembly of the object being loaded. + /// + public static Assembly FindAssembly(string assemblyName, IList projectAssemblies) + { + if (string.IsNullOrEmpty(assemblyName)) + return projectAssemblies?.FirstOrDefault(); + + var project = projectAssemblies?.FirstOrDefault(r => GetAssemblyName(r) == assemblyName); + if (project != null) + return project; + + // prefer the copies completion already reflects over, eg. clr-namespace:Eto.Drawing;assembly=Eto + foreach (var known in new[] { typeof(Eto.Widget).Assembly, typeof(Eto.Serialization.Xaml.XamlReader).Assembly }) + { + if (GetAssemblyName(known) == assemblyName) + return known; + } + return AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(r => !r.IsDynamic && GetAssemblyName(r) == assemblyName); + } + } +} diff --git a/src/Eto.Designer/Completion/TypeCompletion.cs b/src/Eto.Designer/Completion/TypeCompletion.cs index 081d17a..df0ebf4 100644 --- a/src/Eto.Designer/Completion/TypeCompletion.cs +++ b/src/Eto.Designer/Completion/TypeCompletion.cs @@ -17,17 +17,69 @@ class TypeCompletion : Completion { public Assembly Assembly { get; set; } + /// Namespace to offer types from, or null for every namespace in the assembly. public string Namespace { get; set; } + /// Names types as "Namespace.Type, Assembly", the way a .jeto $type refers to types outside Eto. + public bool UseFullName { get; set; } + List exportedTypes; List GetExportedTypes() { if (exportedTypes == null) - exportedTypes = Assembly.ExportedTypes.Where(r => !r.IsGenericType && !r.IsAbstract && r.Namespace == Namespace).ToList(); + exportedTypes = ProjectTypes.GetTypes(Assembly).Where(r => ProjectTypes.IsCreatable(r) && (Namespace == null || r.Namespace == Namespace)).ToList(); return exportedTypes; } + string GetName(Type type) => + UseFullName ? type.FullName + ", " + ProjectTypes.GetAssemblyName(type.Assembly) : PrefixWithColon + type.Name; + + bool IsOwnType(Type type) => + type.Assembly == Assembly && (Namespace == null || type.Namespace == Namespace); + + /// Type named by an object in the document, or null when it isn't one of ours. + Type FindType(string objectName) + { + if (string.IsNullOrEmpty(objectName)) + return null; + string fullName; + if (UseFullName) + { + // json paths drop the assembly name, but a $type value being typed may still have it + var comma = objectName.IndexOf(','); + fullName = (comma >= 0 ? objectName.Substring(0, comma) : objectName).Trim(); + } + else + { + var prefix = PrefixWithColon; + if (!objectName.StartsWith(prefix)) + return null; + fullName = Namespace + "." + objectName.Substring(prefix.Length); + } + try + { + return Assembly.GetType(fullName, false); + } + catch (Exception) + { + // project types can fail to load when one of their dependencies is missing + return null; + } + } + + static bool Matches(Func filter, Type type) + { + try + { + return filter(type); + } + catch (Exception) + { + return false; + } + } + public static sc.TypeConverter GetConverter(Type type) { var attribute = type.GetCustomAttribute(false); @@ -78,34 +130,34 @@ public override IEnumerable GetClasses(IEnumerable path, var types = GetExportedTypes(); if (filter != null) { - foreach (var result in types.Where(filter)) + foreach (var result in types.Where(r => Matches(filter, r))) { // special case, don't allow windows as a child control if (contentType != null && typeof(Window).IsAssignableFrom(result)) continue; - + yield return new CompletionItem - { - Name = prefixWithColon + result.Name, + { + Name = GetName(result), Description = XmlComments.GetSummary(result), Type = CompletionType.Class - }; + }; } } - if (contentType != null - && !contentType.IsAbstract + if (contentType != null + && !contentType.IsAbstract && !types.Contains(contentType) && contentType.Assembly == Assembly) yield return new CompletionItem - { - Name = prefixWithColon + contentType.Name, + { + Name = GetName(contentType), Description = XmlComments.GetSummary(contentType), Type = CompletionType.Class - }; - if (nodeType != null && !lastPath.Contains(".")) + }; + if (nodeType != null && !UseFullName && !lastPath.Contains(".")) { yield return new CompletionItem { @@ -123,7 +175,7 @@ public override string GetImpliedTypeName(IEnumerable path) string propertyName; var nodeType = GetNodeType(path?.LastOrDefault(), out propertyName); var contentType = GetContentType(nodeType, propertyName); - return contentType != null ? PrefixWithColon + contentType.Name : null; + return contentType != null && IsOwnType(contentType) ? GetName(contentType) : null; } Type GetNodeType(string last, out string propertyName) @@ -131,6 +183,23 @@ Type GetNodeType(string last, out string propertyName) propertyName = null; if (string.IsNullOrEmpty(last)) return null; + + var types = GetExportedTypes(); + if (UseFullName) + { + // a full name is dotted already, so "My.Panel.Content" is either a type or a type's property + var type = FindType(last); + if (type == null) + { + var lastDot = last.LastIndexOf('.'); + if (lastDot <= 0) + return null; + propertyName = last.Substring(lastDot + 1); + type = FindType(last.Substring(0, lastDot)); + } + return type != null && types.Contains(type) ? type : null; + } + var prefix = PrefixWithColon; if (!string.IsNullOrEmpty(prefix)) { @@ -150,7 +219,6 @@ Type GetNodeType(string last, out string propertyName) last = last.Substring(0, dotIndex); } last = Namespace + "." + last; - var types = GetExportedTypes(); return types.FirstOrDefault(r => r.FullName == last); } @@ -189,18 +257,7 @@ Type GetContentType(Type type, string propertyName) public override bool? HasContent(string objectName, IEnumerable path) { - if (string.IsNullOrEmpty(objectName)) - return null; - var prefix = PrefixWithColon; - if (prefix != null) - { - if (!objectName.StartsWith(prefix)) - return false; - objectName = objectName.Substring(prefix.Length); - } - - var fullName = Namespace + "." + objectName; - var type = Assembly.GetType(fullName, false); + var type = FindType(objectName); if (type != null) { return type.GetTypeInfo().GetCustomAttribute() != null; @@ -210,18 +267,7 @@ Type GetContentType(Type type, string propertyName) public override IEnumerable GetProperties(string objectName, IEnumerable path) { - if (string.IsNullOrEmpty(objectName)) - yield break; - var prefix = PrefixWithColon; - if (prefix != null) - { - if (!objectName.StartsWith(prefix)) - yield break; - objectName = objectName.Substring(prefix.Length); - } - - var fullName = Namespace + "." + objectName; - var type = Assembly.GetType(fullName, false); + var type = FindType(objectName); if (type != null) { foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) @@ -234,24 +280,24 @@ public override IEnumerable GetProperties(string objectName, IEn var underlyingType = Nullable.GetUnderlyingType(prop.PropertyType); var suffix = underlyingType != null ? underlyingType.Name + "?" : prop.PropertyType.Name; - + // todo: get friendly names for generic types yield return new CompletionItem - { + { Name = prop.Name, Suffix = suffix, Description = XmlComments.GetSummary(prop), - Type = CompletionType.Property + Type = CompletionType.Property }; } foreach (var evt in type.GetEvents(BindingFlags.Public | BindingFlags.Instance)) { yield return new CompletionItem - { + { Name = evt.Name, Description = XmlComments.GetSummary(evt), - Type = CompletionType.Event + Type = CompletionType.Event }; } } @@ -259,18 +305,7 @@ public override IEnumerable GetProperties(string objectName, IEn public override IEnumerable GetPropertyValues(string objectName, string propertyName, IEnumerable path) { - if (string.IsNullOrEmpty(objectName)) - yield break; - var prefix = PrefixWithColon; - if (prefix != null) - { - if (!objectName.StartsWith(prefix)) - yield break; - objectName = objectName.Substring(prefix.Length); - } - - var fullName = Namespace + "." + objectName; - var type = Assembly.GetType(fullName, false); + var type = FindType(objectName); if (type != null) { var prop = type.GetRuntimeProperty(propertyName); @@ -296,7 +331,7 @@ public override IEnumerable GetPropertyValues(string objectName, foreach (var name in Enum.GetNames(propertyType)) { yield return new CompletionItem - { + { Type = CompletionType.Literal, Name = name, Description = XmlComments.GetEnum(prop, name) diff --git a/src/Eto.Designer/Completion/XmlParser.cs b/src/Eto.Designer/Completion/XmlParser.cs index dc49d75..e9be5e7 100644 --- a/src/Eto.Designer/Completion/XmlParser.cs +++ b/src/Eto.Designer/Completion/XmlParser.cs @@ -13,10 +13,10 @@ public static class XmlParser { const RegexOptions opts = RegexOptions.Compiled | RegexOptions.Singleline | RegexOptions.IgnorePatternWhitespace | RegexOptions.IgnoreCase; static readonly Regex valueReg = new Regex(@"(?<=\w+\s*=\s*)(('[^']*)|(""[^""]*))?$", opts); - static readonly Regex propertyReg = new Regex(@"([<]\w+\s+)([^<>]+\s+)?[^<>/]*$", opts); + static readonly Regex propertyReg = new Regex(@"([<](\w+:)?\w+\s+)([^<>]+\s+)?[^<>/]*$", opts); //static readonly Regex propertyReg = new Regex(@"([<]\w+\s+)([^<]*)?(?])|[>])[^<]*)$", opts); static readonly Regex classReg = new Regex(@"([<]\w*)$", opts); - static readonly Regex classPropertyReg = new Regex(@"([<]\w*[.])$", opts); + static readonly Regex classPropertyReg = new Regex(@"([<](\w+:)?\w*[.])$", opts); static readonly Regex usedPrefixReg = new Regex(@"(?:[<]|\s)(?

[A-Za-z_][\w.-]*):", opts); static readonly Regex declaredPrefixReg = new Regex(@"xmlns:(?

[\w.-]+)\s*=", opts); static readonly Regex defaultNamespaceReg = new Regex(@"(? GetCompletions(string text, int offset, CompletionFormat format, string rootTypeName = null) + public static List GetCompletions(string text, int offset, CompletionFormat format, string rootTypeName = null, IList projectAssemblies = null) { var results = new List(); - var context = DocumentCompletion.GetContext(text, offset, format, rootTypeName); + var context = DocumentCompletion.GetContext(text, offset, format, rootTypeName, projectAssemblies); if (context == null) return results; @@ -40,15 +41,19 @@ static CompletionService() Documentation = ToMarkup(item.Item.Description), SortText = item.Label, FilterText = item.Label, - TextEdit = new TextEdit { Range = range, NewText = item.InsertText } + TextEdit = new TextEdit { Range = range, NewText = item.InsertText }, + AdditionalTextEdits = item.NamespaceEdit == null ? null : new List + { + new TextEdit { Range = ToRange(text, item.NamespaceEdit.Offset, item.NamespaceEdit.Offset), NewText = item.NamespaceEdit.Text } + } }); } return results; } - public static Hover GetHover(string text, int offset, CompletionFormat format, string rootTypeName = null) + public static Hover GetHover(string text, int offset, CompletionFormat format, string rootTypeName = null, IList projectAssemblies = null) { - var match = DocumentCompletion.FindItemAt(text, offset, format, rootTypeName, out var start, out var end); + var match = DocumentCompletion.FindItemAt(text, offset, format, rootTypeName, out var start, out var end, projectAssemblies); if (match == null || string.IsNullOrWhiteSpace(match.Description)) return null; diff --git a/src/Eto.DevExtension.LanguageServer/Lsp/Protocol.cs b/src/Eto.DevExtension.LanguageServer/Lsp/Protocol.cs index 78e9367..ed505c1 100644 --- a/src/Eto.DevExtension.LanguageServer/Lsp/Protocol.cs +++ b/src/Eto.DevExtension.LanguageServer/Lsp/Protocol.cs @@ -37,6 +37,7 @@ public class CompletionItem public string SortText { get; set; } public string FilterText { get; set; } public TextEdit TextEdit { get; set; } + public List AdditionalTextEdits { get; set; } } public class CompletionList diff --git a/src/Eto.DevExtension.LanguageServer/ProjectAssemblyLoader.cs b/src/Eto.DevExtension.LanguageServer/ProjectAssemblyLoader.cs new file mode 100644 index 0000000..e9c332f --- /dev/null +++ b/src/Eto.DevExtension.LanguageServer/ProjectAssemblyLoader.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.Loader; + +namespace Eto.DevExtension.LanguageServer +{ + ///

+ /// Loads a project's built assemblies so completion can reflect over them, reloading after each build. + /// + /// + /// Files are read into memory so builds can still overwrite them, and each set lives in its own + /// context so the previous build can be unloaded. + /// + public static class ProjectAssemblyLoader + { + class LoadedSet + { + public string Stamp; + public ProjectLoadContext Context; + public IList Assemblies; + } + + static readonly Dictionary sets = new Dictionary(StringComparer.OrdinalIgnoreCase); + static readonly object loadLock = new object(); + + /// Assembly files, the project's own first. + public static IList Load(IList paths, Action log = null) + { + var files = paths?.Where(File.Exists).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + if (files == null || files.Count == 0) + return Array.Empty(); + + var key = string.Join("|", files); + var stamp = GetStamp(files); + lock (loadLock) + { + if (sets.TryGetValue(key, out var set) && set.Stamp == stamp) + return set.Assemblies; + + set?.Context.Unload(); + sets.Remove(key); + + // project assemblies must bind to the Eto completion reflects over, so make sure it's loaded first + _ = typeof(Eto.Widget).Assembly; + _ = typeof(Eto.Serialization.Xaml.XamlReader).Assembly; + + var context = new ProjectLoadContext(files); + var assemblies = new List(); + foreach (var file in files) + { + try + { + assemblies.Add(context.LoadFile(file)); + } + catch (Exception ex) when (ex is IOException || ex is BadImageFormatException || ex is UnauthorizedAccessException) + { + // likely mid-build, so don't cache and try again next time + log?.Invoke($"Could not load {file}: {ex.Message}"); + context.Unload(); + return Array.Empty(); + } + } + + log?.Invoke($"Loaded project assemblies: {string.Join(", ", files.Select(Path.GetFileName))}"); + sets[key] = new LoadedSet { Stamp = stamp, Context = context, Assemblies = assemblies }; + return assemblies; + } + } + + static string GetStamp(IEnumerable files) => + string.Join("|", files.Select(r => + { + var info = new FileInfo(r); + return info.LastWriteTimeUtc.Ticks + ":" + info.Length; + })); + + class ProjectLoadContext : AssemblyLoadContext + { + readonly Dictionary files; + readonly List directories; + + public ProjectLoadContext(IEnumerable paths) : base("Eto project assemblies", isCollectible: true) + { + files = paths.GroupBy(r => Path.GetFileNameWithoutExtension(r), StringComparer.OrdinalIgnoreCase) + .ToDictionary(r => r.Key, r => r.First(), StringComparer.OrdinalIgnoreCase); + directories = paths.Select(Path.GetDirectoryName).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + } + + public Assembly LoadFile(string path) + { + var name = Path.GetFileNameWithoutExtension(path); + var loaded = Assemblies.FirstOrDefault(r => string.Equals(r.GetName().Name, name, StringComparison.OrdinalIgnoreCase)); + if (loaded != null) + return loaded; + using (var stream = new MemoryStream(File.ReadAllBytes(path))) + return LoadFromStream(stream); + } + + protected override Assembly Load(AssemblyName name) + { + // share whatever the server already has regardless of version, so Eto types stay the same types + var shared = Default.Assemblies.FirstOrDefault(r => string.Equals(r.GetName().Name, name.Name, StringComparison.OrdinalIgnoreCase)); + if (shared != null) + return shared; + + if (files.TryGetValue(name.Name, out var file)) + return LoadFile(file); + + foreach (var directory in directories) + { + var candidate = Path.Combine(directory, name.Name + ".dll"); + if (File.Exists(candidate)) + return LoadFile(candidate); + } + + // framework assemblies come from the default context + return null; + } + } + } +} diff --git a/src/Eto.DevExtension.LanguageServer/ProjectAssemblyLocator.cs b/src/Eto.DevExtension.LanguageServer/ProjectAssemblyLocator.cs new file mode 100644 index 0000000..03d9a23 --- /dev/null +++ b/src/Eto.DevExtension.LanguageServer/ProjectAssemblyLocator.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Eto.DevExtension.LanguageServer +{ + /// + /// Finds the built assemblies of the project a designer file belongs to and the projects it references. + /// + /// + /// Looks for the newest build under each project's bin folder, so a project that builds elsewhere + /// needs its paths supplied by the client instead. + /// + public static class ProjectAssemblyLocator + { + static readonly string[] ProjectExtensions = { "*.csproj", "*.fsproj", "*.vbproj" }; + static readonly Regex assemblyNameReg = new Regex(@"\s*(?[^<$]+?)\s*", RegexOptions.Compiled); + static readonly Regex projectReferenceReg = new Regex(@"[^""]+)""", RegexOptions.Compiled); + + // scanning bin on every keystroke is too slow, but a rebuild into a new folder should still be noticed + static readonly TimeSpan CacheTime = TimeSpan.FromSeconds(10); + static readonly ConcurrentDictionary Paths)> cache = new ConcurrentDictionary)>(StringComparer.OrdinalIgnoreCase); + + /// Assembly files for the document's project, its own first, or empty when it hasn't been built. + public static List Find(string documentPath, Action log = null) + { + var project = FindProject(documentPath); + if (project == null) + return new List(); + + if (cache.TryGetValue(project, out var cached) && DateTime.UtcNow - cached.Time < CacheTime) + return cached.Paths; + + var paths = new List(); + var main = FindOutput(project); + if (main != null) + { + paths.Add(main); + var outputDir = Path.GetDirectoryName(main); + var visited = new HashSet(StringComparer.OrdinalIgnoreCase) { project }; + AddReferences(project, outputDir, paths, visited); + } + else + log?.Invoke($"No build output found for {project}, so its types won't be offered until it is built."); + + cache[project] = (DateTime.UtcNow, paths); + return paths; + } + + static string FindProject(string documentPath) + { + if (string.IsNullOrEmpty(documentPath)) + return null; + var dir = new FileInfo(documentPath).Directory; + while (dir != null) + { + var project = ProjectExtensions.SelectMany(p => dir.EnumerateFiles(p)).FirstOrDefault(); + if (project != null) + return project.FullName; + dir = dir.Parent; + } + return null; + } + + static void AddReferences(string project, string outputDir, List paths, HashSet visited) + { + foreach (var reference in ReadProjectReferences(project)) + { + if (!visited.Add(reference) || !File.Exists(reference)) + continue; + + // prefer the copy built alongside the main project so the versions match + var name = GetAssemblyName(reference); + var local = new[] { ".dll", ".exe" }.Select(r => Path.Combine(outputDir, name + r)).FirstOrDefault(File.Exists); + var path = local ?? FindOutput(reference); + if (path != null && !paths.Contains(path, StringComparer.OrdinalIgnoreCase)) + paths.Add(path); + + AddReferences(reference, outputDir, paths, visited); + } + } + + static IEnumerable ReadProjectReferences(string project) + { + var dir = Path.GetDirectoryName(project); + return projectReferenceReg.Matches(ReadText(project)) + .Cast() + .Select(r => Path.GetFullPath(Path.Combine(dir, r.Groups["path"].Value.Replace('\\', Path.DirectorySeparatorChar)))) + .ToList(); + } + + static string GetAssemblyName(string project) + { + var match = assemblyNameReg.Match(ReadText(project)); + return match.Success ? match.Groups["name"].Value : Path.GetFileNameWithoutExtension(project); + } + + /// Newest build of the project's assembly under its bin folder. + static string FindOutput(string project) + { + var bin = Path.Combine(Path.GetDirectoryName(project), "bin"); + if (!Directory.Exists(bin)) + return null; + + var name = GetAssemblyName(project); + try + { + // an .exe next to a .dll is the native launcher of a .NET app, so only fall back to it + return new[] { ".dll", ".exe" } + .Select(extension => new DirectoryInfo(bin) + .EnumerateFiles(name + extension, SearchOption.AllDirectories) + // skip ref/ reference assemblies, which have no usable types + .Where(r => !string.Equals(r.Directory?.Name, "ref", StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(r => r.LastWriteTimeUtc) + .FirstOrDefault()) + .FirstOrDefault(r => r != null)?.FullName; + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + return null; + } + } + + static string ReadText(string path) + { + try + { + return File.ReadAllText(path); + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + return string.Empty; + } + } + } +} diff --git a/src/Eto.DevExtension.LanguageServer/XetoLanguageServer.cs b/src/Eto.DevExtension.LanguageServer/XetoLanguageServer.cs index f5cd3a1..8bbbfcc 100644 --- a/src/Eto.DevExtension.LanguageServer/XetoLanguageServer.cs +++ b/src/Eto.DevExtension.LanguageServer/XetoLanguageServer.cs @@ -1,5 +1,8 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.Linq; +using System.Reflection; using System.IO; using System.Text.Json.Nodes; using Eto.DevExtension.LanguageServer.Lsp; @@ -12,6 +15,7 @@ public class XetoLanguageServer { readonly LspConnection connection; readonly DocumentStore documents = new DocumentStore(); + readonly ConcurrentDictionary> clientAssemblies = new ConcurrentDictionary>(StringComparer.Ordinal); bool warnedAboutVersion; @@ -28,6 +32,7 @@ public XetoLanguageServer(LspConnection connection) connection.OnNotification("textDocument/didClose", DidClose); connection.OnRequest("textDocument/completion", Complete); connection.OnRequest("textDocument/hover", HoverAt); + connection.OnNotification("eto/setProjectAssemblies", SetProjectAssemblies); } object Initialize(JsonNode parameters) @@ -86,6 +91,7 @@ void DidClose(JsonNode parameters) if (uri != null) { documents.Remove(uri); + clientAssemblies.TryRemove(uri, out _); RootTypeLocator.Forget(ToLocalPath(uri)); } } @@ -94,14 +100,40 @@ object Complete(JsonNode parameters) { if (!TryGetContext(parameters, out var text, out var offset, out var format, out var rootType)) return new CompletionList(); - return new CompletionList { Items = CompletionService.GetCompletions(text, offset, format, rootType) }; + return new CompletionList { Items = CompletionService.GetCompletions(text, offset, format, rootType, GetProjectAssemblies(parameters)) }; } object HoverAt(JsonNode parameters) { if (!TryGetContext(parameters, out var text, out var offset, out var format, out var rootType)) return null; - return CompletionService.GetHover(text, offset, format, rootType); + return CompletionService.GetHover(text, offset, format, rootType, GetProjectAssemblies(parameters)); + } + + /// + /// Lets a client that knows the project's real build output, such as Visual Studio, say where + /// the document's assemblies are instead of having them searched for. + /// + void SetProjectAssemblies(JsonNode parameters) + { + var uri = parameters?["textDocument"]?["uri"]?.GetValue(); + var assemblies = (parameters?["assemblies"] as JsonArray)?.Select(r => r?.GetValue()).Where(r => !string.IsNullOrEmpty(r)).ToList(); + if (uri == null) + return; + if (assemblies == null) + clientAssemblies.TryRemove(uri, out _); + else + clientAssemblies[uri] = assemblies; + } + + IList GetProjectAssemblies(JsonNode parameters) + { + var uri = parameters?["textDocument"]?["uri"]?.GetValue(); + if (uri == null) + return null; + if (!clientAssemblies.TryGetValue(uri, out var paths)) + paths = ProjectAssemblyLocator.Find(ToLocalPath(uri), connection.Log); + return ProjectAssemblyLoader.Load(paths, connection.Log); } bool TryGetContext(JsonNode parameters, out string text, out int offset, out Eto.Designer.Completion.CompletionFormat format, out string rootTypeName) diff --git a/src/Eto.DevExtension.VSCode/README.md b/src/Eto.DevExtension.VSCode/README.md index 328bdda..5caab6e 100644 --- a/src/Eto.DevExtension.VSCode/README.md +++ b/src/Eto.DevExtension.VSCode/README.md @@ -5,6 +5,9 @@ Autocompletion and hover documentation for [Eto.Forms](https://github.com/picoe/ ## Features - Completion of control names, properties, events and property values in `.xeto` (xaml) and `.jeto` (json) files +- Completion of your own controls from the project and the projects it references, once built. In + `.xeto` files picking one adds the `xmlns` it needs; in `.jeto` files they complete as + `"Namespace.Type, Assembly"` - Hover documentation pulled from the Eto.Forms xml docs - Syntax highlighting for both file types diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Editor/EditorFactory.cs b/src/Eto.DevExtension.VisualStudio.Windows/Editor/EditorFactory.cs index 976fc47..3c0f488 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/Editor/EditorFactory.cs +++ b/src/Eto.DevExtension.VisualStudio.Windows/Editor/EditorFactory.cs @@ -288,7 +288,7 @@ public static IEnumerable GetReferences(EnvDTE.Project project) } } } - static string GetAssemblyPath(EnvDTE.Project vsProject) + internal static string GetAssemblyPath(EnvDTE.Project vsProject) { Microsoft.VisualStudio.Shell.ThreadHelper.ThrowIfNotOnUIThread(); string fullPath = vsProject.Properties?.Item("FullPath")?.Value?.ToString(); diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Eto.DevExtension.VisualStudio.Windows.csproj b/src/Eto.DevExtension.VisualStudio.Windows/Eto.DevExtension.VisualStudio.Windows.csproj index bc9cc8d..ae16b09 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/Eto.DevExtension.VisualStudio.Windows.csproj +++ b/src/Eto.DevExtension.VisualStudio.Windows/Eto.DevExtension.VisualStudio.Windows.csproj @@ -89,9 +89,11 @@ + + @@ -268,6 +270,18 @@ --> + + + $(MSBuildProjectDirectory)\..\Eto.DevExtension.LanguageServer\Eto.DevExtension.LanguageServer.csproj + $(ArtifactsDir)Eto.DevExtension.LanguageServer\publish\$(Configuration)\ + + + + + + + + diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Intellisense/EtoLanguageServer.cs b/src/Eto.DevExtension.VisualStudio.Windows/Intellisense/EtoLanguageServer.cs new file mode 100644 index 0000000..551cb19 --- /dev/null +++ b/src/Eto.DevExtension.VisualStudio.Windows/Intellisense/EtoLanguageServer.cs @@ -0,0 +1,239 @@ +using Eto.Designer.Completion; +using Newtonsoft.Json.Linq; +using StreamJsonRpc; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Eto.DevExtension.VisualStudio.Intellisense +{ + /// + /// The .NET language server shared with VS Code, started on first use. + /// + /// + /// Completions come from here so the project's own assemblies are never loaded into Visual Studio. + /// + static class EtoLanguageServer + { + const string ServerDll = "Eto.DevExtension.LanguageServer.dll"; + + static readonly SemaphoreSlim startLock = new SemaphoreSlim(1, 1); + // the server handles one message at a time, and the text sent must match the request that follows it + static readonly SemaphoreSlim requestLock = new SemaphoreSlim(1, 1); + static readonly ConcurrentDictionary versions = new ConcurrentDictionary(); + static readonly ConcurrentDictionary sentAssemblies = new ConcurrentDictionary(); + + static Process process; + static JsonRpc rpc; + static bool unavailable; + + static bool IsRunning => rpc != null && process != null && !process.HasExited; + + /// Makes the server re-read the document's code behind, as when a file is reopened. + public static void Reset(string filePath) + { + var uri = ToUri(filePath); + if (uri == null || !versions.TryRemove(uri, out _) || !IsRunning) + return; + sentAssemblies.TryRemove(uri, out _); + _ = rpc.NotifyWithParameterObjectAsync("textDocument/didClose", new { textDocument = new { uri } }); + } + + /// Project assembly files, the document's own first, or null to let the server find them. + /// The completions, or null when the server can't be used. + public static async Task> GetCompletionsAsync(string filePath, string text, int offset, IList assemblies, CancellationToken token) + { + var uri = ToUri(filePath); + if (uri == null) + return null; + + var server = await GetServerAsync(); + if (server == null) + return null; + + await requestLock.WaitAsync(token); + try + { + var version = versions.AddOrUpdate(uri, 1, (key, value) => value + 1); + if (version == 1) + await server.NotifyWithParameterObjectAsync("textDocument/didOpen", new { textDocument = new { uri, languageId = "eto", version, text } }); + else + await server.NotifyWithParameterObjectAsync("textDocument/didChange", new { textDocument = new { uri, version }, contentChanges = new[] { new { text } } }); + + var assemblyKey = assemblies == null ? string.Empty : string.Join("|", assemblies); + if (sentAssemblies.TryGetValue(uri, out var sent) ? sent != assemblyKey : assemblies != null) + { + await server.NotifyWithParameterObjectAsync("eto/setProjectAssemblies", new { textDocument = new { uri }, assemblies }); + sentAssemblies[uri] = assemblyKey; + } + + var position = GetPosition(text, offset); + var result = await server.InvokeWithParameterObjectAsync( + "textDocument/completion", + new { textDocument = new { uri }, position = new { line = position.Line, character = position.Character } }, + token); + return ReadItems(result, text); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Debug.WriteLine($"Eto language server request failed: {ex}"); + return null; + } + finally + { + requestLock.Release(); + } + } + + static async Task GetServerAsync() + { + if (IsRunning) + return rpc; + if (unavailable) + return null; + + await startLock.WaitAsync(); + try + { + if (IsRunning) + return rpc; + + var serverPath = Path.Combine(Path.GetDirectoryName(typeof(EtoLanguageServer).Assembly.Location), "server", ServerDll); + if (!File.Exists(serverPath)) + { + unavailable = true; + Debug.WriteLine($"Eto language server not found at {serverPath}"); + return null; + } + + var start = new ProcessStartInfo("dotnet", "\"" + serverPath + "\"") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + process = Process.Start(start); + process.ErrorDataReceived += (sender, e) => Debug.WriteLine(e.Data); + process.BeginErrorReadLine(); + + rpc = new JsonRpc(new HeaderDelimitedMessageHandler(process.StandardInput.BaseStream, process.StandardOutput.BaseStream)); + rpc.StartListening(); + await rpc.InvokeWithParameterObjectAsync("initialize", new { processId = Process.GetCurrentProcess().Id, capabilities = new { } }); + await rpc.NotifyWithParameterObjectAsync("initialized", new { }); + + // a new process knows nothing of what was sent to the last one + versions.Clear(); + sentAssemblies.Clear(); + return rpc; + } + catch (Exception ex) + { + // usually no .NET runtime to run it with, so don't keep trying + unavailable = true; + Debug.WriteLine($"Could not start the Eto language server: {ex}"); + return null; + } + finally + { + startLock.Release(); + } + } + + static List ReadItems(JToken result, string text) + { + var items = new List(); + var list = result?["items"] as JArray ?? result as JArray; + if (list == null) + return items; + + foreach (var entry in list) + { + var label = (string)entry["label"]; + if (label == null) + continue; + + DocumentTextEdit namespaceEdit = null; + var extra = (entry["additionalTextEdits"] as JArray)?.FirstOrDefault(); + if (extra != null) + { + var start = extra["range"]?["start"]; + namespaceEdit = new DocumentTextEdit + { + Offset = GetOffset(text, (int?)start?["line"] ?? 0, (int?)start?["character"] ?? 0), + Text = (string)extra["newText"] + }; + } + + items.Add(new DocumentCompletionItem + { + Label = label, + InsertText = (string)entry["textEdit"]?["newText"] ?? label, + NamespaceEdit = namespaceEdit, + Item = new Designer.Completion.CompletionItem + { + Name = label, + Type = GetType((int?)entry["kind"] ?? 0), + Suffix = (string)entry["detail"], + // markdown from the server, which the completion tooltip shows as plain text + Description = ((string)entry["documentation"]?["value"])?.Replace("`", string.Empty), + Behavior = label.EndsWith(".") ? CompletionBehavior.ChildProperty : CompletionBehavior.None + } + }); + } + return items; + } + + // LSP CompletionItemKind values the server sends + static CompletionType GetType(int kind) + { + switch (kind) + { + case 7: return CompletionType.Class; + case 10: return CompletionType.Property; + case 23: return CompletionType.Event; + case 5: return CompletionType.Field; + default: return CompletionType.Literal; + } + } + + static string ToUri(string filePath) + { + if (string.IsNullOrEmpty(filePath) || !Path.IsPathRooted(filePath)) + return null; + return new Uri(filePath).AbsoluteUri; + } + + static (int Line, int Character) GetPosition(string text, int offset) + { + offset = Math.Min(Math.Max(offset, 0), text.Length); + var lineStart = offset == 0 ? 0 : text.LastIndexOf('\n', offset - 1) + 1; + var line = 0; + for (var i = 0; i < lineStart; i++) + { + if (text[i] == '\n') + line++; + } + return (line, offset - lineStart); + } + + static int GetOffset(string text, int line, int character) + { + var offset = 0; + for (var i = 0; i < line; i++) + { + var next = text.IndexOf('\n', offset); + if (next < 0) + return text.Length; + offset = next + 1; + } + return Math.Min(offset + character, text.Length); + } + } +} diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Intellisense/JetoCompletionSource.cs b/src/Eto.DevExtension.VisualStudio.Windows/Intellisense/JetoCompletionSource.cs index b751b0e..429289c 100644 --- a/src/Eto.DevExtension.VisualStudio.Windows/Intellisense/JetoCompletionSource.cs +++ b/src/Eto.DevExtension.VisualStudio.Windows/Intellisense/JetoCompletionSource.cs @@ -32,6 +32,7 @@ public JetoCompletionSource(string filePath) this.filePath = filePath; // pick up base class changes in the code behind each time the file is opened RootTypeLocator.Forget(filePath); + EtoLanguageServer.Reset(filePath); } public CompletionStartData InitializeCompletion(CompletionTrigger trigger, SnapshotPoint triggerLocation, CancellationToken token) @@ -51,7 +52,7 @@ public CompletionStartData InitializeCompletion(CompletionTrigger trigger, Snaps public Task GetCompletionContextAsync(IAsyncCompletionSession session, CompletionTrigger trigger, SnapshotPoint triggerLocation, SnapshotSpan applicableToSpan, CancellationToken token) { - return Task.Run(() => + return Task.Run(async () => { try { @@ -62,7 +63,8 @@ public Task GetCompletionContextAsync(IAsyncCompletionSession var snapshot = triggerLocation.Snapshot; session.Properties[SpanKey] = snapshot.CreateTrackingSpan(context.Start, context.End - context.Start, SpanTrackingMode.EdgeInclusive); - var items = DocumentCompletion.GetItems(context).Select(r => + var found = await XamlCompletionSource.GetItemsAsync(filePath, context, triggerLocation.Position, token); + var items = found.Select(r => { var item = new CompletionItem(r.Label, source: this, diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Intellisense/ProjectAssemblyPaths.cs b/src/Eto.DevExtension.VisualStudio.Windows/Intellisense/ProjectAssemblyPaths.cs new file mode 100644 index 0000000..2de2abf --- /dev/null +++ b/src/Eto.DevExtension.VisualStudio.Windows/Intellisense/ProjectAssemblyPaths.cs @@ -0,0 +1,71 @@ +using Eto.DevExtension.VisualStudio.Windows.Editor; +using Eto.DevExtension.VisualStudio.Windows.Util; +using Microsoft.VisualStudio.Shell; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; + +namespace Eto.DevExtension.VisualStudio.Intellisense +{ + /// + /// Build output of the project a designer file belongs to and the projects it references, as + /// Visual Studio knows it, so the language server doesn't have to guess at custom output paths. + /// + static class ProjectAssemblyPaths + { + // reading the project system needs the UI thread, so don't do it on every keystroke + static readonly TimeSpan CacheTime = TimeSpan.FromSeconds(10); + static readonly ConcurrentDictionary Paths)> cache = new ConcurrentDictionary)>(StringComparer.OrdinalIgnoreCase); + + /// The assembly files, the project's own first, or null when the project isn't known or built. + public static async Task> GetAsync(string filePath) + { + if (string.IsNullOrEmpty(filePath)) + return null; + if (cache.TryGetValue(filePath, out var cached) && DateTime.UtcNow - cached.Time < CacheTime) + return cached.Paths; + + List paths = null; + try + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + var project = TextViewExtensions.GetContainingProject(filePath); + var output = project != null ? EditorFactory.GetAssemblyPath(project) : null; + if (output != null && File.Exists(output)) + { + paths = new List { output }; + AddReferences(project, paths, new HashSet(StringComparer.OrdinalIgnoreCase) { project.UniqueName }); + } + } + catch (Exception ex) + { + Debug.WriteLine($"Could not read project outputs for {filePath}: {ex}"); + } + + cache[filePath] = (DateTime.UtcNow, paths); + return paths; + } + + static void AddReferences(EnvDTE.Project project, List paths, HashSet visited) + { + ThreadHelper.ThrowIfNotOnUIThread(); + if (!(project.Object is VSLangProj.VSProject vsproject)) + return; + + foreach (VSLangProj.Reference reference in vsproject.References) + { + var source = reference.SourceProject; + if (source == null || !visited.Add(source.UniqueName)) + continue; + + var path = EditorFactory.GetAssemblyPath(source); + if (path != null && File.Exists(path)) + paths.Add(path); + AddReferences(source, paths, visited); + } + } + } +} diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Intellisense/XamlCompletionManager.cs b/src/Eto.DevExtension.VisualStudio.Windows/Intellisense/XamlCompletionManager.cs index 0746e43..6c40a9e 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/Intellisense/XamlCompletionManager.cs +++ b/src/Eto.DevExtension.VisualStudio.Windows/Intellisense/XamlCompletionManager.cs @@ -48,6 +48,13 @@ public static SnapshotSpan FindTokenSpan(SnapshotSpan applicableToSpan) return new SnapshotSpan(start, end); } + // avoids a duplicate xmlns, which would make the document invalid + static bool DeclaresPrefix(ITextSnapshot snapshot, Designer.Completion.DocumentTextEdit declaration) + { + var equals = declaration.Text.IndexOf('='); + return equals < 0 || snapshot.GetText().Contains(declaration.Text.Substring(0, equals).Trim() + "="); + } + public bool ShouldCommitCompletion(IAsyncCompletionSession session, SnapshotPoint location, char typedChar, CancellationToken token) { return true; @@ -55,10 +62,11 @@ public bool ShouldCommitCompletion(IAsyncCompletionSession session, SnapshotPoin public CommitResult TryCommit(IAsyncCompletionSession session, ITextBuffer buffer, CompletionItem item, char typedChar, CancellationToken token) { - var etoitem = item.Properties["eto"] as Designer.Completion.CompletionItem; + if (!item.Properties.TryGetProperty(XamlCompletionSource.ItemKey, out Designer.Completion.DocumentCompletionItem etoitem)) + return CommitResult.Unhandled; // only complete on '.' for child properties. - if (typedChar == '.' && !etoitem.Behavior.HasFlag(Designer.Completion.CompletionBehavior.ChildProperty)) + if (typedChar == '.' && !etoitem.Item.Behavior.HasFlag(Designer.Completion.CompletionBehavior.ChildProperty)) return CommitResult.Unhandled; var span = session.ApplicableToSpan.GetSpan(buffer.CurrentSnapshot); @@ -80,8 +88,20 @@ public CommitResult TryCommit(IAsyncCompletionSession session, ITextBuffer buffe result = new CommitResult(true, CommitBehavior.RaiseFurtherReturnKeyAndTabKeyCommandHandlers); break; } - var newSnapshot = buffer.Replace(span, text); - var endLocation = new SnapshotPoint(newSnapshot, span.End.Position + text.Length - span.Length); + // declares the namespace of a project type on the root element, which always comes before the item + var declaration = etoitem.NamespaceEdit; + if (declaration != null && (declaration.Offset > span.Start.Position || DeclaresPrefix(buffer.CurrentSnapshot, declaration))) + declaration = null; + + ITextSnapshot newSnapshot; + using (var edit = buffer.CreateEdit()) + { + if (declaration != null) + edit.Insert(declaration.Offset, declaration.Text); + edit.Replace(span, text); + newSnapshot = edit.Apply(); + } + var endLocation = new SnapshotPoint(newSnapshot, span.End.Position + text.Length - span.Length + (declaration?.Text.Length ?? 0)); switch (typedChar) { case '.': diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Intellisense/XamlCompletionSource.cs b/src/Eto.DevExtension.VisualStudio.Windows/Intellisense/XamlCompletionSource.cs index e4a00d7..726a5d5 100644 --- a/src/Eto.DevExtension.VisualStudio.Windows/Intellisense/XamlCompletionSource.cs +++ b/src/Eto.DevExtension.VisualStudio.Windows/Intellisense/XamlCompletionSource.cs @@ -1,21 +1,16 @@ using Eto.Designer.Completion; -using Microsoft.VisualStudio.Language.Intellisense; using Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion; using Microsoft.VisualStudio.Text; -using Microsoft.VisualStudio.Text.Operations; using System; using System.Collections.Generic; using System.Diagnostics; -using System.IO; using System.Linq; -using System.Text; using System.Threading; using System.Threading.Tasks; -using System.Xml; -using System.Xml.Linq; using mvli = Microsoft.VisualStudio.Language.Intellisense; using Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion.Data; using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Threading; using System.Collections.Immutable; using Microsoft.VisualStudio.Text.Adornments; using Microsoft.VisualStudio.Imaging; @@ -25,16 +20,19 @@ namespace Eto.DevExtension.VisualStudio.Intellisense { class XamlCompletionSource : IAsyncCompletionSource { - ITextView textView; + internal const string ItemKey = "eto"; + + readonly string filePath; static XamlCompletionSource() { XmlComments.EncodeHtml = false; - } + } public XamlCompletionSource(ITextView textView) { - this.textView = textView; + if (textView.TextDataModel.DocumentBuffer.Properties.TryGetProperty(typeof(ITextDocument), out ITextDocument document)) + filePath = document.FilePath; } internal static ImageElement GetGlyph(CompletionType type) @@ -57,76 +55,76 @@ internal static ImageElement GetGlyph(CompletionType type) } } + /// + /// Completions from the language server, which also knows the project's own types, or from + /// Eto alone when the server can't be started. + /// + internal static async Task> GetItemsAsync(string filePath, DocumentCompletionContext context, int offset, CancellationToken token) + { + var assemblies = await ProjectAssemblyPaths.GetAsync(filePath); + await TaskScheduler.Default; + return await EtoLanguageServer.GetCompletionsAsync(filePath, context.Text, offset, assemblies, token) + ?? DocumentCompletion.GetItems(context); + } + public void Dispose() { } - public Task GetCompletionContextAsync(IAsyncCompletionSession session, CompletionTrigger trigger, SnapshotPoint triggerLocation, SnapshotSpan applicableToSpan, CancellationToken token) + public async Task GetCompletionContextAsync(IAsyncCompletionSession session, CompletionTrigger trigger, SnapshotPoint triggerLocation, SnapshotSpan applicableToSpan, CancellationToken token) { - //session.Properties["LineNumber"] = triggerLocation.GetContainingLine().LineNumber; - var point = applicableToSpan.Start; - var text = textView.TextBuffer.CurrentSnapshot.GetText(0, point.Position); - return Task.Run(() => + var text = triggerLocation.Snapshot.GetText(); + var offset = triggerLocation.Position; + await TaskScheduler.Default; + try { - try - { - // read XML up to the cursor - var info = XmlParser.Read(text); - if (info.Mode == CompletionMode.Class) - { - var prevPoint = point - 1; - var prevCh = prevPoint.GetChar(); - if (prevCh != '<' && prevCh != '.') - { - return CompletionContext.Empty; - } - } - - var nodes = info.Nodes; - var ns = nodes.SelectMany(r => r.Namespaces ?? Enumerable.Empty()); - var path = nodes.Where(r => r.Mode == CompletionMode.Class).Select(r => r.Name).ToList(); - var last = nodes.LastOrDefault(); - - // get available completion items - var items = Designer.Completion.Completion.GetCompletionItems(ns, info.Mode, path, last); - - // translate to VS completions - var completionList = new List(); - foreach (var cls in items.OrderBy(r => r.Name)) - { - var displayText = cls.Name; - var item = new mvli.AsyncCompletion.Data.CompletionItem(displayText, - source: this, - filters: ImmutableArray.Empty, - icon: GetGlyph(cls.Type), - suffix: cls.Suffix ?? string.Empty, - attributeIcons: ImmutableArray.Empty, - insertText: displayText, sortText: displayText, filterText: displayText - ); - item.Properties["eto"] = cls; - completionList.Add(item); - } - - return new CompletionContext(completionList.ToImmutableArray(), null, InitialSelectionHint.RegularSelection); - } - catch (Exception ex) + var context = DocumentCompletion.GetContext(text, offset, CompletionFormat.Xaml); + if (context == null) + return CompletionContext.Empty; + + // only offer classes straight after '<' or a property element's '.' + var prevCh = context.Start > 0 ? text[context.Start - 1] : '\0'; + if (context.Info.Mode == CompletionMode.Class && prevCh != '<' && prevCh != '.') + return CompletionContext.Empty; + + var items = await GetItemsAsync(filePath, context, offset, token); + + // translate to VS completions + var completionList = new List(); + foreach (var cls in items.OrderBy(r => r.Label)) { - Debug.WriteLine($"Error doing autocomplete: {ex}"); - throw; + var displayText = cls.Label; + var item = new mvli.AsyncCompletion.Data.CompletionItem(displayText, + source: this, + filters: ImmutableArray.Empty, + icon: GetGlyph(cls.Item.Type), + suffix: cls.Item.Suffix ?? string.Empty, + attributeIcons: ImmutableArray.Empty, + insertText: cls.InsertText, sortText: displayText, filterText: displayText + ); + item.Properties[ItemKey] = cls; + completionList.Add(item); } - }); + + return new CompletionContext(completionList.ToImmutableArray(), null, InitialSelectionHint.RegularSelection); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Debug.WriteLine($"Error doing autocomplete: {ex}"); + throw; + } } public Task GetDescriptionAsync(IAsyncCompletionSession session, mvli.AsyncCompletion.Data.CompletionItem item, CancellationToken token) { - var etoitem = item.Properties["eto"] as Designer.Completion.CompletionItem; - return Task.FromResult(etoitem?.Description); + item.Properties.TryGetProperty(ItemKey, out DocumentCompletionItem etoitem); + return Task.FromResult(etoitem?.Item.Description); } public CompletionStartData InitializeCompletion(CompletionTrigger trigger, SnapshotPoint triggerLocation, CancellationToken token) { if (( - trigger.Reason == CompletionTriggerReason.Insertion + trigger.Reason == CompletionTriggerReason.Insertion || trigger.Reason == CompletionTriggerReason.Invoke ) && !char.IsControl(trigger.Character))