Forge
csharpdeeb25a2
1using System.Globalization;
2
3namespace CascadeIDE.Services.Presentation;
4
5/// <summary>Парсер строки <c>presentation</c> / <c>zone_screen_layout</c> (EBNF — ADR 0017). Внутри одного экрана <c>(…)</c> — <see cref="PresentationInnerEtoGrammar"/> (Eto.Parse); перед разбором содержимое скобок нормализуется: все символы <see cref="char.IsWhiteSpace(char)"/> удаляются (удобочитаемые пробелы вокруг <c>+</c> и т.д.). Литералы якорей задаются в TOML (<see cref="PresentationGrammarTokens"/>).</summary>
6public static class PresentationParser
7{
8 private const double WeightSumTolerance = 1e-6;
9
10 /// <summary>Пустая или пробельная строка — пустой список экранов (снаружи: «не задано»).</summary>
11 public static PresentationParseResult Parse(string? presentation, PresentationGrammarTokens grammar)
12 {
13 if (string.IsNullOrWhiteSpace(presentation))
14 return PresentationParseResult.Ok(Array.Empty<IReadOnlyList<PresentationAnchorSlot>>());
15
16 var text = presentation.Trim();
17 var screens = new List<IReadOnlyList<PresentationAnchorSlot>>();
18 var i = 0;
19 SkipScreenSeparators(text, grammar.ScreenSeparator, ref i);
20
21 while (i < text.Length)
22 {
23 if (text[i] != grammar.ScreenOpen)
24 {
25 return PresentationParseResult.Fail(
26 string.Format(CultureInfo.InvariantCulture, "Ожидался '{0}' начала экрана на позиции {1}.", grammar.ScreenOpen, i));
27 }
28
29 i++;
30 var startInner = i;
31 while (i < text.Length && text[i] != grammar.ScreenClose)
32 i++;
33
34 if (i >= text.Length)
35 return PresentationParseResult.Fail($"Не найден закрывающий символ '{grammar.ScreenClose}' для экрана.");
36
37 var inner = text.AsSpan(startInner, i - startInner);
38 i++;
39
40 var anchors = ParseAnchorsInner(inner, grammar);
41 if (anchors.Error is { } err)
42 return PresentationParseResult.Fail(err);
43
44 screens.Add(anchors.Value);
45
46 SkipScreenSeparators(text, grammar.ScreenSeparator, ref i);
47 }
48
49 return PresentationParseResult.Ok(screens);
50 }
51
52 private static void SkipScreenSeparators(string text, string separator, ref int i)
53 {
54 while (i < text.Length)
55 {
56 if (separator.Length > 0 && i + separator.Length <= text.Length && text.AsSpan(i).StartsWith(separator, StringComparison.Ordinal))
57 {
58 i += separator.Length;
59 continue;
60 }
61
62 if (char.IsWhiteSpace(text[i]))
63 {
64 i++;
65 continue;
66 }
67
68 break;
69 }
70 }
71
72 private static (List<PresentationAnchorSlot> Value, string? Error) ParseAnchorsInner(ReadOnlySpan<char> inner, PresentationGrammarTokens grammar)
73 {
74 var list = new List<PresentationAnchorSlot>();
75 var s = CollapseInnerWhitespace(inner);
76 if (s.Length == 0)
77 return (list, "Пустой список якорей внутри границ экрана.");
78
79 var innerGrammar = PresentationInnerEtoGrammar.GetOrCreate(grammar);
80 var eto = innerGrammar.Match(s);
81 if (!eto.Success)
82 {
83 return (list, "Неверная последовательность якорей или разделителей внутри экрана.");
84 }
85
86 var sSpan = s.AsSpan();
87 var i = 0;
88 while (i < sSpan.Length)
89 {
90 if (list.Count > 0)
91 {
92 if (!TrySkipZoneSeparator(sSpan, grammar, ref i))
93 return (list, $"Ожидался разделитель якорей на позиции {i}.");
94 }
95
96 double? weight = null;
97 if (TryConsumeWeight(sSpan, ref i, out var w))
98 weight = w;
99
100 if (!TryConsumeAnchorToken(sSpan, grammar, ref i, out var kind))
101 return (list, $"Неизвестный якорь на позиции {i}.");
102
103 list.Add(new PresentationAnchorSlot(kind, weight));
104 }
105
106 if (ValidateScreenWeights(list) is { } err)
107 return (list, err);
108
109 return (list, null);
110 }
111
112 /// <summary>Удаляет все пробельные символы внутри одного экрана <c>(…)</c> (после <see cref="ReadOnlySpan{T}.Trim()"/>).</summary>
113 private static string CollapseInnerWhitespace(ReadOnlySpan<char> inner)
114 {
115 inner = inner.Trim();
116 if (inner.IsEmpty)
117 return string.Empty;
118
119 var remove = 0;
120 for (var j = 0; j < inner.Length; j++)
121 {
122 if (char.IsWhiteSpace(inner[j]))
123 remove++;
124 }
125
126 if (remove == 0)
127 return inner.ToString();
128
129 return string.Create(inner.Length - remove, inner, static (dst, src) =>
130 {
131 var d = 0;
132 for (var j = 0; j < src.Length; j++)
133 {
134 if (!char.IsWhiteSpace(src[j]))
135 dst[d++] = src[j];
136 }
137 });
138 }
139
140 private static string? ValidateScreenWeights(List<PresentationAnchorSlot> list)
141 {
142 if (list.Count == 0)
143 return null;
144
145 if (list.Count == 1)
146 return list[0].Weight is not null
147 ? "Один якорь в группе не может иметь коэффициент."
148 : null;
149
150 var any = false;
151 var all = true;
152 for (var i = 0; i < list.Count; i++)
153 {
154 var has = list[i].Weight.HasValue;
155 if (has)
156 any = true;
157 else
158 all = false;
159 }
160
161 if (any && !all)
162 return "Смешение якорей с коэффициентами и без в одной группе недопустимо.";
163
164 if (!any)
165 return null;
166
167 var sum = 0.0;
168 for (var i = 0; i < list.Count; i++)
169 sum += list[i].Weight!.Value;
170
171 if (Math.Abs(sum - 1.0) > WeightSumTolerance)
172 {
173 return string.Format(
174 CultureInfo.InvariantCulture,
175 "Сумма коэффициентов в группе должна быть 1, получено {0}.",
176 sum);
177 }
178
179 return null;
180 }
181
182 /// <summary>Десятичная точка U+002E, инвариантная культура; во входной строке пробелы внутри <c>(…)</c> удаляются до разбора (<see cref="CollapseInnerWhitespace"/>).</summary>
183 private static bool TryConsumeWeight(ReadOnlySpan<char> s, ref int i, out double weight)
184 {
185 weight = 0;
186 if (i >= s.Length)
187 return false;
188
189 var c = s[i];
190 if (!char.IsDigit(c) && c != '.')
191 return false;
192
193 var start = i;
194 while (i < s.Length && (char.IsDigit(s[i]) || s[i] == '.'))
195 i++;
196
197 var numSpan = s.Slice(start, i - start);
198 if (numSpan.Length == 0)
199 {
200 i = start;
201 return false;
202 }
203
204 if (!double.TryParse(numSpan, NumberStyles.Float, CultureInfo.InvariantCulture, out weight))
205 {
206 i = start;
207 return false;
208 }
209
210 if (weight <= 0 || double.IsNaN(weight) || double.IsInfinity(weight))
211 {
212 i = start;
213 return false;
214 }
215
216 return true;
217 }
218
219 private static bool TrySkipZoneSeparator(ReadOnlySpan<char> s, PresentationGrammarTokens g, ref int i)
220 {
221 if (g.ZoneSeparator.Length > 0 && RemainingStartsWith(s, i, g.ZoneSeparator))
222 {
223 i += g.ZoneSeparator.Length;
224 return true;
225 }
226
227 return false;
228 }
229
230 private static bool TryConsumeAnchorToken(ReadOnlySpan<char> s, PresentationGrammarTokens g, ref int i, out PresentationAnchorKind kind)
231 {
232 kind = default;
233 var r = s.Slice(i);
234
235 var pairs = new (string Token, PresentationAnchorKind K)[]
236 {
237 (g.PfdZoneIdentifier, PresentationAnchorKind.Pfd),
238 (g.ForwardZoneIdentifier, PresentationAnchorKind.Forward),
239 (g.MfdZoneIdentifier, PresentationAnchorKind.Mfd),
240 };
241
242 Array.Sort(pairs, static (a, b) => b.Token.Length.CompareTo(a.Token.Length));
243
244 foreach (var (token, k) in pairs)
245 {
246 if (token.Length == 0)
247 continue;
248
249 if (token.Length == 1)
250 {
251 if (r.Length < 1)
252 continue;
253 if (char.ToUpperInvariant(r[0]) == char.ToUpperInvariant(token[0]))
254 {
255 i += 1;
256 kind = k;
257 return true;
258 }
259
260 continue;
261 }
262
263 if (r.StartsWith(token, StringComparison.Ordinal))
264 {
265 i += token.Length;
266 kind = k;
267 return true;
268 }
269 }
270
271 return false;
272 }
273
274 private static bool RemainingStartsWith(ReadOnlySpan<char> s, int i, string sep)
275 {
276 if (sep.Length == 0 || i + sep.Length > s.Length)
277 return false;
278 return s.Slice(i, sep.Length).SequenceEqual(sep.AsSpan());
279 }
280}
281
View only · write via MCP/CIDE