C# Formatter Seçimi: dotnet format mı, CSharpier mı? Kuralı Tek Sahibe Vermek
İki formatter aynı kuralın sahibi olursa döngü hiç bitmez: 1.000 dosyalık Bookstore çözümünde dotnet format CSharpier çıktısında 6.002 sorun, CSharpier de dotnet format çıktısında 1.001 formatlanmamış dosya buldu. Çözüm whitespace'i CSharpier'a, stili analyzer'a vermek; yazı iki aracın çıktısını, süre ölçümünü, IDE0055'i kapatmanın neden yetmediğini ve sürümü dotnet tool manifest'iyle sabitlemeyi gösteriyor.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 (SDK 10.0.402) ile derlenip denenmiştir; formatter olarak CSharpier 1.4.0 (sürüm karşılaştırmasında 1.3.0) kullanılmıştır.
Önce Tahmin Et: İki Formatter, Bir Repo
Bookstore ekibinde iki geliştirici iki farklı yoldan formatlıyor. Ada her commit'ten önce SDK ile gelen dotnet format'ı çalıştırıyor. Grace, editöründe kayıtta CSharpier'ı açmış. Repo'nun .editorconfig'inde eski bir karardan kalma iki satır var: süslü parantez satır sonunda açılsın (csharp_new_line_before_open_brace = none) ve using grupları arasında boş satır olsun (dotnet_separate_import_directive_groups = true). CI her iki aracı da kontrol modunda çalıştırıyor.
Çözümde dört modülde toplam 1.000 dosya var. Grace CSharpier ile bütün repo'yu formatlayıp push ediyor; CI'da dotnet format kaç sorun bulur? Ada bunu görünce dotnet format ile düzeltip push ediyor; bu sefer CSharpier kaç dosyayı "formatlanmamış" bulur? Bu döngü kaç turda durur?
dotnet format: Üç Alt Komut ve Kontrol Modu
dotnet format .NET 6'dan beri SDK'nın parçası; ayrıca kurulmaz. Clean Code serisinin "Formatting ve Dosya Düzeni" yazısında bir formatting hatasını yakalarken kullandık; burada bir formatter adayı olarak bakıyoruz. Üç alt komutu var. whitespace girinti, boşluk ve satır sonlarını .editorconfig'teki formatting option'larına (csharp_new_line_*, csharp_space_*, csharp_indent_*) göre düzeltir. style ikinci yazıda gördüğümüz code-style kurallarını (IDE ile başlayanlar) code fix'leriyle uygular. analyzers CA (kod analizi) kuralları ve üçüncü parti analyzer'ların düzeltmelerini çalıştırır. Alt komutsuz dotnet format üçünü birden çalıştırır.
--verify-no-changes dosyalara dokunmaz, yalnız raporlar ve değişiklik gerekiyorsa sıfırdan farklı çıkış kodu döner; ölçtüğümüz değer 2. --include ve --exclude ile dosya kümesi daraltılır, --diagnostics ile tek bir kural seçilir, --report JSON (JavaScript Object Notation) rapor üretir. whitespace --folder ise proje dosyası yüklemeden bir klasördeki .cs dosyalarına bakar.
Bu son seçenek hız konusunun ipucu. style ve analyzers çalışmak için çözümü MSBuild üzerinden yükler, restore eder, semantik modeli kurar; Microsoft Learn sayfası komutun proje restore edip analyzer çalıştırabileceğini, bu yüzden yalnız güvenilir kodda çağrılması gerektiğini de yazıyor. Whitespace kısmı bunlara ihtiyaç duymaz.
dotnet format'ın bilmen gereken bir sınırı daha var: satır kırmaz. .editorconfig'e max_line_length = 120 yazsan da 180 karakterlik bir LINQ zinciri tek satırda kalır; araç yalnız var olan satırların içindeki boşlukları ve girintiyi düzeltir. Aşağıdaki karşılaştırmada bunu göreceğiz.
CSharpier: Opinionated Formatter
CSharpier, JavaScript dünyasındaki Prettier'ın C# karşılığı olarak başladı; dokümanına göre printer'ı Prettier'dan port edilmiş ve zamanla kendi yoluna gitmiş. Yaklaşımı dotnet format'tan temelden farklı: kodu parse eder, var olan düzeni çoğunlukla yok sayar ve kendi kurallarıyla baştan yazar. Sonuç, kodu kim nasıl yazmış olursa olsun aynı girdiden aynı çıktı.
"Opinionated" sözcüğü burada bir tasarım kararı. CSharpier yalnız birkaç temel ayar sunuyor (printWidth, useTabs, indentSize, endOfLine) ve dokümanı yeni ayar taleplerini kapsam dışı ilan ediyor. Bu kısıt bilerek seçilmiş: ayar yoksa tartışılacak bir şey de yok. İlk yazıdaki bikeshedding sorununun araç tarafındaki çözümü bu.
Ayarlar JSON ya da YAML biçimindeki .csharpierrc, .csharpierrc.json, .csharpierrc.yaml ya da .editorconfig'ten okunur. CSharpier .editorconfig'te indent_style, indent_size, max_line_length ve end_of_line'ı anlar; iki dosya da varsa .csharpierrc kazanır. Denedik: .editorconfig'te max_line_length = 120 varken çözüm formatlı ve temizdi. Köke yalnız şu dosyayı ekledik:
{ "printWidth": 100 }
$ dotnet csharpier check src/Bookstore.Catalog/Service000.cs
Error ./src/Bookstore.Catalog/Service000.cs - Was not formatted.
----------------------------- Expected: Around Line 33 -----------------------------
public decimal CalculateTotal(
string currency,
----------------------------- Actual: Around Line 33 -----------------------------
public decimal CalculateTotal(string currency, bool includeTax, decimal discountRate, CultureInfo culture)
{
Checked 1 files in 130ms.
110 karakterlik imza, 120'ye göre sığıyordu; 100'e göre artık bölünmesi gerekiyor. Bütün çözümde 1.000 dosya birden "formatlanmamış" oldu. Bu yüzden satır genişliğini tek bir yerde tut; Bookstore .editorconfig'i seçti, çünkü editörler de onu okuyor.
CSharpier'ın bilinmesi gereken birkaç davranışı daha var. .gitignore'u ve .csharpierignore'u okur, obj ve node_modules'u her zaman atlar ve .g.cs, .designer.cs gibi üretilmiş dosyalara dokunmaz. Tek bir member'ı // csharpier-ignore yorumuyla, bir aralığı csharpier-ignore-start ve csharpier-ignore-end ile dışarıda bırakabilirsin. using direktiflerini sıralar ve System'i başa alır; yani "using sırası" kuralının sahibi de CSharpier olur. 1.0'dan beri .csproj ve .props gibi XML (Extensible Markup Language) dosyalarını da formatlar. Visual Studio, Visual Studio Code (VS Code) ve Rider için resmî eklentileri var ve hepsi kayıtta formatlamayı destekliyor.
Aynı Dosya, İki Çıktı
Karşılaştırma için bir üretici script'le dört modüllü bir Bookstore çözümü kurduk: 1.000 service dosyası, yaklaşık 39 bin satır, bilerek tutarsız biçimlendirilmiş (bir kısmı tab'lı, bir kısmı iki boşluklu, bir kısmı süslü parantezi satır sonunda). .editorconfig'te dört boşluk, Allman parantez stili ve max_line_length = 120 var. Aynı başlangıç commit'inden iki branch açıp birinde dotnet format whitespace, diğerinde dotnet csharpier format . çalıştırdık. Bir dosyanın ilgili kısmı, önce dotnet format çıktısı:
public sealed record BookCatalogService0Item(string Isbn, string Title, int Quantity, decimal UnitPrice, string Currency);
public sealed class BookCatalogService0(IClock clock, ILogSink log)
{
private readonly List<BookCatalogService0Item> _items = [];
private const decimal TaxRate = 0.20m;
public void Add(BookCatalogService0Item item)
{
ArgumentNullException.ThrowIfNull(item);
if (item.Quantity <= 0) throw new ArgumentOutOfRangeException(nameof(item), item.Quantity, "Quantity must be positive for book items in catalog.");
_items.Add(item);
log.Write($"{clock.UtcNow:O} added {item.Isbn} x{item.Quantity}");
}
public decimal CalculateTotal(string currency, bool includeTax, decimal discountRate, CultureInfo culture)
{
decimal subtotal = _items.Where(i => i.Currency == currency).Select(i => i.UnitPrice * i.Quantity).Where(v => v > 0m).Sum();
decimal discounted = subtotal - (subtotal * discountRate);
return includeTax ? Math.Round(discounted * (1 + TaxRate), 2, MidpointRounding.AwayFromZero) : Math.Round(discounted, 2, MidpointRounding.AwayFromZero);
}
Tab'lar dört boşluk olmuş, parantezler kendi satırına inmiş, Isbn,string ve TaxRate=0.20m gibi yerlerde boşluk eklenmiş. Ama 155 karakterlik if satırı ve 132 karakterlik LINQ zinciri olduğu gibi duruyor. Aynı kısım CSharpier'dan:
public sealed record BookCatalogService0Item(
string Isbn,
string Title,
int Quantity,
decimal UnitPrice,
string Currency
);
public sealed class BookCatalogService0(IClock clock, ILogSink log)
{
private readonly List<BookCatalogService0Item> _items = [];
private const decimal TaxRate = 0.20m;
public void Add(BookCatalogService0Item item)
{
ArgumentNullException.ThrowIfNull(item);
if (item.Quantity <= 0)
throw new ArgumentOutOfRangeException(
nameof(item),
item.Quantity,
"Quantity must be positive for book items in catalog."
);
_items.Add(item);
log.Write($"{clock.UtcNow:O} added {item.Isbn} x{item.Quantity}");
}
public decimal CalculateTotal(string currency, bool includeTax, decimal discountRate, CultureInfo culture)
{
decimal subtotal = _items
.Where(i => i.Currency == currency)
.Select(i => i.UnitPrice * i.Quantity)
.Where(v => v > 0m)
.Sum();
decimal discounted = subtotal - (subtotal * discountRate);
return includeTax
? Math.Round(discounted * (1 + TaxRate), 2, MidpointRounding.AwayFromZero)
: Math.Round(discounted, 2, MidpointRounding.AwayFromZero);
}
Fark satır kırmada. CSharpier 120'yi aşan her yapıyı kendi kurallarına göre böldü: parametre listesi her parametre ayrı satırda, kapanış parantezi kendi satırında; LINQ zinciri nokta başına bir satır; ternary iki dala. Bunların hiçbiri ayarlanamaz; beğenmediğin bir kırılım varsa ya kabul edersin ya da csharpier-ignore kullanırsın. Diff büyüklüğü de bunu yansıtıyor: dotnet format 1.000 dosyada 14.272 satır ekleyip 12.928 satır sildi, CSharpier 44.272 ekleyip 15.928 sildi.
Ölçüm: 1.000 Dosyada Süreler
Aynı formatlanmamış başlangıç commit'inde her komutu kontrol modunda üç kez çalıştırıp duvar saati süresini ölçtük; restore ve build önceden yapılmıştı. Tek bir geliştirici makinesinde alınan değerler; mutlak süreler makineye göre değişir, karşılaştırılması gereken oranlar.
| Komut | Ne kontrol ediyor | Üç koşu (sn) | Çıkış kodu |
|---|---|---|---|
dotnet csharpier check . | Bütün biçim (satır kırma dahil) | 1,07 / 0,94 / 0,98 | 1 |
dotnet format whitespace --folder . --verify-no-changes | Boşluk ve girinti, proje yüklemeden | 1,36 / 1,42 / 1,35 | 2 |
dotnet format whitespace Bookstore.slnx --verify-no-changes | Boşluk ve girinti, çözüm yükleyerek | 2,37 / 2,26 / 2,09 | 2 |
dotnet format Bookstore.slnx --verify-no-changes | Whitespace + style + analyzers | 10,87 / 11,95 / 12,32 | 2 |
CSharpier kendi raporunda "Checked 1007 files in 825ms" diyor; 1.007 dosyanın 1.001'i C#, gerisi beş .csproj ve .slnx. dotnet format whitespace başlangıç commit'inde 20.936 satırlık sorun listesi üretti. Tam dotnet format ise whitespace'e ek olarak semantik analiz yaptığı için yaklaşık on kat yavaş; bu bir kusur değil, yaptığı işin bedeli. Pratik sonuç: kayıtta ya da pre-commit'te çalışacak formatter hızlı olmalı, semantik kontrol CI'da ve build'de durabilir.
Format Çatışması: Önce Tahmin Et'in Cevabı
Açılıştaki senaryoyu aynı 1.000 dosyalık çözümde oynattık. .editorconfig'e K&R (Kernighan ve Ritchie'nin C kitabındaki, açılış parantezi satır sonunda) parantez stilini ve using grupları arasında boş satırı ekledik, CSharpier ile formatlanmış koda dotnet format'ı kontrol modunda çalıştırdık:
$ dotnet format whitespace Bookstore.slnx --verify-no-changes
src/Bookstore.Catalog/Service000.cs(3,1): error WHITESPACE: Fix whitespace formatting. Insert '\n'.
src/Bookstore.Catalog/Service000.cs(15,68): error WHITESPACE: Fix whitespace formatting. Replace 1 characters with '\s'.
src/Bookstore.Catalog/Service000.cs(20,50): error WHITESPACE: Fix whitespace formatting. Replace 5 characters with '\s'.
...
6.002 satır sorun. Ada bunları dotnet format whitespace ile düzeltti; git diff --shortstat'a göre (.editorconfig değişikliği dahil) 1.002 dosya değişti, 6.004 satır eklendi, 10.005 satır silindi. Ardından CSharpier'ın kontrolü:
$ dotnet csharpier check . | grep -c "Was not formatted"
1001
Bin bir dosya. Grace tekrar dotnet csharpier format . çalıştırdığında kod bir önceki hâline döndü ve dotnet format yine 6.002 sorun buldu. Döngü hiç durmaz; iki araç aynı iki kuralın (parantez yeri ve using düzeni) sahibi olduğunu düşünüyor ve her biri diğerinin çıktısını "yanlış" buluyor. Her tur açık PR'larda çatışma, git blame'de anlamsız commit'ler ve CI'da kırmızı build demek.
Daha zor fark edilen kısmı şu: ilk akla gelen çözüm .editorconfig'e dotnet_diagnostic.IDE0055.severity = none yazmak. Bunu denedik, K&R satırı hâlâ dosyadayken:
$ dotnet format Bookstore.slnx --verify-no-changes | wc -l
5002
$ dotnet format whitespace Bookstore.slnx --verify-no-changes | wc -l
5002
$ dotnet format style Bookstore.slnx --verify-no-changes | wc -l
0
IDE0055'i kapatmak build'deki uyarıyı kapatır, dotnet format'ın whitespace adımını kapatmaz; whitespace formatter'ı option'ları doğrudan okur. Çatışmayı bitirmenin yolu severity değil, sahipliği değiştirmek.
Kuralı Tek Sahibe Vermek
Kural basit: her kuralın tek bir sahibi olur. Bookstore'da bölüşüm şöyle. Girinti, boşluk, satır kırma, parantez yeri ve using sırası CSharpier'ın. Stil tercihleri (var, namespace biçimi, expression-bodied member) ve naming IDE kurallarının, yani analyzer'ın. Kalite kuralları (CA) analyzer'ın; onlar ilerideki Statik Analiz serisinin konusu.
Bu bölüşüm iki dosyaya yansıyor. .editorconfig'te CSharpier'ın da okuduğu dört temel ayar kalıyor ve csharp_new_line_*, csharp_space_*, dotnet_separate_import_directive_groups gibi whitespace option'ları siliniyor; böylece iki aracın çelişebileceği bir satır kalmıyor:
[*.cs]
indent_style = space
indent_size = 4
max_line_length = 120
# Whitespace CSharpier'ın: IDE0055 kapalı
dotnet_diagnostic.IDE0055.severity = none
# Stil kuralları analyzer'ın
csharp_style_namespace_declarations = file_scoped
dotnet_diagnostic.IDE0161.severity = warning
csharp_style_var_for_built_in_types = false
csharp_style_var_when_type_is_apparent = false
csharp_style_var_elsewhere = false
dotnet_diagnostic.IDE0008.severity = warning
CI'da da her aracı yalnız kendi işi için çalıştırıyorsun; dotnet format artık alt komutsuz ya da whitespace ile çağrılmıyor:
dotnet tool restore
dotnet csharpier check .
dotnet format style Bookstore.slnx --verify-no-changes
dotnet format analyzers Bookstore.slnx --verify-no-changes
Bu configuration'la CSharpier'dan geçmiş çözümde dört komut da 0 çıkış koduyla döndü; CSharpier "Checked 1007 files in 369ms" yazdı, dotnet format'ın iki adımı hiç çıktı üretmedi. Hook ve CI tarafının ayrıntısı beşinci yazıda.
Ters yön de meşru bir seçim: CSharpier'ı hiç almayıp whitespace'i dotnet format'a bırakmak. Bu durumda satır kırma hiçbir aracın sorumluluğunda değildir ve review'a kalır; ekip bunu kabul ediyorsa ve ek bir araç istemiyorsa sorun yok. İki seçeneğin kısa karşılaştırması: CSharpier tam tutarlılık ve hız verir, karşılığında satır kırma kararlarını ona teslim edersin ve bir araç daha sürümlersin. dotnet format SDK'yla gelir ve .editorconfig'teki her biçim ayarına uyar, karşılığında uzun satırlar ve "bunu nasıl bölelim" tartışması kalır. Bookstore CSharpier'ı seçti.
Sürümü Sabitlemek: dotnet tool Manifest
Formatter'ın çıktısı sürümden sürüme değişir; değişmezse formatter gelişmiyor demektir. CSharpier'ın 1.4.0 changelog'u, son argümanı block body'li bir lambda olan çağrıların artık bölünmeden aynı satırda kalabileceğini yazıyor. Aynı dosyayı iki sürümle formatladık. 1.3.0:
services.AddHttpClient(
configuration["Shipping:ClientName"] ?? "carrier",
client =>
{
client.BaseAddress = new Uri("https://carrier.example.com");
client.Timeout = TimeSpan.FromSeconds(10);
}
);
1.4.0:
services.AddHttpClient(configuration["Shipping:ClientName"] ?? "carrier", client =>
{
client.BaseAddress = new Uri("https://carrier.example.com");
client.Timeout = TimeSpan.FromSeconds(10);
});
Ekipte biri 1.3.0, biri 1.4.0 kullanıyorsa bu tür her çağrı iki kişi arasında gidip gelir; açılıştaki çatışmanın küçük bir versiyonu. Çözüm aracı repo'ya bağlamak, yani local tool:
$ dotnet new tool-manifest
$ dotnet tool install csharpier --version 1.4.0
Tool 'csharpier' (version '1.4.0') was successfully installed. Entry is added to the manifest file /…/dotnet-tools.json.
{
"version": 1,
"isRoot": true,
"tools": {
"csharpier": {
"version": "1.4.0",
"commands": [
"csharpier"
],
"rollForward": false
}
}
}
Bu dosya commit'lenir; herkes ve CI dotnet tool restore ile aynı sürümü alır ve aracı dotnet csharpier olarak çağırır. Küçük bir ayrıntı: SDK 10.0.402'de dotnet new tool-manifest dosyayı repo köküne yazdı; aynı komut SDK 8.0.419'da .config/dotnet-tools.json üretti. İki konum da okunuyor, ama eski bir README'de .config klasörünü arayan biri şaşırabilir. CSharpier'ın changelog'una göre 1.4.0 net8.0 ve net9.0 hedeflerini bıraktı ve net10.0 ile net11.0'ı hedefliyor; .NET 10 SDK'sı olan her makinede çalışır. Sürüm yükseltmesi ayrı bir PR'da, tek başına bir formatlama commit'iyle yapılır; o commit'in git blame'i nasıl kirletmeyeceğini beşinci yazıda göreceğiz.
dotnet format için ayrı bir manifest gerekmiyor; sürümü SDK'nın sürümü. SDK'yı global.json ile sabitlemeyi GitHub Actions serisinin "GitHub Actions ile .NET CI: setup-dotnet, global.json, NuGet Cache, Test Raporu ve Coverage" yazısında anlattık; aynı dosya dotnet format'ın davranışını da sabitler.
Sık Düşülen Tuzaklar
Aynı kuralı iki araca vermek. Açılıştaki döngü bunun sonucu. Belirtisi, formatlama commit'lerinin arka arkaya gelmesi ve aynı dosyaların her seferinde değişmesi. Formatting option'larını .editorconfig'ten temizle ve CI'da dotnet format'ı yalnız style ve analyzers ile çağır.
IDE0055'i kapatmanın yeterli olduğunu sanmak. Ölçtük: build uyarısı gider, dotnet format whitespace option'ları uygulamaya devam eder.
Formatter sürümünü sabitlememek. Global kurulum (dotnet tool install -g) her makinede farklı sürüm demek; CI'da "latest" de aynı anlama gelir. Manifest ve rollForward: false kullan.
Farklı IDE ayarları yüzünden commit'ler arasında gereksiz biçim değişiklikleri. Biri kayıtta CSharpier'ı, biri Visual Studio'nun Code Cleanup'ını, biri hiçbirini kullanıyorsa her commit başka bir biçimle gelir. Ekip için tek bir kayıtta-formatla ayarı belirle; VS Code için repo'da .vscode/settings.json paylaşılabilir. Hook ve CI bunun son kontrolü.
Satır genişliğini iki yerde tutmak. .csharpierrc ve .editorconfig ikisi de genişlik söylüyorsa CSharpier'da .csharpierrc kazanır, editörün çizgisi ise .editorconfig'e göre durur. Ölçtüğümüz gibi tek bir sayı değişikliği 1.000 dosyayı birden "formatlanmamış" yapabilir.
Formatter'ı bir bug fix commit'iyle aynı anda ilk kez çalıştırmak. İlk formatlama tek başına, ayrı bir commit olmalı; aksi hâlde ilk yazıdaki 55 satırlık diff'in bin dosyalık versiyonunu üretirsin.
Küçük Bir Deneme
★☆☆ Kendi çözümünde dotnet format whitespace --verify-no-changes ve dotnet csharpier check . çalıştır (CSharpier'ı önce local tool olarak kur). İkisinin raporladığı dosya sayılarını ve sürelerini karşılaştır.
★★☆ CSharpier'ın 100 karakterlik varsayılan genişliğiyle bir dosyayı formatla, sonra .editorconfig'e max_line_length = 140 yazıp tekrar çalıştır. Hangi yapılar birleşti, hangileri bölünmüş kaldı?
★★★ Bir branch'te .editorconfig'e csharp_new_line_before_open_brace = none ekleyip dotnet format whitespace ve dotnet csharpier format'ı sırayla iki tur çalıştır, her turda git diff --shortstat al. Sonra whitespace option'larını silip aynı deneyi tekrarla ve döngünün bittiğini göster.
C# tarafında kuralları .editorconfig'e, biçimi CSharpier'a verdik. Bookstore'un web arayüzü ise Angular'da. Bir sonraki yazıda aynı sorumluluk paylaşımını TypeScript tarafında kuracağız: Angular Style Guide'ın kararları, Prettier ve ESLint'in hangi kuralın sahibi olduğu.