diff --git a/QuickLook.Plugin/QuickLook.Plugin.TextViewer/JsonBeautifier.cs b/QuickLook.Plugin/QuickLook.Plugin.TextViewer/JsonBeautifier.cs
new file mode 100644
index 000000000..46a3fcb39
--- /dev/null
+++ b/QuickLook.Plugin/QuickLook.Plugin.TextViewer/JsonBeautifier.cs
@@ -0,0 +1,188 @@
+// Copyright © 2017-2026 QL-Win Contributors
+//
+// This file is part of QuickLook program.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using System;
+using System.Text;
+
+namespace QuickLook.Plugin.TextViewer;
+
+/// Validates JSON and inserts indentation without converting any token values.
+internal sealed class JsonBeautifier
+{
+ private const int MaxDepth = 128;
+ private const int MaxOutputLength = 20 * 1024 * 1024;
+ private readonly string _source;
+ private readonly StringBuilder _output = new();
+ private int _position;
+
+ private JsonBeautifier(string source) => _source = source;
+
+ internal static bool TryFormat(string source, out string formatted)
+ {
+ formatted = null;
+ if (source is null) return false;
+ try
+ {
+ var parser = new JsonBeautifier(source);
+ // EncodingDetector may leave a BOM in the decoded text.
+ if (source.Length > 0 && source[0] == '\uFEFF') parser._position++;
+ parser.Value(0);
+ parser.WhiteSpace();
+ if (parser._position != source.Length) return false;
+ formatted = parser._output.ToString();
+ return true;
+ }
+ catch (FormatException)
+ {
+ return false;
+ }
+ }
+
+ private void WhiteSpace()
+ {
+ while (_position < _source.Length && _source[_position] is ' ' or '\t' or '\r' or '\n')
+ _position++;
+ }
+
+ private char Current => _position < _source.Length ? _source[_position] : '\0';
+
+ private void Append(string value)
+ {
+ if (value.Length > MaxOutputLength - _output.Length) throw new FormatException();
+ _output.Append(value);
+ }
+
+ private void NewLine(int depth)
+ {
+ Append(Environment.NewLine);
+ Append(new string(' ', depth * 2));
+ }
+
+ private void Require(char c)
+ {
+ WhiteSpace();
+ if (Current != c) throw new FormatException();
+ _position++;
+ Append(c.ToString());
+ }
+
+ private void Value(int depth)
+ {
+ WhiteSpace();
+ switch (Current)
+ {
+ case '{': Container(depth, true); break;
+ case '[': Container(depth, false); break;
+ case '"': String(); break;
+ case 't': Literal("true"); break;
+ case 'f': Literal("false"); break;
+ case 'n': Literal("null"); break;
+ default: Number(); break;
+ }
+ }
+
+ private void Container(int depth, bool isObject)
+ {
+ if (depth >= MaxDepth) throw new FormatException();
+ char close = isObject ? '}' : ']';
+ Require(isObject ? '{' : '[');
+ WhiteSpace();
+ if (Current == close) { Require(close); return; }
+ while (true)
+ {
+ NewLine(depth + 1);
+ if (isObject)
+ {
+ WhiteSpace();
+ String();
+ Require(':');
+ Append(" ");
+ }
+ Value(depth + 1);
+ WhiteSpace();
+ if (Current == close) break;
+ Require(',');
+ }
+ NewLine(depth);
+ Require(close);
+ }
+
+ private void String()
+ {
+ if (Current != '"') throw new FormatException();
+ int start = _position++;
+ while (_position < _source.Length)
+ {
+ char c = _source[_position++];
+ if (c == '"')
+ {
+ Append(_source.Substring(start, _position - start));
+ return;
+ }
+ if (c < 0x20) throw new FormatException();
+ if (c != '\\') continue;
+ if (_position >= _source.Length) throw new FormatException();
+ char escape = _source[_position++];
+ if (escape == 'u')
+ {
+ for (int i = 0; i < 4; i++)
+ {
+ char hex = Current;
+ if (!(hex is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F'))
+ throw new FormatException();
+ _position++;
+ }
+ }
+ else if (!(escape is '"' or '\\' or '/' or 'b' or 'f' or 'n' or 'r' or 't'))
+ throw new FormatException();
+ }
+ throw new FormatException();
+ }
+
+ private void Literal(string literal)
+ {
+ if (_source.Length - _position < literal.Length ||
+ string.CompareOrdinal(_source, _position, literal, 0, literal.Length) != 0)
+ throw new FormatException();
+ _position += literal.Length;
+ Append(literal);
+ }
+
+ private bool IsDigit => Current is >= '0' and <= '9';
+
+ private void Digits()
+ {
+ if (!IsDigit) throw new FormatException();
+ while (IsDigit) _position++;
+ }
+
+ private void Number()
+ {
+ int start = _position;
+ if (Current == '-') _position++;
+ if (Current == '0') _position++;
+ else Digits();
+ if (Current == '.') { _position++; Digits(); }
+ if (Current is 'e' or 'E')
+ {
+ _position++;
+ if (Current is '+' or '-') _position++;
+ Digits();
+ }
+ Append(_source.Substring(start, _position - start));
+ }
+}
diff --git a/QuickLook.Plugin/QuickLook.Plugin.TextViewer/Plugin.MoreMenu.cs b/QuickLook.Plugin/QuickLook.Plugin.TextViewer/Plugin.MoreMenu.cs
index 5eee34293..cf633d1ed 100644
--- a/QuickLook.Plugin/QuickLook.Plugin.TextViewer/Plugin.MoreMenu.cs
+++ b/QuickLook.Plugin/QuickLook.Plugin.TextViewer/Plugin.MoreMenu.cs
@@ -74,5 +74,13 @@ public IEnumerable GetMenuItems()
if (reopen is not null)
yield return reopen;
+
+ if (extension == ".json" && _beautifyJsonCommand is not null)
+ yield return new MoreMenuItem
+ {
+ Icon = FontSymbols.Code,
+ Header = TranslationHelper.Get("MW_BeautifyJson", translationFile),
+ Command = _beautifyJsonCommand,
+ };
}
}
diff --git a/QuickLook.Plugin/QuickLook.Plugin.TextViewer/Plugin.cs b/QuickLook.Plugin/QuickLook.Plugin.TextViewer/Plugin.cs
index 92bc33737..310c30f00 100644
--- a/QuickLook.Plugin/QuickLook.Plugin.TextViewer/Plugin.cs
+++ b/QuickLook.Plugin/QuickLook.Plugin.TextViewer/Plugin.cs
@@ -15,6 +15,7 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
+using QuickLook.Common.Commands;
using QuickLook.Common.Plugin;
using QuickLook.Common.Plugin.MoreMenu;
using QuickLook.Plugin.TextViewer.Detectors;
@@ -38,6 +39,7 @@ public partial class Plugin : IViewer, IMoreMenu
private TextViewerPanel _tvp;
private string _currentPath;
+ private RelayCommand _beautifyJsonCommand;
public int Priority => -5;
@@ -92,6 +94,8 @@ public void View(string path, ContextObject context)
else
{
_tvp = new TextViewerPanel();
+ _beautifyJsonCommand = new RelayCommand(_tvp.BeautifyJson, () => _tvp?.CanBeautifyJson == true);
+ _tvp.JsonSourceLoaded += () => _beautifyJsonCommand?.RaiseCanExecuteChanged();
_tvp.LoadFileAsync(path, context);
context.ViewerContent = _tvp;
}
@@ -102,6 +106,7 @@ public void Cleanup()
{
_tvp?.Dispose();
_tvp = null;
+ _beautifyJsonCommand = null;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
diff --git a/QuickLook.Plugin/QuickLook.Plugin.TextViewer/TextViewerPanel.cs b/QuickLook.Plugin/QuickLook.Plugin.TextViewer/TextViewerPanel.cs
index 0da7847f1..1562a8a77 100644
--- a/QuickLook.Plugin/QuickLook.Plugin.TextViewer/TextViewerPanel.cs
+++ b/QuickLook.Plugin/QuickLook.Plugin.TextViewer/TextViewerPanel.cs
@@ -43,6 +43,28 @@ namespace QuickLook.Plugin.TextViewer;
public partial class TextViewerPanel : TextEditor, IDisposable
{
private bool _disposed;
+ private string _jsonSource;
+ internal event Action JsonSourceLoaded;
+ internal bool CanBeautifyJson => !_disposed && _jsonSource is not null;
+
+ internal void BeautifyJson()
+ {
+ if (!CanBeautifyJson) return;
+ if (!JsonBeautifier.TryFormat(_jsonSource, out var formatted) || TruncateLongLinesInText(ref formatted))
+ {
+ string translationFile = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "Translations.config");
+ MessageBox.Show(Window.GetWindow(this),
+ TranslationHelper.Get("MW_BeautifyJsonInvalid", translationFile),
+ TranslationHelper.Get("MW_BeautifyJson", translationFile),
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+
+ // Replace only the read-only preview, never the file on disk. Keep the complete
+ // source: the normal text preview may already have truncated long lines.
+ Document = new TextDocument(formatted);
+ ScrollToHome();
+ }
/// Maximum number of characters allowed on a single line before it is truncated.
private const int MAX_LINE_LENGTH = 10000;
@@ -134,6 +156,8 @@ public TextViewerPanel()
public void Dispose()
{
_disposed = true;
+ _jsonSource = null;
+ JsonSourceLoaded = null;
}
private void Viewer_ManipulationInertiaStarting(object sender, ManipulationInertiaStartingEventArgs e)
@@ -356,6 +380,7 @@ public void LoadFileAsync(string path, ContextObject context)
var encoding = EncodingDetector.DetectFromBytes(bufferCopy);
var text = encoding.GetString(bufferCopy);
+ string jsonSource = !fileTooLong && path.EndsWith(".json", StringComparison.OrdinalIgnoreCase) ? text : null;
// Truncate overly long lines to prevent crashes and lag
bool hasLongLines = TruncateLongLinesInText(ref text);
@@ -368,6 +393,7 @@ public void LoadFileAsync(string path, ContextObject context)
Dispatcher.BeginInvoke(() =>
{
+ if (_disposed) return;
var extension = Path.GetExtension(path);
var highlighting = HighlightingThemeManager.GetHighlightingByExtensionOrDetector(path, extension, text);
@@ -420,6 +446,8 @@ public void LoadFileAsync(string path, ContextObject context)
}
context.IsBusy = false;
+ _jsonSource = jsonSource;
+ JsonSourceLoaded?.Invoke();
}, DispatcherPriority.Render);
});
}
diff --git a/QuickLook.Plugin/QuickLook.Plugin.TextViewer/Translations.config b/QuickLook.Plugin/QuickLook.Plugin.TextViewer/Translations.config
index 71bbc18ce..faf5e8111 100644
--- a/QuickLook.Plugin/QuickLook.Plugin.TextViewer/Translations.config
+++ b/QuickLook.Plugin/QuickLook.Plugin.TextViewer/Translations.config
@@ -2,6 +2,8 @@
+ تنسيق JSON
+ تعذر تنسيق JSON. البيانات غير صالحة أو تتجاوز حدود التنسيق. لم تتغير المعاينة.
Consolas
انسخ
حدد الكل
@@ -10,6 +12,8 @@
إعادة فتح كمعاينة CSV
+ Formata el JSON
+ No es pot formatar aquest JSON. No és vàlid o supera els límits de format. La previsualització no ha canviat.
Consolas
Copia
Selecciona-ho tot
@@ -18,6 +22,8 @@
Torna a obrir com a previsualització CSV
+ JSON formatieren
+ Dieses JSON kann nicht formatiert werden. Es ist ungültig oder überschreitet die Formatierungsgrenzen. Die Vorschau wurde nicht geändert.
Consolas
Kopieren
Alles markieren
@@ -26,6 +32,8 @@
Als CSV-Vorschau erneut öffnen
+ Beautify JSON
+ Cannot beautify this JSON. It is invalid or exceeds the formatting limits. The preview has not changed.
Consolas
Copy
Select All
@@ -34,6 +42,8 @@
Reopen as CSV preview
+ Formatear JSON
+ No se puede formatear este JSON. No es válido o supera los límites de formato. La vista previa no ha cambiado.
Consolas
Copiar
Seleccionar Todo
@@ -42,6 +52,8 @@
Reabrir como vista previa CSV
+ Formater le JSON
+ Impossible de formater ce JSON. Il est invalide ou dépasse les limites de formatage. L’aperçu n’a pas été modifié.
Consolas
Copie
Tout sélectionner
@@ -50,6 +62,8 @@
Rouvrir en aperçu CSV
+ Formatta JSON
+ Impossibile formattare questo JSON. Non è valido o supera i limiti di formattazione. L’anteprima non è stata modificata.
Consolas
Copia
Seleziona tutto
@@ -58,6 +72,8 @@
Riapri come anteprima CSV
+ JSON を整形
+ この JSON を整形できません。無効な JSON であるか、整形の制限を超えています。プレビューは変更されていません。
Consolas,Meiryo UI,MS UI Gothic,MS Gothic
コピー
すべて選択
@@ -66,6 +82,8 @@
CSV プレビューとして再度開く
+ JSON 서식 정리
+ 이 JSON의 서식을 정리할 수 없습니다. 유효하지 않거나 서식 정리 제한을 초과했습니다. 미리 보기는 변경되지 않았습니다.
Consolas,Malgun Gothic,Gulim
복사
모두 선택
@@ -74,6 +92,8 @@
CSV 미리보기로 다시 열기
+ Formater JSON
+ Kan ikke formatere denne JSON-teksten. Den er ugyldig eller overskrider formateringsgrensene. Forhåndsvisningen er ikke endret.
Consolas
Kopier
Merk alt
@@ -82,6 +102,8 @@
Åpne på nytt som CSV-forhåndsvisning
+ JSON opmaken
+ Deze JSON kan niet worden opgemaakt. De inhoud is ongeldig of overschrijdt de opmaaklimieten. Het voorbeeld is niet gewijzigd.
Consolas
Kopiëren
Alles selecteren
@@ -90,6 +112,8 @@
Opnieuw openen als CSV-voorbeeld
+ Formatuj JSON
+ Nie można sformatować tego JSON. Jest nieprawidłowy lub przekracza limity formatowania. Podgląd nie został zmieniony.
Consolas
Kopiuj
Zaznacz wszystko
@@ -98,6 +122,8 @@
Otwórz ponownie jako podgląd CSV
+ Formatar JSON
+ Não foi possível formatar este JSON. Ele é inválido ou excede os limites de formatação. A visualização não foi alterada.
Consolas
Copiar
Selecionar tudo
@@ -106,6 +132,8 @@
Reabrir como visualização CSV
+ Formatar JSON
+ Não foi possível formatar este JSON. É inválido ou excede os limites de formatação. A pré-visualização não foi alterada.
Consolas
Copiar
Selecionar tudo
@@ -114,6 +142,8 @@
Reabrir como pré-visualização CSV
+ Formatează JSON
+ Acest JSON nu poate fi formatat. Este nevalid sau depășește limitele de formatare. Previzualizarea nu a fost modificată.
Consolas
Copiere
Selectați tot
@@ -122,6 +152,8 @@
Redeschideți ca previzualizare CSV
+ Форматировать JSON
+ Не удалось форматировать JSON: некорректный JSON или превышены ограничения форматирования. Просмотр не изменён.
Consolas
Копировать
Выделить всё
@@ -130,6 +162,8 @@
Переоткрыть как предварительный просмотр CSV
+ JSON biçimlendir
+ Bu JSON biçimlendirilemiyor. Geçersiz veya biçimlendirme sınırlarını aşıyor. Önizleme değiştirilmedi.
Consolas
Kopyala
Tümünü seç
@@ -138,6 +172,8 @@
CSV önizlemesi olarak yeniden aç
+ Форматувати JSON
+ Не вдалося форматувати JSON: некоректний JSON або перевищено обмеження форматування. Перегляд не змінено.
Consolas
Копіювати
Виділити Все
@@ -146,6 +182,8 @@
Перевідкрити як попередній перегляд CSV
+ Định dạng JSON
+ Không thể định dạng JSON này. Dữ liệu không hợp lệ hoặc vượt quá giới hạn định dạng. Bản xem trước không thay đổi.
Consolas
Sao chép
Chọn tất cả
@@ -154,6 +192,8 @@
Mở lại dưới dạng xem trước CSV
+ 美化 JSON
+ 无法美化此 JSON。内容无效或超出格式化限制。预览未更改。
Consolas,Microsoft Yahei UI,Microsoft Yahei,SimSun
复制
全选
@@ -162,6 +202,8 @@
重新作为 CSV 预览打开
+ 美化 JSON
+ 無法美化此 JSON。內容無效或超出格式化限制。預覽未變更。
Consolas,Microsoft JhengHei UI,Microsoft JhengHei,MingLiU
複製
全選
@@ -169,6 +211,8 @@
重新作為圖像預覽開啟
+ JSON स्वरूपित करा
+ हा JSON स्वरूपित करता आला नाही. तो अवैध आहे किंवा स्वरूपणाच्या मर्यादा ओलांडतो. पूर्वावलोकन बदललेले नाही.
Consolas,Mangal,Nirmala UI
प्रतिलिपी करा
सर्व निवडा
@@ -176,6 +220,8 @@
प्रतिमा पूर्वावलोकन म्हणून पुन्हा उघडा
+ JSON फ़ॉर्मैट करें
+ इस JSON को फ़ॉर्मैट नहीं किया जा सकता। यह अमान्य है या फ़ॉर्मैटिंग की सीमाओं से अधिक है। पूर्वावलोकन बदला नहीं गया है।
Consolas,Mangal,Nirmala UI
कॉपी करें
सभी चुनें
@@ -183,6 +229,8 @@
छवि पूर्वावलोकन के रूप में फिर से खोलें
+ עיצוב JSON
+ לא ניתן לעצב את ה־JSON הזה. הוא אינו תקין או חורג ממגבלות העיצוב. התצוגה המקדימה לא השתנתה.
Consolas
העתק
בחר הכל
@@ -190,6 +238,8 @@
פתח מחדש כתצוגה מקדימה של תמונה
+ JSON formázása
+ Ez a JSON nem formázható. Érvénytelen, vagy meghaladja a formázási korlátokat. Az előnézet nem változott.
Consolas
Másolás
Mindet kijelöl
@@ -197,6 +247,8 @@
Újranyitás kép előnézetként
+ Format JSON
+ JSON ini tidak dapat diformat. Isinya tidak valid atau melebihi batas pemformatan. Pratinjau tidak berubah.
Consolas
Salin
Pilih Semua
@@ -204,6 +256,8 @@
Buka ulang sebagai pratinjau gambar
+ Formátovať JSON
+ Tento JSON nie je možné formátovať. Je neplatný alebo prekračuje limity formátovania. Náhľad sa nezmenil.
Consolas
Kopírovať
Označiť všetko
@@ -211,6 +265,8 @@
Znova otvoriť ako náhľad obrázka
+ Μορφοποίηση JSON
+ Δεν είναι δυνατή η μορφοποίηση αυτού του JSON. Δεν είναι έγκυρο ή υπερβαίνει τα όρια μορφοποίησης. Η προεπισκόπηση δεν άλλαξε.
Consolas
Aντιγραφή
Επιλογή όλων
@@ -218,6 +274,8 @@
Επανανοίξτε ως προεπισκόπηση εικόνας
+ Formatera JSON
+ Det går inte att formatera denna JSON. Den är ogiltig eller överskrider formateringsgränserna. Förhandsvisningen har inte ändrats.
Consolas
Kopiera
Markera allt
diff --git a/Tests/QuickLook.Plugin.TextViewer.Tests/JsonBeautifierTests.cs b/Tests/QuickLook.Plugin.TextViewer.Tests/JsonBeautifierTests.cs
new file mode 100644
index 000000000..f64503183
--- /dev/null
+++ b/Tests/QuickLook.Plugin.TextViewer.Tests/JsonBeautifierTests.cs
@@ -0,0 +1,160 @@
+// Copyright © 2017-2026 QL-Win Contributors
+//
+// This file is part of QuickLook program.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using QuickLook.Plugin.TextViewer;
+using System;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Xml.Linq;
+using Xunit;
+
+namespace QuickLook.Plugin.TextViewer.Tests;
+
+public class JsonBeautifierTests
+{
+ [Fact]
+ public void IndentsNestedObjectsAndArrays()
+ {
+ Assert.True(JsonBeautifier.TryFormat("{\"a\":[1,{\"b\":true}],\"empty\":{}}", out var formatted));
+ Assert.Equal(string.Join(Environment.NewLine,
+ "{", " \"a\": [", " 1,", " {", " \"b\": true", " }", " ],", " \"empty\": {}", "}"), formatted);
+ }
+
+ [Theory]
+ [InlineData("{}")]
+ [InlineData("[]")]
+ [InlineData("null")]
+ [InlineData("true")]
+ [InlineData("false")]
+ [InlineData("0")]
+ [InlineData("-0")]
+ [InlineData("123")]
+ [InlineData("1.2300E+99999")]
+ [InlineData("\"Привет 東京 🌍\"")]
+ [InlineData("{\"x\":\" \\u041f \\b \\n \\\\ \\\"\"}")]
+ [InlineData(" \n[1,2,{},[],[true,false,null]]\t")]
+ public void PreservesTokensAndIsIdempotent(string source)
+ {
+ Assert.True(JsonBeautifier.TryFormat(source, out var formatted));
+ Assert.Equal(Tokens(source), Tokens(formatted));
+ Assert.True(JsonBeautifier.TryFormat(formatted, out var twice));
+ Assert.Equal(formatted, twice);
+ }
+
+ [Fact]
+ public void PreservesExactNumbersAndDuplicateKeys()
+ {
+ const string source = "{\"n\":900719925474099312345678901234567890,\"n\":0.123456789012345678901234567890,\"n\":1e-10000,\"n\":1.234567890123456789e+10000,\"n\":-0,\"n\":1.2300}";
+ Assert.True(JsonBeautifier.TryFormat(source, out var formatted));
+ Assert.Equal(source, Tokens(formatted));
+ }
+
+ [Fact]
+ public void AcceptsLeadingDecodedBom()
+ {
+ Assert.True(JsonBeautifier.TryFormat("\uFEFF{\"a\":1}", out var formatted));
+ Assert.Equal("{\"a\":1}", Tokens(formatted));
+ }
+
+ [Fact]
+ public void HandlesCompactSourceLongerThanPreviewLineLimit()
+ {
+ string source = "[" + string.Join(",", Enumerable.Repeat("{\"name\":\"Привет\",\"id\":12345678901234567890}", 1000)) + "]";
+ Assert.True(source.Length > 10000);
+ Assert.True(JsonBeautifier.TryFormat(source, out var formatted));
+ Assert.Equal(source, Tokens(formatted));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("{")]
+ [InlineData("[")]
+ [InlineData("{\"a\":1,}")]
+ [InlineData("[1,2,]")]
+ [InlineData("[01]")]
+ [InlineData("[-01]")]
+ [InlineData("[+1]")]
+ [InlineData("[1.]")]
+ [InlineData("[.1]")]
+ [InlineData("[1e]")]
+ [InlineData("[1e+]")]
+ [InlineData("[NaN]")]
+ [InlineData("[Infinity]")]
+ [InlineData("undefined")]
+ [InlineData("true false")]
+ [InlineData("{}[]")]
+ [InlineData("{a:1}")]
+ [InlineData("{'a':1}")]
+ [InlineData("{\"a\" 1}")]
+ [InlineData("[1 2]")]
+ [InlineData("\"\\x12\"")]
+ [InlineData("\"\\u123\"")]
+ [InlineData("\"\\uZZZZ\"")]
+ [InlineData("\"raw\nline\"")]
+ [InlineData("\"unterminated")]
+ [InlineData("/*hi*/{}")]
+ [InlineData("[1,//hi\n2]")]
+ [InlineData("[truex]")]
+ [InlineData("\u00a0{}")]
+ [InlineData("{}\uFEFF")]
+ public void RejectsInvalidJsonWithoutReturningPartialOutput(string source)
+ {
+ Assert.False(JsonBeautifier.TryFormat(source, out var formatted));
+ Assert.Null(formatted);
+ }
+
+ [Fact]
+ public void EnforcesDepthAndOutputLimits()
+ {
+ Assert.True(JsonBeautifier.TryFormat(new string('[', 128) + "0" + new string(']', 128), out _));
+ Assert.False(JsonBeautifier.TryFormat(new string('[', 129) + "0" + new string(']', 129), out _));
+ Assert.False(JsonBeautifier.TryFormat("\"" + new string('a', 21 * 1024 * 1024) + "\"", out _));
+ }
+
+ [Fact]
+ public void EverySupportedLocaleHasBothNewStrings()
+ {
+ var translations = XDocument.Load(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Translations.config"));
+ foreach (var locale in translations.Root.Elements())
+ {
+ foreach (string key in new[] { "MW_BeautifyJson", "MW_BeautifyJsonInvalid" })
+ Assert.False(string.IsNullOrWhiteSpace(locale.Element(key)?.Value), locale.Name + ": " + key);
+ }
+ }
+
+ private static string Tokens(string text)
+ {
+ var result = new StringBuilder();
+ bool quoted = false, escaped = false;
+ foreach (char c in text)
+ {
+ if (quoted)
+ {
+ result.Append(c);
+ if (escaped) escaped = false;
+ else if (c == '\\') escaped = true;
+ else if (c == '"') quoted = false;
+ }
+ else if (c == '"') { result.Append(c); quoted = true; }
+ else if (!(c is ' ' or '\t' or '\n' or '\r')) result.Append(c);
+ }
+ return result.ToString();
+ }
+}
diff --git a/Tests/QuickLook.Plugin.TextViewer.Tests/QuickLook.Plugin.TextViewer.Tests.csproj b/Tests/QuickLook.Plugin.TextViewer.Tests/QuickLook.Plugin.TextViewer.Tests.csproj
new file mode 100644
index 000000000..7b0fe2955
--- /dev/null
+++ b/Tests/QuickLook.Plugin.TextViewer.Tests/QuickLook.Plugin.TextViewer.Tests.csproj
@@ -0,0 +1,18 @@
+
+
+ net462
+ latest
+ false
+ true
+
+
+
+
+
+ all
+
+
+
+
+
+
diff --git a/Tests/QuickLook.Plugin.TextViewer.Tests/README.md b/Tests/QuickLook.Plugin.TextViewer.Tests/README.md
new file mode 100644
index 000000000..5c9d121b5
--- /dev/null
+++ b/Tests/QuickLook.Plugin.TextViewer.Tests/README.md
@@ -0,0 +1,7 @@
+Run the formatter and localization regression tests on Windows:
+
+```powershell
+dotnet test Tests/QuickLook.Plugin.TextViewer.Tests/QuickLook.Plugin.TextViewer.Tests.csproj
+```
+
+The tests target .NET Framework 4.6.2 and link the production formatter source, so they do not require the native QuickLook components or a desktop session. They verify JSON syntax, indentation, exact preservation of number and string tokens, duplicate keys, long compact input, formatting limits, and localization coverage. The preview menu and error dialog still require an interactive UI check.