Fix #3775: escape reserved Windows device names in output file names

Windows maps CON, PRN, AUX, NUL, COM1-9 and LPT1-9 to devices -- on many
builds even with an extension appended, so a type named Con made both
whole-project export and the save dialog fail with IOException '\\.\Con'.
CleanUpName only checked for reserved names after re-appending the file
extension, where they never match, and the save-dialog default-name
helpers did not check them at all. The escape appends the underscore to
the base name (con_.txt, not con.txt_) because device-name parsing
ignores everything after the first dot, and is applied per path segment
so reserved directory names produced by namespaces are covered too. The
ILSpy.Tests.Windows fixture verifies on a real Windows filesystem that
the escaped names are creatable.

Assisted-by: Claude:claude-fable-5:Claude Code
This commit is contained in:
Siegfried Pammer
2026-06-12 18:58:31 +02:00
committed by Siegfried Pammer
parent 9352a58289
commit 83879c4a07
9 changed files with 379 additions and 34 deletions
@@ -148,6 +148,7 @@
<Compile Include="Output\ILAmbienceTests.cs" />
<Compile Include="Output\InsertParenthesesVisitorTests.cs" />
<Compile Include="Output\TextTokenWriterTests.cs" />
<Compile Include="ProjectDecompiler\ReservedFileSystemNameTests.cs" />
<Compile Include="ProjectDecompiler\TargetFrameworkTests.cs" />
<Compile Include="ProjectDecompiler\WholeProjectDecompilerTests.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
@@ -0,0 +1,128 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.IO;
using ICSharpCode.Decompiler.CSharp.ProjectDecompiler;
using NUnit.Framework;
namespace ICSharpCode.Decompiler.Tests.ProjectDecompiler;
/// <summary>
/// Windows reserves the device names below as file/directory names — both bare ("CON") and,
/// on many Windows versions, with an extension appended ("CON.cs" resolves to \\.\CON).
/// Every sanitizer in <see cref="WholeProjectDecompiler"/> must escape the base name (the part
/// before the first dot) with a trailing underscore so the decompiled output is creatable on
/// Windows (issue #3775).
/// </summary>
[TestFixture]
public sealed class ReservedFileSystemNameTests
{
static readonly string[] ReservedNames = {
"AUX", "CON", "NUL", "PRN",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
};
static readonly char Sep = Path.DirectorySeparatorChar;
[Test]
public void CleanUpFileNameEscapesReservedNames([ValueSource(nameof(ReservedNames))] string name)
{
Assert.That(WholeProjectDecompiler.CleanUpFileName(name, ".cs"), Is.EqualTo($"{name}_.cs"));
}
[Test]
public void CleanUpFileNameEscapesReservedNamesCaseInsensitively([ValueSource(nameof(ReservedNames))] string name)
{
string lower = name.ToLowerInvariant();
Assert.That(WholeProjectDecompiler.CleanUpFileName(lower, ".cs"), Is.EqualTo($"{lower}_.cs"));
}
[Test]
public void CleanUpFileNameEscapesTheBaseNameOfMultiDotNames()
{
// The underscore must land on the base name: Windows device-name parsing ignores
// everything after the first dot, so "con.fig.cs_" would still resolve to \\.\CON.
Assert.That(WholeProjectDecompiler.CleanUpFileName("con.fig", ".cs"), Is.EqualTo("con_.fig.cs"));
}
[Test]
public void SanitizeFileNameEscapesReservedNames([ValueSource(nameof(ReservedNames))] string name)
{
Assert.That(WholeProjectDecompiler.SanitizeFileName(name + ".txt"), Is.EqualTo($"{name}_.txt"));
}
[Test]
public void SanitizeFileNameEscapesReservedDirectorySegments([ValueSource(nameof(ReservedNames))] string name)
{
Assert.That(WholeProjectDecompiler.SanitizeFileName(name + "/data.bin"), Is.EqualTo($"{name}_{Sep}data.bin"));
}
[Test]
public void SanitizeFileNameEscapesReservedFinalSegments([ValueSource(nameof(ReservedNames))] string name)
{
Assert.That(WholeProjectDecompiler.SanitizeFileName($"dir/{name}.png"), Is.EqualTo($"dir{Sep}{name}_.png"));
}
[Test]
public void CleanUpDirectoryNameEscapesReservedNames([ValueSource(nameof(ReservedNames))] string name)
{
Assert.That(WholeProjectDecompiler.CleanUpDirectoryName(name), Is.EqualTo(name + "_"));
}
[Test]
public void CleanUpPathEscapesReservedLeadingSegments([ValueSource(nameof(ReservedNames))] string name)
{
Assert.That(WholeProjectDecompiler.CleanUpPath(name + ".Foo"), Is.EqualTo($"{name}_{Sep}Foo"));
}
[Test]
public void CleanUpPathEscapesReservedTrailingSegments([ValueSource(nameof(ReservedNames))] string name)
{
Assert.That(WholeProjectDecompiler.CleanUpPath("Foo." + name), Is.EqualTo($"Foo{Sep}{name}_"));
}
[Test]
[TestCase("Console")]
[TestCase("CONtoso")]
[TestCase("con1")]
[TestCase("com10")]
[TestCase("lpt10")]
public void NamesMerelyStartingWithReservedNamesAreNotEscaped(string name)
{
using (Assert.EnterMultipleScope())
{
Assert.That(WholeProjectDecompiler.CleanUpFileName(name, ".cs"), Is.EqualTo(name + ".cs"));
Assert.That(WholeProjectDecompiler.SanitizeFileName(name + ".txt"), Is.EqualTo(name + ".txt"));
Assert.That(WholeProjectDecompiler.CleanUpDirectoryName(name), Is.EqualTo(name));
}
}
[Test]
public void OrdinaryPathsPassThroughUnchanged()
{
using (Assert.EnterMultipleScope())
{
Assert.That(WholeProjectDecompiler.SanitizeFileName("config.txt"), Is.EqualTo("config.txt"));
Assert.That(WholeProjectDecompiler.SanitizeFileName("dir/file.png"), Is.EqualTo($"dir{Sep}file.png"));
Assert.That(WholeProjectDecompiler.CleanUpPath("Foo.Bar"), Is.EqualTo($"Foo{Sep}Bar"));
}
}
}
@@ -693,6 +693,8 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler
// Whitelist allowed characters, replace everything else:
StringBuilder b = new StringBuilder(text.Length + (extension?.Length ?? 0));
bool countBytes = !RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
int segmentStart = 0;
int baseNameEnd = -1;
foreach (var c in text)
{
if (char.IsLetterOrDigit(c) || c == '-' || c == '_')
@@ -709,19 +711,32 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler
else if (c == '.' && b.Length > 0 && b[^1] != '.')
{
currentSegmentLength++;
// if the current segment exceeds maxSegmentLength characters,
// skip until the end of the segment.
if (separateAtDots || currentSegmentLength <= maxSegmentLength)
b.Append('.'); // allow dot, but never two in a row
// Reset length at end of segment.
if (separateAtDots)
{
// The dot ends the current segment.
EscapeReservedFileSystemName(b, segmentStart, baseNameEnd);
b.Append('.');
segmentStart = b.Length;
baseNameEnd = -1;
// Reset length at end of segment.
currentSegmentLength = 0;
}
else if (currentSegmentLength <= maxSegmentLength)
{
// The first dot ends the segment's base name, the part Windows
// device-name parsing looks at.
if (baseNameEnd < 0)
baseNameEnd = b.Length;
b.Append('.'); // allow dot, but never two in a row
}
}
else if (treatAsPath && (c is '/' or '\\') && currentSegmentLength > 0)
{
// if we treat this as a file name, we've started a new segment
EscapeReservedFileSystemName(b, segmentStart, baseNameEnd);
b.Append(Path.DirectorySeparatorChar);
segmentStart = b.Length;
baseNameEnd = -1;
currentSegmentLength = 0;
}
else
@@ -741,6 +756,7 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler
}
if (b.Length == 0)
b.Append('-');
EscapeReservedFileSystemName(b, segmentStart, baseNameEnd);
string name = b.ToString();
if (extension != null)
{
@@ -752,9 +768,7 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler
name += extension;
}
if (IsReservedFileSystemName(name))
return name + "_";
else if (name == ".")
if (name == ".")
return "_";
else
return name;
@@ -774,6 +788,25 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler
.Replace('.', Path.DirectorySeparatorChar);
}
/// <summary>
/// Appends an underscore to the segment [<paramref name="segmentStart"/>..) of
/// <paramref name="b"/> if its base name (the part before <paramref name="baseNameEnd"/>,
/// or the whole segment when there was no dot) is a reserved Windows device name
/// (CON, PRN, AUX, NUL, COM1-9, LPT1-9): "con" becomes "con_" and "con.txt" becomes
/// "con_.txt". Windows device-name parsing ignores everything after the first dot, so the
/// underscore must be inserted before it, not appended at the end.
/// </summary>
static void EscapeReservedFileSystemName(StringBuilder b, int segmentStart, int baseNameEnd)
{
if (baseNameEnd < 0)
baseNameEnd = b.Length;
int baseNameLength = baseNameEnd - segmentStart;
// All reserved names are 3 or 4 characters long; checking the length first avoids
// allocating a substring for segments that cannot be reserved anyway.
if (baseNameLength is 3 or 4 && IsReservedFileSystemName(b.ToString(segmentStart, baseNameLength)))
b.Insert(baseNameEnd, '_');
}
static bool IsReservedFileSystemName(string name)
{
switch (name.ToUpperInvariant())
@@ -0,0 +1,122 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.IO;
using System.Linq;
using AwesomeAssertions;
using ICSharpCode.Decompiler.CSharp.ProjectDecompiler;
using ILSpy.Commands;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests.Windows;
/// <summary>
/// Verifies on a real Windows filesystem that the sanitized names produced for reserved device
/// names (CON, PRN, AUX, NUL, COM1-9, LPT1-9) are actually creatable (issue #3775: a type named
/// "Con" crashed saving with IOException '\\.\Con'). The cross-platform expectations live in
/// ICSharpCode.Decompiler.Tests (ReservedFileSystemNameTests) and ILSpy.Tests
/// (SuggestedFileNameTests); this fixture only proves the escaped output round-trips through
/// File/Directory creation. It deliberately does not assert that the raw names fail to be
/// created: whether "con.cs" is rejected depends on the Windows version (Windows 11 allows
/// reserved names with extensions), and bare-name behavior is already covered by the escape.
/// </summary>
[TestFixture]
public class ReservedFileSystemNameTests
{
static readonly string[] ReservedNames = [
"AUX", "CON", "NUL", "PRN",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
string tempDir = null!;
[SetUp]
public void SetUp()
{
tempDir = Path.Combine(Path.GetTempPath(), "ILSpyReserved_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
}
[TearDown]
public void TearDown()
{
if (Directory.Exists(tempDir))
Directory.Delete(tempDir, recursive: true);
}
[Test]
public void Sanitized_Code_File_Names_Are_Creatable([ValueSource(nameof(ReservedNames))] string name)
{
var fileName = WholeProjectDecompiler.CleanUpFileName(name, ".cs");
var path = Path.Combine(tempDir, fileName);
File.WriteAllText(path, "// " + name);
File.Exists(path).Should().BeTrue();
// Enumerate to guard against the name silently resolving to a device instead of a file.
Directory.EnumerateFiles(tempDir).Select(Path.GetFileName).Should().Contain(fileName);
}
[Test]
public void Sanitized_Directory_Names_Are_Creatable([ValueSource(nameof(ReservedNames))] string name)
{
var dirName = WholeProjectDecompiler.CleanUpDirectoryName(name);
var dir = Path.Combine(tempDir, dirName);
Directory.CreateDirectory(dir);
File.WriteAllText(Path.Combine(dir, "data.bin"), name);
File.Exists(Path.Combine(dir, "data.bin")).Should().BeTrue();
Directory.EnumerateDirectories(tempDir).Select(Path.GetFileName).Should().Contain(dirName);
}
[Test]
public void Sanitized_Resource_Paths_Are_Creatable([ValueSource(nameof(ReservedNames))] string name)
{
// Resource names can carry directory structure; both the directory and the file segment
// must come out creatable.
var relativePath = WholeProjectDecompiler.SanitizeFileName(name + "/" + name + ".png");
var path = Path.Combine(tempDir, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, name);
File.Exists(path).Should().BeTrue();
}
[Test]
public void Save_Code_Default_File_Name_For_A_Type_Named_Con_Is_Creatable()
{
// The exact crash from issue #3775: the save dialog suggested "Con.cs" and the
// StreamWriter in SaveCodeHelper.WriteNodeToFile failed with '\\.\Con'.
var path = Path.Combine(tempDir, SaveCodeHelper.SuggestedFileName("Con", ".cs"));
using (var writer = new StreamWriter(path))
{
writer.WriteLine("// decompiled output");
}
File.Exists(path).Should().BeTrue();
}
}
@@ -0,0 +1,71 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using AwesomeAssertions;
using ILSpy.Commands;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests;
/// <summary>
/// The default file name offered by the save dialog must never be a reserved Windows device
/// name (CON, PRN, AUX, NUL, COM1-9, LPT1-9) — Windows cannot create such files, so a type
/// named "Con" would make the save crash with an IOException (issue #3775). The suggestion is
/// produced by <c>WholeProjectDecompiler.CleanUpFileName</c>, whose sanitization rules are
/// covered in depth by ICSharpCode.Decompiler.Tests (ReservedFileSystemNameTests); these tests
/// pin the delegation and the fallback for nodes without usable text.
/// </summary>
[TestFixture]
public class SuggestedFileNameTests
{
static readonly string[] ReservedNames = [
"AUX", "CON", "NUL", "PRN",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
[Test]
public void Save_Code_Escapes_Reserved_Device_Names([ValueSource(nameof(ReservedNames))] string name)
{
// "Con" + ".cs" would resolve to \\.\CON on Windows, so the escape must end up on the
// base name, before the extension.
SaveCodeHelper.SuggestedFileName(name, ".cs").Should().Be(name + "_.cs");
SaveCodeHelper.SuggestedFileName(name.ToLowerInvariant(), ".cs").Should().Be(name.ToLowerInvariant() + "_.cs");
}
[Test]
public void Save_Code_Keeps_Ordinary_Type_Names_Unchanged()
{
SaveCodeHelper.SuggestedFileName("Console", ".cs").Should().Be("Console.cs");
}
[Test]
public void Save_Code_Replaces_Invalid_File_Name_Characters()
{
SaveCodeHelper.SuggestedFileName("a/b", ".cs").Should().Be("a-b.cs");
}
[Test]
public void Save_Code_Falls_Back_To_Output_For_Unusable_Text()
{
SaveCodeHelper.SuggestedFileName(null, ".cs").Should().Be("output.cs");
SaveCodeHelper.SuggestedFileName(" ", ".cs").Should().Be("output.cs");
}
}
+4 -3
View File
@@ -26,6 +26,7 @@ using System.Linq;
using System.Threading.Tasks;
using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.CSharp.ProjectDecompiler;
using ICSharpCode.ILSpy.Properties;
using ICSharpCode.ILSpyX;
@@ -77,7 +78,7 @@ namespace ILSpy.Commands
return;
var watch = Stopwatch.StartNew();
Exception? ex = null;
var path = Path.Combine(OutputDir, asm.ShortName + ".cs");
var path = Path.Combine(OutputDir, WholeProjectDecompiler.CleanUpFileName(asm.ShortName, ".cs"));
try
{
using var writer = new StreamWriter(path);
@@ -144,8 +145,8 @@ namespace ILSpy.Commands
return;
var watch = Stopwatch.StartNew();
Exception? ex = null;
var safeName = (asm.Text?.ToString() ?? asm.ShortName).Replace("(", "").Replace(")", "").Replace(' ', '_');
var path = Path.Combine(OutputDir, safeName + ".il");
var safeName = WholeProjectDecompiler.CleanUpFileName(asm.Text?.ToString() ?? asm.ShortName, ".il");
var path = Path.Combine(OutputDir, safeName);
try
{
using var writer = new StreamWriter(path);
+8 -11
View File
@@ -23,6 +23,7 @@ using System.Threading;
using System.Threading.Tasks;
using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.CSharp.ProjectDecompiler;
using ICSharpCode.ILSpy.Properties;
using ILSpy.Docking;
@@ -60,7 +61,7 @@ namespace ILSpy.Commands
return;
var language = languageService.CurrentLanguage;
var defaultName = SuggestedFileName(node) + language.FileExtension;
var defaultName = SuggestedFileName(node.Text?.ToString(), language.FileExtension);
var path = await FilePickers.SaveAsync(
$"{language.Name} (*{language.FileExtension})|*{language.FileExtension}|All files|*.*",
defaultName).ConfigureAwait(true);
@@ -125,18 +126,14 @@ namespace ILSpy.Commands
}
}
// A reasonable default file name for the save dialog: the node's text, cleaned of characters
// that aren't valid in a file name; falls back to "output" when nothing usable remains.
static string SuggestedFileName(ILSpyTreeNode node)
// A reasonable default file name for the save dialog: the node's text run through the
// project decompiler's file-name cleanup (which also escapes reserved Windows device names
// like "Con"); falls back to "output" when the node has no usable text.
internal static string SuggestedFileName(string? text, string extension)
{
var text = node.Text?.ToString();
if (string.IsNullOrWhiteSpace(text))
return "output";
var clean = text;
foreach (var c in Path.GetInvalidFileNameChars())
clean = clean.Replace(c, '_');
clean = clean.Trim();
return string.IsNullOrEmpty(clean) ? "output" : clean;
text = "output";
return WholeProjectDecompiler.CleanUpFileName(text, extension);
}
}
+1
View File
@@ -39,6 +39,7 @@ using System.Runtime.InteropServices;
[assembly: NeutralResourcesLanguage("en-US")]
[assembly: InternalsVisibleTo("ILSpy.Tests")]
[assembly: InternalsVisibleTo("ILSpy.Tests.Windows")]
[assembly: SuppressMessage("Microsoft.Usage", "CA2243:AttributeStringLiteralsShouldParseCorrectly",
Justification = "AssemblyInformationalVersion does not need to be a parsable version")]
+2 -11
View File
@@ -28,6 +28,7 @@ using Avalonia.Controls.Documents;
using Avalonia.Media;
using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.CSharp.ProjectDecompiler;
using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.Decompiler.TypeSystem;
using ICSharpCode.ILSpyX;
@@ -182,11 +183,10 @@ namespace ILSpy.TreeNodes
async Task SaveAsProjectOrSingleFileAsync(Language language)
{
var shortName = CleanSuggestedFileName(assembly.ShortName);
var filter = $"{language.Name} project (*{language.ProjectFileExtension})|*{language.ProjectFileExtension}"
+ $"|{language.Name} (*{language.FileExtension})|*{language.FileExtension}"
+ "|All files (*.*)|*.*";
var defaultName = shortName + language.ProjectFileExtension;
var defaultName = WholeProjectDecompiler.CleanUpFileName(assembly.ShortName, language.ProjectFileExtension);
var path = await Commands.FilePickers.SaveAsync(filter, defaultName, "Save Code").ConfigureAwait(false);
if (string.IsNullOrEmpty(path))
return;
@@ -217,15 +217,6 @@ namespace ILSpy.TreeNodes
}).ConfigureAwait(false);
}
static string CleanSuggestedFileName(string name)
{
var invalid = Path.GetInvalidFileNameChars();
var clean = name;
foreach (var c in invalid)
clean = clean.Replace(c, '_');
return clean;
}
static LanguageService? TryGetLanguageService()
{
try