diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..dcf34652 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(dotnet build:*)", + "Bash(dotnet test:*)" + ] + } +} diff --git a/src/MarkdownSnippets/NewLineConfigReader.cs b/src/MarkdownSnippets/NewLineConfigReader.cs new file mode 100644 index 00000000..6bb0bc9e --- /dev/null +++ b/src/MarkdownSnippets/NewLineConfigReader.cs @@ -0,0 +1,231 @@ +static class NewLineConfigReader +{ + public static string ReadNewLine(string directory, IEnumerable mdFiles) + { + var newLine = TryReadFromGitAttributes(directory); + if (newLine != null) + { + return newLine; + } + + newLine = TryReadFromEditorConfig(directory); + if (newLine != null) + { + return newLine; + } + + return DetectFromFiles(mdFiles); + } + + static string DetectFromFiles(IEnumerable mdFiles) + { + foreach (var mdFile in mdFiles.OrderBy(_ => _.Length)) + { + using var reader = File.OpenText(mdFile); + if (reader.TryFindNewline(out var detectedNewLine)) + { + return detectedNewLine; + } + } + + return Environment.NewLine; + } + + static string? TryReadFromGitAttributes(string directory) + { + var gitAttributesPath = FindFileUpward(directory, ".gitattributes"); + if (gitAttributesPath == null) + { + return null; + } + + var lines = File.ReadAllLines(gitAttributesPath); + return ParseGitAttributesEol(lines); + } + + static string? ParseGitAttributesEol(string[] lines) + { + string? wildcardEol = null; + string? extensionEol = null; + + foreach (var line in lines) + { + var trimmed = line.Trim(); + if (trimmed.Length == 0 || trimmed.StartsWith('#')) + { + continue; + } + + var eolValue = ExtractGitAttributeEol(trimmed); + if (eolValue == null) + { + continue; + } + + var pattern = GetGitAttributePattern(trimmed); + if (pattern == "*") + { + wildcardEol = eolValue; + } + else if (pattern is "*.md" or "*.MD") + { + extensionEol = eolValue; + } + } + + // More specific pattern wins + var eol = extensionEol ?? wildcardEol; + return EolValueToNewLine(eol); + } + + static string? ExtractGitAttributeEol(string line) + { + // Look for eol=lf or eol=crlf in the line + var eolIndex = line.IndexOf("eol=", StringComparison.OrdinalIgnoreCase); + if (eolIndex == -1) + { + return null; + } + + var valueStart = eolIndex + 4; + var valueEnd = valueStart; + while (valueEnd < line.Length && !char.IsWhiteSpace(line[valueEnd])) + { + valueEnd++; + } + + return line.Substring(valueStart, valueEnd - valueStart).ToLowerInvariant(); + } + + static string GetGitAttributePattern(string line) + { + // Pattern is the first whitespace-delimited token + var end = 0; + while (end < line.Length && !char.IsWhiteSpace(line[end])) + { + end++; + } + + return line[..end]; + } + + static string? TryReadFromEditorConfig(string directory) + { + var editorConfigPath = FindFileUpward(directory, ".editorconfig"); + if (editorConfigPath == null) + { + return null; + } + + var lines = File.ReadAllLines(editorConfigPath); + return ParseEditorConfigEol(lines); + } + + static string? ParseEditorConfigEol(string[] lines) + { + string? globalEol = null; + string? extensionEol = null; + var inWildcardSection = false; + var inExtensionSection = false; + + foreach (var line in lines) + { + var trimmed = line.Trim(); + if (trimmed.Length == 0 || trimmed.StartsWith('#') || trimmed.StartsWith(';')) + { + continue; + } + + // Check for section headers + if (trimmed.StartsWith('[') && trimmed.EndsWith(']')) + { + var section = trimmed.Substring(1, trimmed.Length - 2); + inWildcardSection = section == "*"; + inExtensionSection = EditorConfigSectionMatchesMd(section); + continue; + } + + // Parse key=value + var equalsIndex = trimmed.IndexOf('='); + if (equalsIndex == -1) + { + continue; + } + + var key = trimmed[..equalsIndex].Trim().ToLowerInvariant(); + var value = trimmed[(equalsIndex + 1)..].Trim().ToLowerInvariant(); + + if (key == "end_of_line") + { + if (inExtensionSection) + { + extensionEol = value; + } + else if (inWildcardSection) + { + globalEol = value; + } + } + } + + // More specific section wins + var eol = extensionEol ?? globalEol; + return EolValueToNewLine(eol); + } + + static bool EditorConfigSectionMatchesMd(string section) + { + // Handle patterns like *.md, *.{md,txt}, etc. + if (!section.StartsWith('*')) + { + return false; + } + + var pattern = section[1..]; + if (pattern.Equals(".md", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // Handle {md,txt} style patterns like *.{md,txt} + if (pattern.StartsWith('.') && pattern.Contains('{')) + { + var braceStart = pattern.IndexOf('{'); + var braceEnd = pattern.IndexOf('}'); + if (braceStart != -1 && braceEnd > braceStart) + { + var extensions = pattern.Substring(braceStart + 1, braceEnd - braceStart - 1).Split(','); + return extensions.Any(_ => _.Trim().Equals("md", StringComparison.OrdinalIgnoreCase)); + } + } + + return false; + } + + static string? EolValueToNewLine(string? eolValue) => + eolValue switch + { + "lf" => "\n", + "crlf" => "\r\n", + "cr" => "\r", + _ => null + }; + + static string? FindFileUpward(string directory, string fileName) + { + var current = directory; + while (current != null) + { + var filePath = Path.Combine(current, fileName); + if (File.Exists(filePath)) + { + return filePath; + } + + var parent = Directory.GetParent(current); + current = parent?.FullName; + } + + return null; + } +} diff --git a/src/MarkdownSnippets/Processing/DirectoryMarkdownProcessor.cs b/src/MarkdownSnippets/Processing/DirectoryMarkdownProcessor.cs index 04e983f3..52b181cb 100644 --- a/src/MarkdownSnippets/Processing/DirectoryMarkdownProcessor.cs +++ b/src/MarkdownSnippets/Processing/DirectoryMarkdownProcessor.cs @@ -153,17 +153,7 @@ void InitNewLine() return; } - foreach (var mdFile in mdFiles.OrderBy(_ => _.Length)) - { - using var reader = File.OpenText(mdFile); - if (reader.TryFindNewline(out var detectedNewLine)) - { - newLine = detectedNewLine; - return; - } - } - - newLine = Environment.NewLine; + newLine = NewLineConfigReader.ReadNewLine(targetDirectory, mdFiles); } public void AddSnippets(List snippets) diff --git a/src/Tests/NewLineConfigReaderTests.cs b/src/Tests/NewLineConfigReaderTests.cs new file mode 100644 index 00000000..4a34c71f --- /dev/null +++ b/src/Tests/NewLineConfigReaderTests.cs @@ -0,0 +1,260 @@ +public class NewLineConfigReaderTests +{ + [Fact] + public void GitAttributes_WildcardEolLf() + { + var directory = new TempDirectory(); + + File.WriteAllText(Path.Combine(directory, ".gitattributes"), "* text eol=lf"); + var result = NewLineConfigReader.ReadNewLine(directory, []); + Assert.Equal("\n", result); + } + + [Fact] + public void GitAttributes_WildcardEolCrlf() + { + var directory = new TempDirectory(); + File.WriteAllText(Path.Combine(directory, ".gitattributes"), "* text eol=crlf"); + var result = NewLineConfigReader.ReadNewLine(directory, []); + Assert.Equal("\r\n", result); + } + + [Fact] + public void GitAttributes_MdSpecificEolLf() + { + var directory = new TempDirectory(); + File.WriteAllText(Path.Combine(directory, ".gitattributes"), "*.md text eol=lf"); + var result = NewLineConfigReader.ReadNewLine(directory, []); + Assert.Equal("\n", result); + } + + [Fact] + public void GitAttributes_MdSpecificOverridesWildcard() + { + var directory = new TempDirectory(); + File.WriteAllText( + Path.Combine(directory, ".gitattributes"), + """ + * text eol=crlf + *.md text eol=lf + """); + var result = NewLineConfigReader.ReadNewLine(directory, []); + Assert.Equal("\n", result); + } + + [Fact] + public void GitAttributes_NoEolSetting_FallsBackToEnvironmentNewLine() + { + var directory = new TempDirectory(); + File.WriteAllText(Path.Combine(directory, ".gitattributes"), "* text"); + var result = NewLineConfigReader.ReadNewLine(directory, []); + Assert.Equal(Environment.NewLine, result); + } + + [Fact] + public void GitAttributes_IgnoresComments() + { + var directory = new TempDirectory(); + File.WriteAllText( + Path.Combine(directory, ".gitattributes"), + """ + # comment eol=crlf + * text eol=lf + """); + var result = NewLineConfigReader.ReadNewLine(directory, []); + Assert.Equal("\n", result); + } + + [Fact] + public void GitAttributes_InParentDirectory() + { + var directory = new TempDirectory(); + var childDir = Path.Combine(directory, "child"); + Directory.CreateDirectory(childDir); + File.WriteAllText(Path.Combine(directory, ".gitattributes"), "* text eol=lf"); + var result = NewLineConfigReader.ReadNewLine(childDir, []); + Assert.Equal("\n", result); + } + + [Fact] + public void EditorConfig_WildcardEndOfLineLf() + { + var directory = new TempDirectory(); + File.WriteAllText( + Path.Combine(directory, ".editorconfig"), + """ + [*] + end_of_line = lf + """); + var result = NewLineConfigReader.ReadNewLine(directory, []); + Assert.Equal("\n", result); + } + + [Fact] + public void EditorConfig_WildcardEndOfLineCrlf() + { + var directory = new TempDirectory(); + File.WriteAllText( + Path.Combine(directory, ".editorconfig"), + """ + [*] + end_of_line = crlf + """); + var result = NewLineConfigReader.ReadNewLine(directory, []); + Assert.Equal("\r\n", result); + } + + [Fact] + public void EditorConfig_MdSpecificEndOfLine() + { + var directory = new TempDirectory(); + File.WriteAllText( + Path.Combine(directory, ".editorconfig"), + """ + [*.md] + end_of_line = lf + """); + var result = NewLineConfigReader.ReadNewLine(directory, []); + Assert.Equal("\n", result); + } + + [Fact] + public void EditorConfig_MdSpecificOverridesWildcard() + { + var directory = new TempDirectory(); + File.WriteAllText( + Path.Combine(directory, ".editorconfig"), + """ + [*] + end_of_line = crlf + + [*.md] + end_of_line = lf + """); + var result = NewLineConfigReader.ReadNewLine(directory, []); + Assert.Equal("\n", result); + } + + [Fact] + public void EditorConfig_BracePattern() + { + var directory = new TempDirectory(); + File.WriteAllText( + Path.Combine(directory, ".editorconfig"), + """ + [*.{md,txt}] + end_of_line = lf + """); + var result = NewLineConfigReader.ReadNewLine(directory, []); + Assert.Equal("\n", result); + } + + [Fact] + public void EditorConfig_IgnoresComments() + { + var directory = new TempDirectory(); + File.WriteAllText( + Path.Combine(directory, ".editorconfig"), + """ + # comment + ; another comment + [*] + end_of_line = lf + """); + var result = NewLineConfigReader.ReadNewLine(directory, []); + Assert.Equal("\n", result); + } + + [Fact] + public void GitAttributes_TakesPriorityOverEditorConfig() + { + var directory = new TempDirectory(); + File.WriteAllText(Path.Combine(directory, ".gitattributes"), "* text eol=crlf"); + File.WriteAllText( + Path.Combine(directory, ".editorconfig"), + """ + [*] + end_of_line = lf + """); + var result = NewLineConfigReader.ReadNewLine(directory, []); + Assert.Equal("\r\n", result); + } + + [Fact] + public void NoConfigFiles_FallsBackToEnvironmentNewLine() + { + var directory = new TempDirectory(); + var result = NewLineConfigReader.ReadNewLine(directory, []); + Assert.Equal(Environment.NewLine, result); + } + + [Fact] + public void EditorConfig_FallbackWhenGitAttributesHasNoEol() + { + var directory = new TempDirectory(); + File.WriteAllText(Path.Combine(directory, ".gitattributes"), "* text"); + File.WriteAllText( + Path.Combine(directory, ".editorconfig"), + """ + [*] + end_of_line = lf + """); + var result = NewLineConfigReader.ReadNewLine(directory, []); + Assert.Equal("\n", result); + } + + [Fact] + public void DetectsNewLineFromMdFiles_Lf() + { + var directory = new TempDirectory(); + var mdFile = Path.Combine(directory, "test.md"); + File.WriteAllText(mdFile, "line1\nline2\n"); + var result = NewLineConfigReader.ReadNewLine(directory, [mdFile]); + Assert.Equal("\n", result); + } + + [Fact] + public void DetectsNewLineFromMdFiles_Crlf() + { + var directory = new TempDirectory(); + var mdFile = Path.Combine(directory, "test.md"); + File.WriteAllText(mdFile, "line1\r\nline2\r\n"); + var result = NewLineConfigReader.ReadNewLine(directory, [mdFile]); + Assert.Equal("\r\n", result); + } + + [Fact] + public void ConfigTakesPriorityOverMdFileDetection() + { + var directory = new TempDirectory(); + File.WriteAllText(Path.Combine(directory, ".gitattributes"), "* text eol=lf"); + var mdFile = Path.Combine(directory, "test.md"); + File.WriteAllText(mdFile, "line1\r\nline2\r\n"); + var result = NewLineConfigReader.ReadNewLine(directory, [mdFile]); + Assert.Equal("\n", result); + } + + [Fact] + public void MdFileDetection_PicksShortestFileFirst() + { + var directory = new TempDirectory(); + var shortFile = Path.Combine(directory, "a.md"); + var longFile = Path.Combine(directory, "longer-name.md"); + File.WriteAllText(shortFile, "line1\nline2\n"); + File.WriteAllText(longFile, "line1\r\nline2\r\n"); + var result = NewLineConfigReader.ReadNewLine(directory, [longFile, shortFile]); + Assert.Equal("\n", result); + } + + [Fact] + public void MdFileDetection_SkipsFilesWithNoNewlines() + { + var directory = new TempDirectory(); + var noNewlineFile = Path.Combine(directory, "a.md"); + var withNewlineFile = Path.Combine(directory, "bb.md"); + File.WriteAllText(noNewlineFile, "no newlines here"); + File.WriteAllText(withNewlineFile, "has\nnewlines"); + var result = NewLineConfigReader.ReadNewLine(directory, [noNewlineFile, withNewlineFile]); + Assert.Equal("\n", result); + } +} \ No newline at end of file