├── .github
└── workflows
│ └── main.yml
├── .gitignore
├── LICENSE
├── README.md
├── img
├── editor-demo.png
├── icon.png
└── icon.webp
└── src
├── .idea
└── .idea.Echoes
│ └── .idea
│ ├── .gitignore
│ ├── .name
│ ├── avalonia.xml
│ ├── encodings.xml
│ ├── indexLayout.xml
│ └── vcs.xml
├── Directory.Build.props
├── Echoes.Generator.Tests
├── Echoes.Generator.Tests.csproj
├── SampleSourceGeneratorTests.cs
└── Utils
│ └── TestAdditionalFile.cs
├── Echoes.Generator
├── Echoes.Generator.csproj
├── Generator.cs
└── Lib
│ ├── Extensions.cs
│ └── Tommy.cs
├── Echoes.SampleApp.Translations
├── Echoes.SampleApp.Translations.csproj
└── Translations
│ ├── Strings.toml
│ ├── Strings_de.toml
│ └── Strings_zh.toml
├── Echoes.SampleApp
├── App.axaml
├── App.axaml.cs
├── Assets
│ └── avalonia-logo.ico
├── Echoes.SampleApp.csproj
├── MainWindow.axaml
├── MainWindow.axaml.cs
├── MainWindowViewModel.cs
├── Program.cs
└── app.manifest
├── Echoes.sln
├── Echoes
├── Echoes.csproj
├── FileTranslationProvider.cs
├── Lib
│ └── Tommy.cs
├── MarkupExtension.cs
├── TranslationProvider.cs
└── TranslationUnit.cs
└── global.json
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: Build, Test and Publish release
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 |
7 | jobs:
8 | build:
9 |
10 | runs-on: macos-latest
11 |
12 | steps:
13 | - uses: actions/checkout@v4
14 |
15 | - name: Setup .NET
16 | uses: actions/setup-dotnet@v4
17 | with:
18 | dotnet-version: |
19 | 8.x.x
20 |
21 |
22 | - name: install workloads
23 | run: dotnet workload restore src/Echoes.sln
24 |
25 | - name: Build for release
26 | run: dotnet build src/Echoes/Echoes.csproj -c Release
27 |
28 | - name: Build for release
29 | run: dotnet build src/Echoes.Generator/Echoes.Generator.csproj -c Release
30 |
31 | - name: Publish Packages (if this version was not published before)
32 | run: dotnet nuget push src/**/bin/Release/*.nupkg -s https://api.nuget.org/v3/index.json -k ${{ secrets.NUGET_API_KEY }} --skip-duplicate
33 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ## Ignore Visual Studio temporary files, build results, and
2 | ## files generated by popular Visual Studio add-ons.
3 | ##
4 | ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
5 |
6 | # User-specific files
7 | *.rsuser
8 | *.suo
9 | *.user
10 | *.userosscache
11 | *.sln.docstates
12 |
13 | # User-specific files (MonoDevelop/Xamarin Studio)
14 | *.userprefs
15 |
16 | # Mono auto generated files
17 | mono_crash.*
18 |
19 | # Build results
20 | [Dd]ebug/
21 | [Dd]ebugPublic/
22 | [Rr]elease/
23 | [Rr]eleases/
24 | x64/
25 | x86/
26 | [Ww][Ii][Nn]32/
27 | [Aa][Rr][Mm]/
28 | [Aa][Rr][Mm]64/
29 | bld/
30 | [Bb]in/
31 | [Oo]bj/
32 | [Ll]og/
33 | [Ll]ogs/
34 |
35 | # Visual Studio 2015/2017 cache/options directory
36 | .vs/
37 | # Uncomment if you have tasks that create the project's static files in wwwroot
38 | #wwwroot/
39 |
40 | # Visual Studio 2017 auto generated files
41 | Generated\ Files/
42 |
43 | # MSTest test Results
44 | [Tt]est[Rr]esult*/
45 | [Bb]uild[Ll]og.*
46 |
47 | # NUnit
48 | *.VisualState.xml
49 | TestResult.xml
50 | nunit-*.xml
51 |
52 | # Build Results of an ATL Project
53 | [Dd]ebugPS/
54 | [Rr]eleasePS/
55 | dlldata.c
56 |
57 | # Benchmark Results
58 | BenchmarkDotNet.Artifacts/
59 |
60 | # .NET Core
61 | project.lock.json
62 | project.fragment.lock.json
63 | artifacts/
64 |
65 | # ASP.NET Scaffolding
66 | ScaffoldingReadMe.txt
67 |
68 | # StyleCop
69 | StyleCopReport.xml
70 |
71 | # Files built by Visual Studio
72 | *_i.c
73 | *_p.c
74 | *_h.h
75 | *.ilk
76 | *.meta
77 | *.obj
78 | *.iobj
79 | *.pch
80 | *.pdb
81 | *.ipdb
82 | *.pgc
83 | *.pgd
84 | *.rsp
85 | *.sbr
86 | *.tlb
87 | *.tli
88 | *.tlh
89 | *.tmp
90 | *.tmp_proj
91 | *_wpftmp.csproj
92 | *.log
93 | *.tlog
94 | *.vspscc
95 | *.vssscc
96 | .builds
97 | *.pidb
98 | *.svclog
99 | *.scc
100 |
101 | # Chutzpah Test files
102 | _Chutzpah*
103 |
104 | # Visual C++ cache files
105 | ipch/
106 | *.aps
107 | *.ncb
108 | *.opendb
109 | *.opensdf
110 | *.sdf
111 | *.cachefile
112 | *.VC.db
113 | *.VC.VC.opendb
114 |
115 | # Visual Studio profiler
116 | *.psess
117 | *.vsp
118 | *.vspx
119 | *.sap
120 |
121 | # Visual Studio Trace Files
122 | *.e2e
123 |
124 | # TFS 2012 Local Workspace
125 | $tf/
126 |
127 | # Guidance Automation Toolkit
128 | *.gpState
129 |
130 | # ReSharper is a .NET coding add-in
131 | _ReSharper*/
132 | *.[Rr]e[Ss]harper
133 | *.DotSettings.user
134 |
135 | # TeamCity is a build add-in
136 | _TeamCity*
137 |
138 | # DotCover is a Code Coverage Tool
139 | *.dotCover
140 |
141 | # AxoCover is a Code Coverage Tool
142 | .axoCover/*
143 | !.axoCover/settings.json
144 |
145 | # Coverlet is a free, cross platform Code Coverage Tool
146 | coverage*.json
147 | coverage*.xml
148 | coverage*.info
149 |
150 | # Visual Studio code coverage results
151 | *.coverage
152 | *.coveragexml
153 |
154 | # NCrunch
155 | _NCrunch_*
156 | .*crunch*.local.xml
157 | nCrunchTemp_*
158 |
159 | # MightyMoose
160 | *.mm.*
161 | AutoTest.Net/
162 |
163 | # Web workbench (sass)
164 | .sass-cache/
165 |
166 | # Installshield output folder
167 | [Ee]xpress/
168 |
169 | # DocProject is a documentation generator add-in
170 | DocProject/buildhelp/
171 | DocProject/Help/*.HxT
172 | DocProject/Help/*.HxC
173 | DocProject/Help/*.hhc
174 | DocProject/Help/*.hhk
175 | DocProject/Help/*.hhp
176 | DocProject/Help/Html2
177 | DocProject/Help/html
178 |
179 | # Click-Once directory
180 | publish/
181 |
182 | # Publish Web Output
183 | *.[Pp]ublish.xml
184 | *.azurePubxml
185 | # Note: Comment the next line if you want to checkin your web deploy settings,
186 | # but database connection strings (with potential passwords) will be unencrypted
187 | *.pubxml
188 | *.publishproj
189 |
190 | # Microsoft Azure Web App publish settings. Comment the next line if you want to
191 | # checkin your Azure Web App publish settings, but sensitive information contained
192 | # in these scripts will be unencrypted
193 | PublishScripts/
194 |
195 | # NuGet Packages
196 | *.nupkg
197 | # NuGet Symbol Packages
198 | *.snupkg
199 | # The packages folder can be ignored because of Package Restore
200 | **/[Pp]ackages/*
201 | # except build/, which is used as an MSBuild target.
202 | !**/[Pp]ackages/build/
203 | # Uncomment if necessary however generally it will be regenerated when needed
204 | #!**/[Pp]ackages/repositories.config
205 | # NuGet v3's project.json files produces more ignorable files
206 | *.nuget.props
207 | *.nuget.targets
208 |
209 | # Microsoft Azure Build Output
210 | csx/
211 | *.build.csdef
212 |
213 | # Microsoft Azure Emulator
214 | ecf/
215 | rcf/
216 |
217 | # Windows Store app package directories and files
218 | AppPackages/
219 | BundleArtifacts/
220 | Package.StoreAssociation.xml
221 | _pkginfo.txt
222 | *.appx
223 | *.appxbundle
224 | *.appxupload
225 |
226 | # Visual Studio cache files
227 | # files ending in .cache can be ignored
228 | *.[Cc]ache
229 | # but keep track of directories ending in .cache
230 | !?*.[Cc]ache/
231 |
232 | # Others
233 | ClientBin/
234 | ~$*
235 | *~
236 | *.dbmdl
237 | *.dbproj.schemaview
238 | *.jfm
239 | *.pfx
240 | *.publishsettings
241 | orleans.codegen.cs
242 |
243 | # Including strong name files can present a security risk
244 | # (https://github.com/github/gitignore/pull/2483#issue-259490424)
245 | #*.snk
246 |
247 | # Since there are multiple workflows, uncomment next line to ignore bower_components
248 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
249 | #bower_components/
250 |
251 | # RIA/Silverlight projects
252 | Generated_Code/
253 |
254 | # Backup & report files from converting an old project file
255 | # to a newer Visual Studio version. Backup files are not needed,
256 | # because we have git ;-)
257 | _UpgradeReport_Files/
258 | Backup*/
259 | UpgradeLog*.XML
260 | UpgradeLog*.htm
261 | ServiceFabricBackup/
262 | *.rptproj.bak
263 |
264 | # SQL Server files
265 | *.mdf
266 | *.ldf
267 | *.ndf
268 |
269 | # Business Intelligence projects
270 | *.rdl.data
271 | *.bim.layout
272 | *.bim_*.settings
273 | *.rptproj.rsuser
274 | *- [Bb]ackup.rdl
275 | *- [Bb]ackup ([0-9]).rdl
276 | *- [Bb]ackup ([0-9][0-9]).rdl
277 |
278 | # Microsoft Fakes
279 | FakesAssemblies/
280 |
281 | # GhostDoc plugin setting file
282 | *.GhostDoc.xml
283 |
284 | # Node.js Tools for Visual Studio
285 | .ntvs_analysis.dat
286 | node_modules/
287 |
288 | # Visual Studio 6 build log
289 | *.plg
290 |
291 | # Visual Studio 6 workspace options file
292 | *.opt
293 |
294 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
295 | *.vbw
296 |
297 | # Visual Studio 6 auto-generated project file (contains which files were open etc.)
298 | *.vbp
299 |
300 | # Visual Studio 6 workspace and project file (working project files containing files to include in project)
301 | *.dsw
302 | *.dsp
303 |
304 | # Visual Studio 6 technical files
305 | *.ncb
306 | *.aps
307 |
308 | # Visual Studio LightSwitch build output
309 | **/*.HTMLClient/GeneratedArtifacts
310 | **/*.DesktopClient/GeneratedArtifacts
311 | **/*.DesktopClient/ModelManifest.xml
312 | **/*.Server/GeneratedArtifacts
313 | **/*.Server/ModelManifest.xml
314 | _Pvt_Extensions
315 |
316 | # Paket dependency manager
317 | .paket/paket.exe
318 | paket-files/
319 |
320 | # FAKE - F# Make
321 | .fake/
322 |
323 | # CodeRush personal settings
324 | .cr/personal
325 |
326 | # Python Tools for Visual Studio (PTVS)
327 | __pycache__/
328 | *.pyc
329 |
330 | # Cake - Uncomment if you are using it
331 | # tools/**
332 | # !tools/packages.config
333 |
334 | # Tabs Studio
335 | *.tss
336 |
337 | # Telerik's JustMock configuration file
338 | *.jmconfig
339 |
340 | # BizTalk build output
341 | *.btp.cs
342 | *.btm.cs
343 | *.odx.cs
344 | *.xsd.cs
345 |
346 | # OpenCover UI analysis results
347 | OpenCover/
348 |
349 | # Azure Stream Analytics local run output
350 | ASALocalRun/
351 |
352 | # MSBuild Binary and Structured Log
353 | *.binlog
354 |
355 | # NVidia Nsight GPU debugger configuration file
356 | *.nvuser
357 |
358 | # MFractors (Xamarin productivity tool) working folder
359 | .mfractor/
360 |
361 | # Local History for Visual Studio
362 | .localhistory/
363 |
364 | # Visual Studio History (VSHistory) files
365 | .vshistory/
366 |
367 | # BeatPulse healthcheck temp database
368 | healthchecksdb
369 |
370 | # Backup folder for Package Reference Convert tool in Visual Studio 2017
371 | MigrationBackup/
372 |
373 | # Ionide (cross platform F# VS Code tools) working folder
374 | .ionide/
375 |
376 | # Fody - auto-generated XML schema
377 | FodyWeavers.xsd
378 |
379 | # VS Code files for those working on multiple tools
380 | .vscode/*
381 | !.vscode/settings.json
382 | !.vscode/tasks.json
383 | !.vscode/launch.json
384 | !.vscode/extensions.json
385 | *.code-workspace
386 |
387 | # Local History for Visual Studio Code
388 | .history/
389 |
390 | # Windows Installer files from build outputs
391 | *.cab
392 | *.msi
393 | *.msix
394 | *.msm
395 | *.msp
396 |
397 | # JetBrains Rider
398 | *.sln.iml
399 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Voyonic Systems
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Echoes
4 |
5 | Simple type safe translations for Avalonia
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | ### Features
14 | - Change language at runtime (obviously - but hard with ResX)
15 | - Translation keys are generated at compile time. Missing keys (from the invariant) will show up as compiler errors.
16 | - [Markup extension](https://docs.avaloniaui.net/docs/concepts/markupextensions) for simple usage
17 | - Simple translation file format based on [TOML](https://toml.io/en/)
18 | - Multiple translation files, so you can split translations by feature, ..
19 |
20 | ### Getting Started
21 |
22 | It's best to take a look at the [Sample Project](https://github.com/Voyonic-Systems/Echoes/tree/main/src/Echoes.SampleApp)
23 |
24 | Add references to the following packages:
25 | ```xml
26 |
27 |
28 | ```
29 |
30 | Specify translations files (Embedded Resources, Source Generator)
31 | ```xml
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | ```
40 |
41 | > [!CAUTION]
42 | > You currently have to place your translation (.toml) files and the generated code in a **separate project**. This is because Avalonia also generates
43 | > code using their XAML compiler. In order for the xaml compiler to see your translations you need to put them in a different project. Otherwise you'll get a
44 | > compiler error.
45 |
46 |
47 | ### Translation Files
48 | Translations are loaded from `.toml` files. The invariant file is **special** as it's structure included configuration data.
49 | Language files are identified by `_{lang}.toml` postfix.
50 |
51 | ```
52 | Strings.toml
53 | Strings_de.toml
54 | Strings_es.toml
55 | ```
56 |
57 | You can split translations in multiple toml files.
58 |
59 | ```
60 | FeatureA.toml
61 | FeatureA_de.toml
62 | FeatureA_es.toml
63 |
64 | FeatureB.toml
65 | FeatureB_de.toml
66 | FeatureB_es.toml
67 | ```
68 |
69 | ### File Format
70 | #### Example: Strings.toml
71 | ```toml
72 | [echoes_config]
73 | generated_class_name = "Strings"
74 | generated_namespace = "Echoes.SampleApp.Translations"
75 |
76 | [translations]
77 | hello_world = 'Hello World'
78 | greeting = 'Hello {0}, how are you?'
79 | ```
80 |
81 | #### Example: Strings_de.toml
82 | ```toml
83 | hello_world = 'Hallo Welt'
84 | greeting = 'Hallo {0}, wie geht es dir?'
85 | ```
86 |
87 | ### Is this library stable?
88 | No, it's currently in preview. See the version number.
89 |
90 | ### Why is it named "Echoes"?
91 | The library is named after the Pink Floyd song [Echoes](https://en.wikipedia.org/wiki/Echoes_(Pink_Floyd_song)).
92 |
--------------------------------------------------------------------------------
/img/editor-demo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Voyonic-Systems/Echoes/c6b12de1f72869ca96683e3212d4598314507c80/img/editor-demo.png
--------------------------------------------------------------------------------
/img/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Voyonic-Systems/Echoes/c6b12de1f72869ca96683e3212d4598314507c80/img/icon.png
--------------------------------------------------------------------------------
/img/icon.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Voyonic-Systems/Echoes/c6b12de1f72869ca96683e3212d4598314507c80/img/icon.webp
--------------------------------------------------------------------------------
/src/.idea/.idea.Echoes/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | # Rider ignored files
5 | /contentModel.xml
6 | /modules.xml
7 | /projectSettingsUpdater.xml
8 | /.idea.Echoes.iml
9 | # Editor-based HTTP Client requests
10 | /httpRequests/
11 | # Datasource local storage ignored files
12 | /dataSources/
13 | /dataSources.local.xml
14 |
--------------------------------------------------------------------------------
/src/.idea/.idea.Echoes/.idea/.name:
--------------------------------------------------------------------------------
1 | Echoes
--------------------------------------------------------------------------------
/src/.idea/.idea.Echoes/.idea/avalonia.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
11 |
12 |
--------------------------------------------------------------------------------
/src/.idea/.idea.Echoes/.idea/encodings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/.idea/.idea.Echoes/.idea/indexLayout.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/src/.idea/.idea.Echoes/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/Directory.Build.props:
--------------------------------------------------------------------------------
1 |
2 |
3 | 11.1.0
4 | 0.1.4
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/Echoes.Generator.Tests/Echoes.Generator.Tests.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net8.0
5 | enable
6 |
7 | false
8 |
9 | Echoes.Generator.Tests
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | runtime; build; native; contentfiles; analyzers; buildtransitive
18 | all
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/src/Echoes.Generator.Tests/SampleSourceGeneratorTests.cs:
--------------------------------------------------------------------------------
1 | using System.IO;
2 | using System.Linq;
3 | using Echoes.Generator.Tests.Utils;
4 | using Microsoft.CodeAnalysis.CSharp;
5 | using Xunit;
6 |
7 | namespace Echoes.Generator.Tests;
8 |
9 | public class GeneratorTests
10 | {
11 | private const string TranslationFileText =
12 | @"
13 | [echoes_config]
14 | generated_namespace = ""Echoes.SampleApp.Translations""
15 | generated_class_name = ""Strings""
16 |
17 | [translations]
18 | hello_world = 'Hello World'
19 | greeting = 'Hello {0}, how are you?'
20 | ";
21 |
22 | [Fact]
23 | public void GenerateClassesBasedOnDDDRegistry()
24 | {
25 | // Create an instance of the source generator.
26 | var generator = new Generator();
27 |
28 | // Source generators should be tested using 'GeneratorDriver'.
29 | var driver = CSharpGeneratorDriver.Create(new[] { generator },
30 | new[]
31 | {
32 | // Add the additional file separately from the compilation.
33 | new TestAdditionalFile("./Strings.toml", TranslationFileText)
34 | });
35 |
36 | // To run generators, we can use an empty compilation.
37 | var compilation = CSharpCompilation.Create(nameof(GeneratorTests));
38 |
39 | // Run generators. Don't forget to use the new compilation rather than the previous one.
40 | driver.RunGeneratorsAndUpdateCompilation(compilation, out var newCompilation, out _);
41 |
42 | // Retrieve all files in the compilation.
43 | var generatedFiles = newCompilation.SyntaxTrees
44 | .Select(t => Path.GetFileName(t.FilePath))
45 | .ToArray();
46 |
47 | // In this case, it is enough to check the file name.
48 | Assert.Equivalent(new[]
49 | {
50 | "User.g.cs",
51 | "Document.g.cs",
52 | "Customer.g.cs"
53 | }, generatedFiles);
54 | }
55 | }
--------------------------------------------------------------------------------
/src/Echoes.Generator.Tests/Utils/TestAdditionalFile.cs:
--------------------------------------------------------------------------------
1 | using System.Threading;
2 | using Microsoft.CodeAnalysis;
3 | using Microsoft.CodeAnalysis.Text;
4 |
5 | namespace Echoes.Generator.Tests.Utils;
6 |
7 | public class TestAdditionalFile : AdditionalText
8 | {
9 | private readonly SourceText _text;
10 |
11 | public TestAdditionalFile(string path, string text)
12 | {
13 | Path = path;
14 | _text = SourceText.From(text);
15 | }
16 |
17 | public override SourceText GetText(CancellationToken cancellationToken = new()) => _text;
18 |
19 | public override string Path { get; }
20 | }
--------------------------------------------------------------------------------
/src/Echoes.Generator/Echoes.Generator.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | netstandard2.0
5 | true
6 | enable
7 | latest
8 |
9 | true
10 | true
11 | Echoes.Generator
12 |
13 |
14 | $(EchoesVersion)
15 | $(EchoesVersion)
16 | icon.png
17 | Voyonic Systems GmbH
18 | Echoes.Generator
19 | Echoes.Generator
20 | Echoes.Generator
21 | Simple type safe translations for Avalonia
22 | MIT
23 | https://github.com/Voyonic-Systems/Echoes
24 | true
25 | true
26 | true
27 | README.md
28 | false
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | all
39 | runtime; build; native; contentfiles; analyzers; buildtransitive
40 |
41 |
42 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/src/Echoes.Generator/Generator.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Collections.Immutable;
4 | using System.IO;
5 | using System.Text;
6 | using Microsoft.CodeAnalysis;
7 | using Tommy;
8 |
9 | namespace Echoes.Generator;
10 |
11 | [Generator]
12 | public class Generator : ISourceGenerator
13 | {
14 | public record InvariantLanguageFile
15 | {
16 | public string ProjectRelativeTomlFilePath { get; }
17 | public string GeneratorNamespace { get; }
18 | public string GeneratorClassName { get; }
19 | public ImmutableArray Units { get; }
20 |
21 | public InvariantLanguageFile
22 | (
23 | string projectRelativeTomlFilePath,
24 | string generatorNamespace,
25 | string generatorClassName,
26 | ImmutableArray units
27 | )
28 | {
29 | ProjectRelativeTomlFilePath = projectRelativeTomlFilePath;
30 | GeneratorNamespace = generatorNamespace;
31 | GeneratorClassName = generatorClassName;
32 | Units = units;
33 | }
34 | }
35 |
36 | public void Initialize(GeneratorInitializationContext context)
37 | {
38 |
39 | }
40 |
41 | public void Execute(GeneratorExecutionContext context)
42 | {
43 | var translationFiles = FindRelevantFiles(context.AdditionalFiles);
44 |
45 | foreach (var file in translationFiles)
46 | {
47 | GenerateKeysFile(file, context);
48 | }
49 | }
50 |
51 | private static ImmutableArray FindRelevantFiles(ImmutableArray additionalFiles)
52 | {
53 | var translationFiles = new List();
54 |
55 | foreach (var additionalFile in additionalFiles)
56 | {
57 | if (additionalFile == null)
58 | continue;
59 |
60 | if (!additionalFile.Path.EndsWith(".toml"))
61 | continue;
62 |
63 | var text = additionalFile.GetText();
64 |
65 | if (text == null)
66 | continue;
67 |
68 | var stringText = text.ToString();
69 |
70 | if (stringText.Contains("[echoes_config]"))
71 | translationFiles.Add(additionalFile);
72 |
73 | }
74 |
75 | return translationFiles.ToImmutableArray();
76 | }
77 |
78 | private static InvariantLanguageFile? ParseTomlFiles(AdditionalText translationFile, GeneratorExecutionContext context)
79 | {
80 | var keys = new List();
81 |
82 | var text = translationFile.GetText()?.ToString() ?? string.Empty;
83 | var reader = new StringReader(text);
84 | var parser = new TOMLParser(reader);
85 | var root = parser.Parse();
86 |
87 | if (!root.RawTable.TryGetValue("echoes_config", out var echoesConfig))
88 | return null;
89 |
90 | if (!echoesConfig.IsTable)
91 | return null;
92 |
93 | if (!echoesConfig.AsTable.RawTable.TryGetValue("generated_class_name", out var generatedClassName))
94 | return null;
95 |
96 | if (!generatedClassName.IsString)
97 | return null;
98 |
99 | if (!echoesConfig.AsTable.RawTable.TryGetValue("generated_namespace", out var generatedNamespace))
100 | return null;
101 |
102 | if (!generatedNamespace.IsString)
103 | return null;
104 |
105 | var projectFolder = context.GetCallingPath();
106 | var sourceFile = translationFile.Path;
107 |
108 | var trimmedSourceFile = sourceFile;
109 |
110 | if (sourceFile.StartsWith(projectFolder))
111 | {
112 | trimmedSourceFile = sourceFile.Substring(projectFolder.Length);
113 | }
114 |
115 | if (!root.RawTable.TryGetValue("translations", out var translations))
116 | return null;
117 |
118 | foreach (var pair in translations.AsTable.RawTable)
119 | {
120 | if (pair.Value.IsString)
121 | {
122 | keys.Add(pair.Key);
123 | }
124 | }
125 |
126 | var units = keys.ToImmutableArray();
127 |
128 | return new InvariantLanguageFile(
129 | trimmedSourceFile,
130 | generatedNamespace.AsString,
131 | generatedClassName.AsString,
132 | units
133 | );
134 | }
135 |
136 | private static void GenerateKeysFile (AdditionalText translationFile, GeneratorExecutionContext context)
137 | {
138 | var file = ParseTomlFiles(translationFile, context);
139 |
140 | if (file == null)
141 | throw new Exception("Failed to parse translation file");
142 |
143 | var sb = new StringBuilder();
144 |
145 | sb.AppendLine($"using Echoes;");
146 | sb.AppendLine($"using System;");
147 | sb.AppendLine($"using System.Reflection;");
148 | sb.AppendLine($"");
149 | sb.AppendLine($"namespace {file.GeneratorNamespace};");
150 | sb.AppendLine("");
151 | sb.AppendLine($"// {file.ProjectRelativeTomlFilePath}");
152 | sb.AppendLine($"// ");
153 | sb.AppendLine($"public static class {file.GeneratorClassName}");
154 | sb.AppendLine("{");
155 |
156 | sb.AppendLine($"\tprivate static readonly string _file = @\"{file.ProjectRelativeTomlFilePath}\";");
157 | sb.AppendLine($"\tprivate static readonly Assembly _assembly = typeof({file.GeneratorClassName}).Assembly;");
158 |
159 | foreach (var key in file.Units)
160 | {
161 | sb.AppendLine($"\tpublic static TranslationUnit {key} => new TranslationUnit(_assembly, _file, \"{key}\");");
162 | }
163 |
164 | sb.AppendLine("}");
165 |
166 | var text = sb.ToString();
167 |
168 | context.AddSource(file.GeneratorClassName + ".g.cs", text);
169 | }
170 | }
--------------------------------------------------------------------------------
/src/Echoes.Generator/Lib/Extensions.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.CodeAnalysis;
2 |
3 | namespace Tommy;
4 |
5 | public static class Extensions
6 | {
7 | /// Gets the file path the source generator was called from.
8 | /// The context of the Generator's Execute method.
9 | /// The file path the generator was called from.
10 | public static string GetCallingPath(this GeneratorExecutionContext context)
11 | {
12 | return context.AnalyzerConfigOptions.GlobalOptions.TryGetValue("build_property.projectdir", out var result)
13 | ? result
14 | : null;
15 | }
16 | }
--------------------------------------------------------------------------------
/src/Echoes.Generator/Lib/Tommy.cs:
--------------------------------------------------------------------------------
1 | #region LICENSE
2 |
3 | /*
4 | * MIT License
5 | *
6 | * Copyright (c) 2020 Denis Zhidkikh
7 | *
8 | * Permission is hereby granted, free of charge, to any person obtaining a copy
9 | * of this software and associated documentation files (the "Software"), to deal
10 | * in the Software without restriction, including without limitation the rights
11 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12 | * copies of the Software, and to permit persons to whom the Software is
13 | * furnished to do so, subject to the following conditions:
14 | *
15 | * The above copyright notice and this permission notice shall be included in all
16 | * copies or substantial portions of the Software.
17 | *
18 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24 | * SOFTWARE.
25 | */
26 |
27 | #endregion
28 |
29 | using System;
30 | using System.Collections;
31 | using System.Collections.Generic;
32 | using System.Globalization;
33 | using System.IO;
34 | using System.Linq;
35 | using System.Text;
36 | using System.Text.RegularExpressions;
37 |
38 | namespace Tommy
39 | {
40 | #region TOML Nodes
41 |
42 | public abstract class TomlNode : IEnumerable
43 | {
44 | public virtual bool HasValue { get; } = false;
45 | public virtual bool IsArray { get; } = false;
46 | public virtual bool IsTable { get; } = false;
47 | public virtual bool IsString { get; } = false;
48 | public virtual bool IsInteger { get; } = false;
49 | public virtual bool IsFloat { get; } = false;
50 | public bool IsDateTime => IsDateTimeLocal || IsDateTimeOffset;
51 | public virtual bool IsDateTimeLocal { get; } = false;
52 | public virtual bool IsDateTimeOffset { get; } = false;
53 | public virtual bool IsBoolean { get; } = false;
54 | public virtual string Comment { get; set; }
55 | public virtual int CollapseLevel { get; set; }
56 |
57 | public virtual TomlTable AsTable => this as TomlTable;
58 | public virtual TomlString AsString => this as TomlString;
59 | public virtual TomlInteger AsInteger => this as TomlInteger;
60 | public virtual TomlFloat AsFloat => this as TomlFloat;
61 | public virtual TomlBoolean AsBoolean => this as TomlBoolean;
62 | public virtual TomlDateTimeLocal AsDateTimeLocal => this as TomlDateTimeLocal;
63 | public virtual TomlDateTimeOffset AsDateTimeOffset => this as TomlDateTimeOffset;
64 | public virtual TomlDateTime AsDateTime => this as TomlDateTime;
65 | public virtual TomlArray AsArray => this as TomlArray;
66 |
67 | public virtual int ChildrenCount => 0;
68 |
69 | public virtual TomlNode this[string key]
70 | {
71 | get => null;
72 | set { }
73 | }
74 |
75 | public virtual TomlNode this[int index]
76 | {
77 | get => null;
78 | set { }
79 | }
80 |
81 | public virtual IEnumerable Children
82 | {
83 | get { yield break; }
84 | }
85 |
86 | public virtual IEnumerable Keys
87 | {
88 | get { yield break; }
89 | }
90 |
91 | public IEnumerator GetEnumerator() => Children.GetEnumerator();
92 |
93 | public virtual bool TryGetNode(string key, out TomlNode node)
94 | {
95 | node = null;
96 | return false;
97 | }
98 |
99 | public virtual bool HasKey(string key) => false;
100 |
101 | public virtual bool HasItemAt(int index) => false;
102 |
103 | public virtual void Add(string key, TomlNode node) { }
104 |
105 | public virtual void Add(TomlNode node) { }
106 |
107 | public virtual void Delete(TomlNode node) { }
108 |
109 | public virtual void Delete(string key) { }
110 |
111 | public virtual void Delete(int index) { }
112 |
113 | public virtual void AddRange(IEnumerable nodes)
114 | {
115 | foreach (var tomlNode in nodes) Add(tomlNode);
116 | }
117 |
118 | public virtual void WriteTo(TextWriter tw, string name = null) => tw.WriteLine(ToInlineToml());
119 |
120 | public virtual string ToInlineToml() => ToString();
121 |
122 | #region Native type to TOML cast
123 |
124 | public static implicit operator TomlNode(string value) => new TomlString {Value = value};
125 |
126 | public static implicit operator TomlNode(bool value) => new TomlBoolean {Value = value};
127 |
128 | public static implicit operator TomlNode(long value) => new TomlInteger {Value = value};
129 |
130 | public static implicit operator TomlNode(float value) => new TomlFloat {Value = value};
131 |
132 | public static implicit operator TomlNode(double value) => new TomlFloat {Value = value};
133 |
134 | public static implicit operator TomlNode(DateTime value) => new TomlDateTimeLocal {Value = value};
135 |
136 | public static implicit operator TomlNode(DateTimeOffset value) => new TomlDateTimeOffset {Value = value};
137 |
138 | public static implicit operator TomlNode(TomlNode[] nodes)
139 | {
140 | var result = new TomlArray();
141 | result.AddRange(nodes);
142 | return result;
143 | }
144 |
145 | #endregion
146 |
147 | #region TOML to native type cast
148 |
149 | public static implicit operator string(TomlNode value) => value.ToString();
150 |
151 | public static implicit operator int(TomlNode value) => (int) value.AsInteger.Value;
152 |
153 | public static implicit operator long(TomlNode value) => value.AsInteger.Value;
154 |
155 | public static implicit operator float(TomlNode value) => (float) value.AsFloat.Value;
156 |
157 | public static implicit operator double(TomlNode value) => value.AsFloat.Value;
158 |
159 | public static implicit operator bool(TomlNode value) => value.AsBoolean.Value;
160 |
161 | public static implicit operator DateTime(TomlNode value) => value.AsDateTimeLocal.Value;
162 |
163 | public static implicit operator DateTimeOffset(TomlNode value) => value.AsDateTimeOffset.Value;
164 |
165 | #endregion
166 | }
167 |
168 | public class TomlString : TomlNode
169 | {
170 | public override bool HasValue { get; } = true;
171 | public override bool IsString { get; } = true;
172 | public bool IsMultiline { get; set; }
173 | public bool MultilineTrimFirstLine { get; set; }
174 | public bool PreferLiteral { get; set; }
175 |
176 | public string Value { get; set; }
177 |
178 | public override string ToString() => Value;
179 |
180 | public override string ToInlineToml()
181 | {
182 | var newLine = @"
183 | ";
184 | // Automatically convert literal to non-literal if there are too many literal string symbols
185 | if (Value.IndexOf(new string(TomlSyntax.LITERAL_STRING_SYMBOL, IsMultiline ? 3 : 1), StringComparison.Ordinal) != -1 && PreferLiteral) PreferLiteral = false;
186 | var quotes = new string(PreferLiteral ? TomlSyntax.LITERAL_STRING_SYMBOL : TomlSyntax.BASIC_STRING_SYMBOL,
187 | IsMultiline ? 3 : 1);
188 | var result = PreferLiteral ? Value : Value.Escape(!IsMultiline);
189 | if (IsMultiline)
190 | result = result.Replace("\r\n", "\n").Replace("\n", newLine);
191 | if (IsMultiline && (MultilineTrimFirstLine || !MultilineTrimFirstLine && result.StartsWith(newLine)))
192 | result = $"{newLine}{result}";
193 | return $"{quotes}{result}{quotes}";
194 | }
195 | }
196 |
197 | public class TomlInteger : TomlNode
198 | {
199 | public enum Base
200 | {
201 | Binary = 2,
202 | Octal = 8,
203 | Decimal = 10,
204 | Hexadecimal = 16
205 | }
206 |
207 | public override bool IsInteger { get; } = true;
208 | public override bool HasValue { get; } = true;
209 | public Base IntegerBase { get; set; } = Base.Decimal;
210 |
211 | public long Value { get; set; }
212 |
213 | public override string ToString() => Value.ToString();
214 |
215 | public override string ToInlineToml() =>
216 | IntegerBase != Base.Decimal
217 | ? $"0{TomlSyntax.BaseIdentifiers[(int) IntegerBase]}{Convert.ToString(Value, (int) IntegerBase)}"
218 | : Value.ToString(CultureInfo.InvariantCulture);
219 | }
220 |
221 | public class TomlFloat : TomlNode, IFormattable
222 | {
223 | public override bool IsFloat { get; } = true;
224 | public override bool HasValue { get; } = true;
225 |
226 | public double Value { get; set; }
227 |
228 | public override string ToString() => Value.ToString(CultureInfo.InvariantCulture);
229 |
230 | public string ToString(string format, IFormatProvider formatProvider) => Value.ToString(format, formatProvider);
231 |
232 | public string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider);
233 |
234 | public override string ToInlineToml() =>
235 | Value switch
236 | {
237 | var v when double.IsNaN(v) => TomlSyntax.NAN_VALUE,
238 | var v when double.IsPositiveInfinity(v) => TomlSyntax.INF_VALUE,
239 | var v when double.IsNegativeInfinity(v) => TomlSyntax.NEG_INF_VALUE,
240 | var v => v.ToString("G", CultureInfo.InvariantCulture).ToLowerInvariant()
241 | };
242 | }
243 |
244 | public class TomlBoolean : TomlNode
245 | {
246 | public override bool IsBoolean { get; } = true;
247 | public override bool HasValue { get; } = true;
248 |
249 | public bool Value { get; set; }
250 |
251 | public override string ToString() => Value.ToString();
252 |
253 | public override string ToInlineToml() => Value ? TomlSyntax.TRUE_VALUE : TomlSyntax.FALSE_VALUE;
254 | }
255 |
256 | public class TomlDateTime : TomlNode, IFormattable
257 | {
258 | public int SecondsPrecision { get; set; }
259 | public override bool HasValue { get; } = true;
260 | public virtual string ToString(string format, IFormatProvider formatProvider) => string.Empty;
261 | public virtual string ToString(IFormatProvider formatProvider) => string.Empty;
262 | protected virtual string ToInlineTomlInternal() => string.Empty;
263 |
264 | public override string ToInlineToml() => ToInlineTomlInternal()
265 | .Replace(TomlSyntax.RFC3339EmptySeparator, TomlSyntax.ISO861Separator)
266 | .Replace(TomlSyntax.ISO861ZeroZone, TomlSyntax.RFC3339ZeroZone);
267 | }
268 |
269 | public class TomlDateTimeOffset : TomlDateTime
270 | {
271 | public override bool IsDateTimeOffset { get; } = true;
272 | public DateTimeOffset Value { get; set; }
273 |
274 | public override string ToString() => Value.ToString(CultureInfo.InvariantCulture);
275 | public override string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider);
276 |
277 | public override string ToString(string format, IFormatProvider formatProvider) =>
278 | Value.ToString(format, formatProvider);
279 |
280 | protected override string ToInlineTomlInternal() => Value.ToString(TomlSyntax.RFC3339Formats[SecondsPrecision]);
281 | }
282 |
283 | public class TomlDateTimeLocal : TomlDateTime
284 | {
285 | public enum DateTimeStyle
286 | {
287 | Date,
288 | Time,
289 | DateTime
290 | }
291 |
292 | public override bool IsDateTimeLocal { get; } = true;
293 | public DateTimeStyle Style { get; set; } = DateTimeStyle.DateTime;
294 | public DateTime Value { get; set; }
295 |
296 | public override string ToString() => Value.ToString(CultureInfo.InvariantCulture);
297 |
298 | public override string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider);
299 |
300 | public override string ToString(string format, IFormatProvider formatProvider) =>
301 | Value.ToString(format, formatProvider);
302 |
303 | public override string ToInlineToml() =>
304 | Style switch
305 | {
306 | DateTimeStyle.Date => Value.ToString(TomlSyntax.LocalDateFormat),
307 | DateTimeStyle.Time => Value.ToString(TomlSyntax.RFC3339LocalTimeFormats[SecondsPrecision]),
308 | var _ => Value.ToString(TomlSyntax.RFC3339LocalDateTimeFormats[SecondsPrecision])
309 | };
310 | }
311 |
312 | public class TomlArray : TomlNode
313 | {
314 | private List values;
315 |
316 | public override bool HasValue { get; } = true;
317 | public override bool IsArray { get; } = true;
318 | public bool IsMultiline { get; set; }
319 | public bool IsTableArray { get; set; }
320 | public List RawArray => values ??= new List();
321 |
322 | public override TomlNode this[int index]
323 | {
324 | get
325 | {
326 | if (index < RawArray.Count) return RawArray[index];
327 | var lazy = new TomlLazy(this);
328 | this[index] = lazy;
329 | return lazy;
330 | }
331 | set
332 | {
333 | if (index == RawArray.Count)
334 | RawArray.Add(value);
335 | else
336 | RawArray[index] = value;
337 | }
338 | }
339 |
340 | public override int ChildrenCount => RawArray.Count;
341 |
342 | public override IEnumerable Children => RawArray.AsEnumerable();
343 |
344 | public override void Add(TomlNode node) => RawArray.Add(node);
345 |
346 | public override void AddRange(IEnumerable nodes) => RawArray.AddRange(nodes);
347 |
348 | public override void Delete(TomlNode node) => RawArray.Remove(node);
349 |
350 | public override void Delete(int index) => RawArray.RemoveAt(index);
351 |
352 | public override string ToString() => ToString(false);
353 |
354 | public string ToString(bool multiline)
355 | {
356 | var sb = new StringBuilder();
357 | sb.Append(TomlSyntax.ARRAY_START_SYMBOL);
358 | if (ChildrenCount != 0)
359 | {
360 | var newLine = @"
361 | ";
362 |
363 | var arrayStart = multiline ? $"{newLine} " : " ";
364 | var arraySeparator = multiline ? $"{TomlSyntax.ITEM_SEPARATOR}{newLine} " : $"{TomlSyntax.ITEM_SEPARATOR} ";
365 | var arrayEnd = multiline ? newLine : " ";
366 | sb.Append(arrayStart)
367 | .Append(arraySeparator.Join(RawArray.Select(n => n.ToInlineToml())))
368 | .Append(arrayEnd);
369 | }
370 | sb.Append(TomlSyntax.ARRAY_END_SYMBOL);
371 | return sb.ToString();
372 | }
373 |
374 | public override void WriteTo(TextWriter tw, string name = null)
375 | {
376 | // If it's a normal array, write it as usual
377 | if (!IsTableArray)
378 | {
379 | tw.WriteLine(ToString(IsMultiline));
380 | return;
381 | }
382 |
383 | if (Comment is not null)
384 | {
385 | tw.WriteLine();
386 | Comment.AsComment(tw);
387 | }
388 | tw.Write(TomlSyntax.ARRAY_START_SYMBOL);
389 | tw.Write(TomlSyntax.ARRAY_START_SYMBOL);
390 | tw.Write(name);
391 | tw.Write(TomlSyntax.ARRAY_END_SYMBOL);
392 | tw.Write(TomlSyntax.ARRAY_END_SYMBOL);
393 | tw.WriteLine();
394 |
395 | var first = true;
396 |
397 | foreach (var tomlNode in RawArray)
398 | {
399 | if (tomlNode is not TomlTable tbl)
400 | throw new TomlFormatException("The array is marked as array table but contains non-table nodes!");
401 |
402 | // Ensure it's parsed as a section
403 | tbl.IsInline = false;
404 |
405 | if (!first)
406 | {
407 | tw.WriteLine();
408 |
409 | Comment?.AsComment(tw);
410 | tw.Write(TomlSyntax.ARRAY_START_SYMBOL);
411 | tw.Write(TomlSyntax.ARRAY_START_SYMBOL);
412 | tw.Write(name);
413 | tw.Write(TomlSyntax.ARRAY_END_SYMBOL);
414 | tw.Write(TomlSyntax.ARRAY_END_SYMBOL);
415 | tw.WriteLine();
416 | }
417 |
418 | first = false;
419 |
420 | // Don't write section since it's already written here
421 | tbl.WriteTo(tw, name, false);
422 | }
423 | }
424 | }
425 |
426 | public class TomlTable : TomlNode
427 | {
428 | private Dictionary children;
429 | internal bool isImplicit;
430 |
431 | public override bool HasValue { get; } = false;
432 | public override bool IsTable { get; } = true;
433 | public bool IsInline { get; set; }
434 | public Dictionary RawTable => children ??= new Dictionary();
435 |
436 | public override TomlNode this[string key]
437 | {
438 | get
439 | {
440 | if (RawTable.TryGetValue(key, out var result)) return result;
441 | var lazy = new TomlLazy(this);
442 | RawTable[key] = lazy;
443 | return lazy;
444 | }
445 | set => RawTable[key] = value;
446 | }
447 |
448 | public override int ChildrenCount => RawTable.Count;
449 | public override IEnumerable Children => RawTable.Select(kv => kv.Value);
450 | public override IEnumerable Keys => RawTable.Select(kv => kv.Key);
451 | public override bool HasKey(string key) => RawTable.ContainsKey(key);
452 | public override void Add(string key, TomlNode node) => RawTable.Add(key, node);
453 | public override bool TryGetNode(string key, out TomlNode node) => RawTable.TryGetValue(key, out node);
454 | public override void Delete(TomlNode node) => RawTable.Remove(RawTable.First(kv => kv.Value == node).Key);
455 | public override void Delete(string key) => RawTable.Remove(key);
456 |
457 | public override string ToString()
458 | {
459 | var sb = new StringBuilder();
460 | sb.Append(TomlSyntax.INLINE_TABLE_START_SYMBOL);
461 |
462 | if (ChildrenCount != 0)
463 | {
464 | var collapsed = CollectCollapsedItems(normalizeOrder: false);
465 |
466 | if (collapsed.Count != 0)
467 | sb.Append(' ')
468 | .Append($"{TomlSyntax.ITEM_SEPARATOR} ".Join(collapsed.Select(n =>
469 | $"{n.Key} {TomlSyntax.KEY_VALUE_SEPARATOR} {n.Value.ToInlineToml()}")));
470 | sb.Append(' ');
471 | }
472 |
473 | sb.Append(TomlSyntax.INLINE_TABLE_END_SYMBOL);
474 | return sb.ToString();
475 | }
476 |
477 | private LinkedList> CollectCollapsedItems(string prefix = "", int level = 0, bool normalizeOrder = true)
478 | {
479 | var nodes = new LinkedList>();
480 | var postNodes = normalizeOrder ? new LinkedList>() : nodes;
481 |
482 | foreach (var keyValuePair in RawTable)
483 | {
484 | var node = keyValuePair.Value;
485 | var key = keyValuePair.Key.AsKey();
486 |
487 | if (node is TomlTable tbl)
488 | {
489 | var subnodes = tbl.CollectCollapsedItems($"{prefix}{key}.", level + 1, normalizeOrder);
490 | // Write main table first before writing collapsed items
491 | if (subnodes.Count == 0 && node.CollapseLevel == level)
492 | {
493 | postNodes.AddLast(new KeyValuePair($"{prefix}{key}", node));
494 | }
495 | foreach (var kv in subnodes)
496 | postNodes.AddLast(kv);
497 | }
498 | else if (node.CollapseLevel == level)
499 | nodes.AddLast(new KeyValuePair($"{prefix}{key}", node));
500 | }
501 |
502 | if (normalizeOrder)
503 | foreach (var kv in postNodes)
504 | nodes.AddLast(kv);
505 |
506 | return nodes;
507 | }
508 |
509 | public override void WriteTo(TextWriter tw, string name = null) => WriteTo(tw, name, true);
510 |
511 | internal void WriteTo(TextWriter tw, string name, bool writeSectionName)
512 | {
513 | // The table is inline table
514 | if (IsInline && name != null)
515 | {
516 | tw.WriteLine(ToInlineToml());
517 | return;
518 | }
519 |
520 | var collapsedItems = CollectCollapsedItems();
521 |
522 | if (collapsedItems.Count == 0)
523 | return;
524 |
525 | var hasRealValues = !collapsedItems.All(n => n.Value is TomlTable {IsInline: false} or TomlArray {IsTableArray: true});
526 |
527 | Comment?.AsComment(tw);
528 |
529 | if (name != null && (hasRealValues || Comment != null) && writeSectionName)
530 | {
531 | tw.Write(TomlSyntax.ARRAY_START_SYMBOL);
532 | tw.Write(name);
533 | tw.Write(TomlSyntax.ARRAY_END_SYMBOL);
534 | tw.WriteLine();
535 | }
536 | else if (Comment != null) // Add some spacing between the first node and the comment
537 | {
538 | tw.WriteLine();
539 | }
540 |
541 | var namePrefix = name == null ? "" : $"{name}.";
542 | var first = true;
543 |
544 | foreach (var collapsedItem in collapsedItems)
545 | {
546 | var key = collapsedItem.Key;
547 | if (collapsedItem.Value is TomlArray {IsTableArray: true} or TomlTable {IsInline: false})
548 | {
549 | if (!first) tw.WriteLine();
550 | first = false;
551 | collapsedItem.Value.WriteTo(tw, $"{namePrefix}{key}");
552 | continue;
553 | }
554 | first = false;
555 |
556 | collapsedItem.Value.Comment?.AsComment(tw);
557 | tw.Write(key);
558 | tw.Write(' ');
559 | tw.Write(TomlSyntax.KEY_VALUE_SEPARATOR);
560 | tw.Write(' ');
561 |
562 | collapsedItem.Value.WriteTo(tw, $"{namePrefix}{key}");
563 | }
564 | }
565 | }
566 |
567 | internal class TomlLazy : TomlNode
568 | {
569 | private readonly TomlNode parent;
570 | private TomlNode replacement;
571 |
572 | public TomlLazy(TomlNode parent) => this.parent = parent;
573 |
574 | public override TomlNode this[int index]
575 | {
576 | get => Set()[index];
577 | set => Set()[index] = value;
578 | }
579 |
580 | public override TomlNode this[string key]
581 | {
582 | get => Set()[key];
583 | set => Set()[key] = value;
584 | }
585 |
586 | public override void Add(TomlNode node) => Set().Add(node);
587 |
588 | public override void Add(string key, TomlNode node) => Set().Add(key, node);
589 |
590 | public override void AddRange(IEnumerable nodes) => Set().AddRange(nodes);
591 |
592 | private TomlNode Set() where T : TomlNode, new()
593 | {
594 | if (replacement != null) return replacement;
595 |
596 | var newNode = new T
597 | {
598 | Comment = Comment
599 | };
600 |
601 | if (parent.IsTable)
602 | {
603 | var key = parent.Keys.FirstOrDefault(s => parent.TryGetNode(s, out var node) && node.Equals(this));
604 | if (key == null) return default(T);
605 |
606 | parent[key] = newNode;
607 | }
608 | else if (parent.IsArray)
609 | {
610 | var index = parent.Children.TakeWhile(child => child != this).Count();
611 | if (index == parent.ChildrenCount) return default(T);
612 | parent[index] = newNode;
613 | }
614 | else
615 | {
616 | return default(T);
617 | }
618 |
619 | replacement = newNode;
620 | return newNode;
621 | }
622 | }
623 |
624 | #endregion
625 |
626 | #region Parser
627 |
628 | public class TOMLParser : IDisposable
629 | {
630 | public enum ParseState
631 | {
632 | None,
633 | KeyValuePair,
634 | SkipToNextLine,
635 | Table
636 | }
637 |
638 | private readonly TextReader reader;
639 | private ParseState currentState;
640 | private int line, col;
641 | private List syntaxErrors;
642 |
643 | public TOMLParser(TextReader reader)
644 | {
645 | this.reader = reader;
646 | line = col = 0;
647 | }
648 |
649 | public bool ForceASCII { get; set; }
650 |
651 | public void Dispose() => reader?.Dispose();
652 |
653 | public TomlTable Parse()
654 | {
655 | syntaxErrors = new List();
656 | line = col = 1;
657 | var rootNode = new TomlTable();
658 | var currentNode = rootNode;
659 | currentState = ParseState.None;
660 | var keyParts = new List();
661 | var arrayTable = false;
662 | StringBuilder latestComment = null;
663 | var firstComment = true;
664 |
665 | int currentChar;
666 | while ((currentChar = reader.Peek()) >= 0)
667 | {
668 | var c = (char) currentChar;
669 |
670 | if (currentState == ParseState.None)
671 | {
672 | // Skip white space
673 | if (TomlSyntax.IsWhiteSpace(c)) goto consume_character;
674 |
675 | if (TomlSyntax.IsNewLine(c))
676 | {
677 | // Check if there are any comments and so far no items being declared
678 | if (latestComment != null && firstComment)
679 | {
680 | rootNode.Comment = latestComment.ToString().TrimEnd();
681 | latestComment = null;
682 | firstComment = false;
683 | }
684 |
685 | if (TomlSyntax.IsLineBreak(c))
686 | AdvanceLine();
687 |
688 | goto consume_character;
689 | }
690 |
691 | // Start of a comment; ignore until newline
692 | if (c == TomlSyntax.COMMENT_SYMBOL)
693 | {
694 | latestComment ??= new StringBuilder();
695 | latestComment.AppendLine(ParseComment());
696 | AdvanceLine(1);
697 | continue;
698 | }
699 |
700 | // Encountered a non-comment value. The comment must belong to it (ignore possible newlines)!
701 | firstComment = false;
702 |
703 | if (c == TomlSyntax.TABLE_START_SYMBOL)
704 | {
705 | currentState = ParseState.Table;
706 | goto consume_character;
707 | }
708 |
709 | if (TomlSyntax.IsBareKey(c) || TomlSyntax.IsQuoted(c))
710 | {
711 | currentState = ParseState.KeyValuePair;
712 | }
713 | else
714 | {
715 | AddError($"Unexpected character \"{c}\"");
716 | continue;
717 | }
718 | }
719 |
720 | if (currentState == ParseState.KeyValuePair)
721 | {
722 | var keyValuePair = ReadKeyValuePair(keyParts);
723 |
724 | if (keyValuePair == null)
725 | {
726 | latestComment = null;
727 | keyParts.Clear();
728 |
729 | if (currentState != ParseState.None)
730 | AddError("Failed to parse key-value pair!");
731 | continue;
732 | }
733 |
734 | keyValuePair.Comment = latestComment?.ToString()?.TrimEnd();
735 | var inserted = InsertNode(keyValuePair, currentNode, keyParts);
736 | latestComment = null;
737 | keyParts.Clear();
738 | if (inserted)
739 | currentState = ParseState.SkipToNextLine;
740 | continue;
741 | }
742 |
743 | if (currentState == ParseState.Table)
744 | {
745 | if (keyParts.Count == 0)
746 | {
747 | // We have array table
748 | if (c == TomlSyntax.TABLE_START_SYMBOL)
749 | {
750 | // Consume the character
751 | ConsumeChar();
752 | arrayTable = true;
753 | }
754 |
755 | if (!ReadKeyName(ref keyParts, TomlSyntax.TABLE_END_SYMBOL))
756 | {
757 | keyParts.Clear();
758 | continue;
759 | }
760 |
761 | if (keyParts.Count == 0)
762 | {
763 | AddError("Table name is emtpy.");
764 | arrayTable = false;
765 | latestComment = null;
766 | keyParts.Clear();
767 | }
768 |
769 | continue;
770 | }
771 |
772 | if (c == TomlSyntax.TABLE_END_SYMBOL)
773 | {
774 | if (arrayTable)
775 | {
776 | // Consume the ending bracket so we can peek the next character
777 | ConsumeChar();
778 | var nextChar = reader.Peek();
779 | if (nextChar < 0 || (char) nextChar != TomlSyntax.TABLE_END_SYMBOL)
780 | {
781 | AddError($"Array table {".".Join(keyParts)} has only one closing bracket.");
782 | keyParts.Clear();
783 | arrayTable = false;
784 | latestComment = null;
785 | continue;
786 | }
787 | }
788 |
789 | currentNode = CreateTable(rootNode, keyParts, arrayTable);
790 | if (currentNode != null)
791 | {
792 | currentNode.IsInline = false;
793 | currentNode.Comment = latestComment?.ToString()?.TrimEnd();
794 | }
795 |
796 | keyParts.Clear();
797 | arrayTable = false;
798 | latestComment = null;
799 |
800 | if (currentNode == null)
801 | {
802 | if (currentState != ParseState.None)
803 | AddError("Error creating table array!");
804 | // Reset a node to root in order to try and continue parsing
805 | currentNode = rootNode;
806 | continue;
807 | }
808 |
809 | currentState = ParseState.SkipToNextLine;
810 | goto consume_character;
811 | }
812 |
813 | if (keyParts.Count != 0)
814 | {
815 | AddError($"Unexpected character \"{c}\"");
816 | keyParts.Clear();
817 | arrayTable = false;
818 | latestComment = null;
819 | }
820 | }
821 |
822 | if (currentState == ParseState.SkipToNextLine)
823 | {
824 | if (TomlSyntax.IsWhiteSpace(c) || c == TomlSyntax.NEWLINE_CARRIAGE_RETURN_CHARACTER)
825 | goto consume_character;
826 |
827 | if (c is TomlSyntax.COMMENT_SYMBOL or TomlSyntax.NEWLINE_CHARACTER)
828 | {
829 | currentState = ParseState.None;
830 | AdvanceLine();
831 |
832 | if (c == TomlSyntax.COMMENT_SYMBOL)
833 | {
834 | col++;
835 | ParseComment();
836 | continue;
837 | }
838 |
839 | goto consume_character;
840 | }
841 |
842 | AddError($"Unexpected character \"{c}\" at the end of the line.");
843 | }
844 |
845 | consume_character:
846 | reader.Read();
847 | col++;
848 | }
849 |
850 | if (currentState != ParseState.None && currentState != ParseState.SkipToNextLine)
851 | AddError("Unexpected end of file!");
852 |
853 | if (syntaxErrors.Count > 0)
854 | throw new TomlParseException(rootNode, syntaxErrors);
855 |
856 | return rootNode;
857 | }
858 |
859 | private bool AddError(string message, bool skipLine = true)
860 | {
861 | syntaxErrors.Add(new TomlSyntaxException(message, currentState, line, col));
862 | // Skip the whole line in hope that it was only a single faulty value (and non-multiline one at that)
863 | if (skipLine)
864 | {
865 | reader.ReadLine();
866 | AdvanceLine(1);
867 | }
868 | currentState = ParseState.None;
869 | return false;
870 | }
871 |
872 | private void AdvanceLine(int startCol = 0)
873 | {
874 | line++;
875 | col = startCol;
876 | }
877 |
878 | private int ConsumeChar()
879 | {
880 | col++;
881 | return reader.Read();
882 | }
883 |
884 | #region Key-Value pair parsing
885 |
886 | /**
887 | * Reads a single key-value pair.
888 | * Assumes the cursor is at the first character that belong to the pair (including possible whitespace).
889 | * Consumes all characters that belong to the key and the value (ignoring possible trailing whitespace at the end).
890 | *
891 | * Example:
892 | * foo = "bar" ==> foo = "bar"
893 | * ^ ^
894 | */
895 | private TomlNode ReadKeyValuePair(List keyParts)
896 | {
897 | int cur;
898 | while ((cur = reader.Peek()) >= 0)
899 | {
900 | var c = (char) cur;
901 |
902 | if (TomlSyntax.IsQuoted(c) || TomlSyntax.IsBareKey(c))
903 | {
904 | if (keyParts.Count != 0)
905 | {
906 | AddError("Encountered extra characters in key definition!");
907 | return null;
908 | }
909 |
910 | if (!ReadKeyName(ref keyParts, TomlSyntax.KEY_VALUE_SEPARATOR))
911 | return null;
912 |
913 | continue;
914 | }
915 |
916 | if (TomlSyntax.IsWhiteSpace(c))
917 | {
918 | ConsumeChar();
919 | continue;
920 | }
921 |
922 | if (c == TomlSyntax.KEY_VALUE_SEPARATOR)
923 | {
924 | ConsumeChar();
925 | return ReadValue();
926 | }
927 |
928 | AddError($"Unexpected character \"{c}\" in key name.");
929 | return null;
930 | }
931 |
932 | return null;
933 | }
934 |
935 | /**
936 | * Reads a single value.
937 | * Assumes the cursor is at the first character that belongs to the value (including possible starting whitespace).
938 | * Consumes all characters belonging to the value (ignoring possible trailing whitespace at the end).
939 | *
940 | * Example:
941 | * "test" ==> "test"
942 | * ^ ^
943 | */
944 | private TomlNode ReadValue(bool skipNewlines = false)
945 | {
946 | int cur;
947 | while ((cur = reader.Peek()) >= 0)
948 | {
949 | var c = (char) cur;
950 |
951 | if (TomlSyntax.IsWhiteSpace(c))
952 | {
953 | ConsumeChar();
954 | continue;
955 | }
956 |
957 | if (c == TomlSyntax.COMMENT_SYMBOL)
958 | {
959 | AddError("No value found!");
960 | return null;
961 | }
962 |
963 | if (TomlSyntax.IsNewLine(c))
964 | {
965 | if (skipNewlines)
966 | {
967 | reader.Read();
968 | AdvanceLine(1);
969 | continue;
970 | }
971 |
972 | AddError("Encountered a newline when expecting a value!");
973 | return null;
974 | }
975 |
976 | if (TomlSyntax.IsQuoted(c))
977 | {
978 | var isMultiline = IsTripleQuote(c, out var excess);
979 |
980 | // Error occurred in triple quote parsing
981 | if (currentState == ParseState.None)
982 | return null;
983 |
984 | var value = isMultiline
985 | ? ReadQuotedValueMultiLine(c)
986 | : ReadQuotedValueSingleLine(c, excess);
987 |
988 | if (value is null)
989 | return null;
990 |
991 | return new TomlString
992 | {
993 | Value = value,
994 | IsMultiline = isMultiline,
995 | PreferLiteral = c == TomlSyntax.LITERAL_STRING_SYMBOL
996 | };
997 | }
998 |
999 | return c switch
1000 | {
1001 | TomlSyntax.INLINE_TABLE_START_SYMBOL => ReadInlineTable(),
1002 | TomlSyntax.ARRAY_START_SYMBOL => ReadArray(),
1003 | var _ => ReadTomlValue()
1004 | };
1005 | }
1006 |
1007 | return null;
1008 | }
1009 |
1010 | /**
1011 | * Reads a single key name.
1012 | * Assumes the cursor is at the first character belonging to the key (with possible trailing whitespace if `skipWhitespace = true`).
1013 | * Consumes all the characters until the `until` character is met (but does not consume the character itself).
1014 | *
1015 | * Example 1:
1016 | * foo.bar ==> foo.bar (`skipWhitespace = false`, `until = ' '`)
1017 | * ^ ^
1018 | *
1019 | * Example 2:
1020 | * [ foo . bar ] ==> [ foo . bar ] (`skipWhitespace = true`, `until = ']'`)
1021 | * ^ ^
1022 | */
1023 | private bool ReadKeyName(ref List parts, char until)
1024 | {
1025 | var buffer = new StringBuilder();
1026 | var quoted = false;
1027 | var prevWasSpace = false;
1028 | int cur;
1029 | while ((cur = reader.Peek()) >= 0)
1030 | {
1031 | var c = (char) cur;
1032 |
1033 | // Reached the final character
1034 | if (c == until) break;
1035 |
1036 | if (TomlSyntax.IsWhiteSpace(c))
1037 | {
1038 | prevWasSpace = true;
1039 | goto consume_character;
1040 | }
1041 |
1042 | if (buffer.Length == 0) prevWasSpace = false;
1043 |
1044 | if (c == TomlSyntax.SUBKEY_SEPARATOR)
1045 | {
1046 | if (buffer.Length == 0 && !quoted)
1047 | return AddError($"Found an extra subkey separator in {".".Join(parts)}...");
1048 |
1049 | parts.Add(buffer.ToString());
1050 | buffer.Length = 0;
1051 | quoted = false;
1052 | prevWasSpace = false;
1053 | goto consume_character;
1054 | }
1055 |
1056 | if (prevWasSpace)
1057 | return AddError("Invalid spacing in key name");
1058 |
1059 | if (TomlSyntax.IsQuoted(c))
1060 | {
1061 | if (quoted)
1062 |
1063 | return AddError("Expected a subkey separator but got extra data instead!");
1064 |
1065 | if (buffer.Length != 0)
1066 | return AddError("Encountered a quote in the middle of subkey name!");
1067 |
1068 | // Consume the quote character and read the key name
1069 | col++;
1070 | buffer.Append(ReadQuotedValueSingleLine((char) reader.Read()));
1071 | quoted = true;
1072 | continue;
1073 | }
1074 |
1075 | if (TomlSyntax.IsBareKey(c))
1076 | {
1077 | buffer.Append(c);
1078 | goto consume_character;
1079 | }
1080 |
1081 | // If we see an invalid symbol, let the next parser handle it
1082 | break;
1083 |
1084 | consume_character:
1085 | reader.Read();
1086 | col++;
1087 | }
1088 |
1089 | if (buffer.Length == 0 && !quoted)
1090 | return AddError($"Found an extra subkey separator in {".".Join(parts)}...");
1091 |
1092 | parts.Add(buffer.ToString());
1093 |
1094 | return true;
1095 | }
1096 |
1097 | #endregion
1098 |
1099 | #region Non-string value parsing
1100 |
1101 | /**
1102 | * Reads the whole raw value until the first non-value character is encountered.
1103 | * Assumes the cursor start position at the first value character and consumes all characters that may be related to the value.
1104 | * Example:
1105 | *
1106 | * 1_0_0_0 ==> 1_0_0_0
1107 | * ^ ^
1108 | */
1109 | private string ReadRawValue()
1110 | {
1111 | var result = new StringBuilder();
1112 | int cur;
1113 | while ((cur = reader.Peek()) >= 0)
1114 | {
1115 | var c = (char) cur;
1116 | if (c == TomlSyntax.COMMENT_SYMBOL || TomlSyntax.IsNewLine(c) || TomlSyntax.IsValueSeparator(c)) break;
1117 | result.Append(c);
1118 | ConsumeChar();
1119 | }
1120 |
1121 | // Replace trim with manual space counting?
1122 | return result.ToString().Trim();
1123 | }
1124 |
1125 | /**
1126 | * Reads and parses a non-string, non-composite TOML value.
1127 | * Assumes the cursor at the first character that is related to the value (with possible spaces).
1128 | * Consumes all the characters that are related to the value.
1129 | *
1130 | * Example
1131 | * 1_0_0_0 # This is a comment
1132 | *
1133 | * ==> 1_0_0_0 # This is a comment
1134 | * ^ ^
1135 | */
1136 | private TomlNode ReadTomlValue()
1137 | {
1138 | var value = ReadRawValue();
1139 | TomlNode node = value switch
1140 | {
1141 | var v when TomlSyntax.IsBoolean(v) => bool.Parse(v),
1142 | var v when TomlSyntax.IsNaN(v) => double.NaN,
1143 | var v when TomlSyntax.IsPosInf(v) => double.PositiveInfinity,
1144 | var v when TomlSyntax.IsNegInf(v) => double.NegativeInfinity,
1145 | var v when TomlSyntax.IsInteger(v) => long.Parse(value.RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR),
1146 | CultureInfo.InvariantCulture),
1147 | var v when TomlSyntax.IsFloat(v) => double.Parse(value.RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR),
1148 | CultureInfo.InvariantCulture),
1149 | var v when TomlSyntax.IsIntegerWithBase(v, out var numberBase) => new TomlInteger
1150 | {
1151 | Value = Convert.ToInt64(value.Substring(2).RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), numberBase),
1152 | IntegerBase = (TomlInteger.Base) numberBase
1153 | },
1154 | var _ => null
1155 | };
1156 | if (node != null) return node;
1157 |
1158 | // Normalize by removing space separator
1159 | value = value.Replace(TomlSyntax.RFC3339EmptySeparator, TomlSyntax.ISO861Separator);
1160 | if (StringUtils.TryParseDateTime(value,
1161 | TomlSyntax.RFC3339LocalDateTimeFormats,
1162 | DateTimeStyles.AssumeLocal,
1163 | DateTime.TryParseExact,
1164 | out var dateTimeResult,
1165 | out var precision))
1166 | return new TomlDateTimeLocal
1167 | {
1168 | Value = dateTimeResult,
1169 | SecondsPrecision = precision
1170 | };
1171 |
1172 | if (DateTime.TryParseExact(value,
1173 | TomlSyntax.LocalDateFormat,
1174 | CultureInfo.InvariantCulture,
1175 | DateTimeStyles.AssumeLocal,
1176 | out dateTimeResult))
1177 | return new TomlDateTimeLocal
1178 | {
1179 | Value = dateTimeResult,
1180 | Style = TomlDateTimeLocal.DateTimeStyle.Date
1181 | };
1182 |
1183 | if (StringUtils.TryParseDateTime(value,
1184 | TomlSyntax.RFC3339LocalTimeFormats,
1185 | DateTimeStyles.AssumeLocal,
1186 | DateTime.TryParseExact,
1187 | out dateTimeResult,
1188 | out precision))
1189 | return new TomlDateTimeLocal
1190 | {
1191 | Value = dateTimeResult,
1192 | Style = TomlDateTimeLocal.DateTimeStyle.Time,
1193 | SecondsPrecision = precision
1194 | };
1195 |
1196 | if (StringUtils.TryParseDateTime(value,
1197 | TomlSyntax.RFC3339Formats,
1198 | DateTimeStyles.None,
1199 | DateTimeOffset.TryParseExact,
1200 | out var dateTimeOffsetResult,
1201 | out precision))
1202 | return new TomlDateTimeOffset
1203 | {
1204 | Value = dateTimeOffsetResult,
1205 | SecondsPrecision = precision
1206 | };
1207 |
1208 | AddError($"Value \"{value}\" is not a valid TOML value!");
1209 | return null;
1210 | }
1211 |
1212 | /**
1213 | * Reads an array value.
1214 | * Assumes the cursor is at the start of the array definition. Reads all character until the array closing bracket.
1215 | *
1216 | * Example:
1217 | * [1, 2, 3] ==> [1, 2, 3]
1218 | * ^ ^
1219 | */
1220 | private TomlArray ReadArray()
1221 | {
1222 | // Consume the start of array character
1223 | ConsumeChar();
1224 | var result = new TomlArray();
1225 | TomlNode currentValue = null;
1226 | var expectValue = true;
1227 |
1228 | int cur;
1229 | while ((cur = reader.Peek()) >= 0)
1230 | {
1231 | var c = (char) cur;
1232 |
1233 | if (c == TomlSyntax.ARRAY_END_SYMBOL)
1234 | {
1235 | ConsumeChar();
1236 | break;
1237 | }
1238 |
1239 | if (c == TomlSyntax.COMMENT_SYMBOL)
1240 | {
1241 | reader.ReadLine();
1242 | AdvanceLine(1);
1243 | continue;
1244 | }
1245 |
1246 | if (TomlSyntax.IsWhiteSpace(c) || TomlSyntax.IsNewLine(c))
1247 | {
1248 | if (TomlSyntax.IsLineBreak(c))
1249 | AdvanceLine();
1250 | goto consume_character;
1251 | }
1252 |
1253 | if (c == TomlSyntax.ITEM_SEPARATOR)
1254 | {
1255 | if (currentValue == null)
1256 | {
1257 | AddError("Encountered multiple value separators");
1258 | return null;
1259 | }
1260 |
1261 | result.Add(currentValue);
1262 | currentValue = null;
1263 | expectValue = true;
1264 | goto consume_character;
1265 | }
1266 |
1267 | if (!expectValue)
1268 | {
1269 | AddError("Missing separator between values");
1270 | return null;
1271 | }
1272 | currentValue = ReadValue(true);
1273 | if (currentValue == null)
1274 | {
1275 | if (currentState != ParseState.None)
1276 | AddError("Failed to determine and parse a value!");
1277 | return null;
1278 | }
1279 | expectValue = false;
1280 |
1281 | continue;
1282 | consume_character:
1283 | ConsumeChar();
1284 | }
1285 |
1286 | if (currentValue != null) result.Add(currentValue);
1287 | return result;
1288 | }
1289 |
1290 | /**
1291 | * Reads an inline table.
1292 | * Assumes the cursor is at the start of the table definition. Reads all character until the table closing bracket.
1293 | *
1294 | * Example:
1295 | * { test = "foo", value = 1 } ==> { test = "foo", value = 1 }
1296 | * ^ ^
1297 | */
1298 | private TomlNode ReadInlineTable()
1299 | {
1300 | ConsumeChar();
1301 | var result = new TomlTable {IsInline = true};
1302 | TomlNode currentValue = null;
1303 | var separator = false;
1304 | var keyParts = new List();
1305 | int cur;
1306 | while ((cur = reader.Peek()) >= 0)
1307 | {
1308 | var c = (char) cur;
1309 |
1310 | if (c == TomlSyntax.INLINE_TABLE_END_SYMBOL)
1311 | {
1312 | ConsumeChar();
1313 | break;
1314 | }
1315 |
1316 | if (c == TomlSyntax.COMMENT_SYMBOL)
1317 | {
1318 | AddError("Incomplete inline table definition!");
1319 | return null;
1320 | }
1321 |
1322 | if (TomlSyntax.IsNewLine(c))
1323 | {
1324 | AddError("Inline tables are only allowed to be on single line");
1325 | return null;
1326 | }
1327 |
1328 | if (TomlSyntax.IsWhiteSpace(c))
1329 | goto consume_character;
1330 |
1331 | if (c == TomlSyntax.ITEM_SEPARATOR)
1332 | {
1333 | if (currentValue == null)
1334 | {
1335 | AddError("Encountered multiple value separators in inline table!");
1336 | return null;
1337 | }
1338 |
1339 | if (!InsertNode(currentValue, result, keyParts))
1340 | return null;
1341 | keyParts.Clear();
1342 | currentValue = null;
1343 | separator = true;
1344 | goto consume_character;
1345 | }
1346 |
1347 | separator = false;
1348 | currentValue = ReadKeyValuePair(keyParts);
1349 | continue;
1350 |
1351 | consume_character:
1352 | ConsumeChar();
1353 | }
1354 |
1355 | if (separator)
1356 | {
1357 | AddError("Trailing commas are not allowed in inline tables.");
1358 | return null;
1359 | }
1360 |
1361 | if (currentValue != null && !InsertNode(currentValue, result, keyParts))
1362 | return null;
1363 |
1364 | return result;
1365 | }
1366 |
1367 | #endregion
1368 |
1369 | #region String parsing
1370 |
1371 | /**
1372 | * Checks if the string value a multiline string (i.e. a triple quoted string).
1373 | * Assumes the cursor is at the first quote character. Consumes the least amount of characters needed to determine if the string is multiline.
1374 | *
1375 | * If the result is false, returns the consumed character through the `excess` variable.
1376 | *
1377 | * Example 1:
1378 | * """test""" ==> """test"""
1379 | * ^ ^
1380 | *
1381 | * Example 2:
1382 | * "test" ==> "test" (doesn't return the first quote)
1383 | * ^ ^
1384 | *
1385 | * Example 3:
1386 | * "" ==> "" (returns the extra `"` through the `excess` variable)
1387 | * ^ ^
1388 | */
1389 | private bool IsTripleQuote(char quote, out char excess)
1390 | {
1391 | // Copypasta, but it's faster...
1392 |
1393 | int cur;
1394 | // Consume the first quote
1395 | ConsumeChar();
1396 | if ((cur = reader.Peek()) < 0)
1397 | {
1398 | excess = '\0';
1399 | return AddError("Unexpected end of file!");
1400 | }
1401 |
1402 | if ((char) cur != quote)
1403 | {
1404 | excess = '\0';
1405 | return false;
1406 | }
1407 |
1408 | // Consume the second quote
1409 | excess = (char) ConsumeChar();
1410 | if ((cur = reader.Peek()) < 0 || (char) cur != quote) return false;
1411 |
1412 | // Consume the final quote
1413 | ConsumeChar();
1414 | excess = '\0';
1415 | return true;
1416 | }
1417 |
1418 | /**
1419 | * A convenience method to process a single character within a quote.
1420 | */
1421 | private bool ProcessQuotedValueCharacter(char quote,
1422 | bool isNonLiteral,
1423 | char c,
1424 | StringBuilder sb,
1425 | ref bool escaped)
1426 | {
1427 | if (TomlSyntax.MustBeEscaped(c))
1428 | return AddError($"The character U+{(int) c:X8} must be escaped in a string!");
1429 |
1430 | if (escaped)
1431 | {
1432 | sb.Append(c);
1433 | escaped = false;
1434 | return false;
1435 | }
1436 |
1437 | if (c == quote) return true;
1438 | if (isNonLiteral && c == TomlSyntax.ESCAPE_SYMBOL)
1439 | escaped = true;
1440 | if (c == TomlSyntax.NEWLINE_CHARACTER)
1441 | return AddError("Encountered newline in single line string!");
1442 |
1443 | sb.Append(c);
1444 | return false;
1445 | }
1446 |
1447 | /**
1448 | * Reads a single-line string.
1449 | * Assumes the cursor is at the first character that belongs to the string.
1450 | * Consumes all characters that belong to the string (including the closing quote).
1451 | *
1452 | * Example:
1453 | * "test" ==> "test"
1454 | * ^ ^
1455 | */
1456 | private string ReadQuotedValueSingleLine(char quote, char initialData = '\0')
1457 | {
1458 | var isNonLiteral = quote == TomlSyntax.BASIC_STRING_SYMBOL;
1459 | var sb = new StringBuilder();
1460 | var escaped = false;
1461 |
1462 | if (initialData != '\0')
1463 | {
1464 | var shouldReturn =
1465 | ProcessQuotedValueCharacter(quote, isNonLiteral, initialData, sb, ref escaped);
1466 | if (currentState == ParseState.None) return null;
1467 | if (shouldReturn)
1468 | if (isNonLiteral)
1469 | {
1470 | if (sb.ToString().TryUnescape(out var res, out var ex)) return res;
1471 | AddError(ex.Message);
1472 | return null;
1473 | }
1474 | else
1475 | return sb.ToString();
1476 | }
1477 |
1478 | int cur;
1479 | var readDone = false;
1480 | while ((cur = reader.Read()) >= 0)
1481 | {
1482 | // Consume the character
1483 | col++;
1484 | var c = (char) cur;
1485 | readDone = ProcessQuotedValueCharacter(quote, isNonLiteral, c, sb, ref escaped);
1486 | if (readDone)
1487 | {
1488 | if (currentState == ParseState.None) return null;
1489 | break;
1490 | }
1491 | }
1492 |
1493 | if (!readDone)
1494 | {
1495 | AddError("Unclosed string.");
1496 | return null;
1497 | }
1498 |
1499 | if (!isNonLiteral) return sb.ToString();
1500 | if (sb.ToString().TryUnescape(out var unescaped, out var unescapedEx)) return unescaped;
1501 | AddError(unescapedEx.Message);
1502 | return null;
1503 | }
1504 |
1505 | /**
1506 | * Reads a multiline string.
1507 | * Assumes the cursor is at the first character that belongs to the string.
1508 | * Consumes all characters that belong to the string and the three closing quotes.
1509 | *
1510 | * Example:
1511 | * """test""" ==> """test"""
1512 | * ^ ^
1513 | */
1514 | private string ReadQuotedValueMultiLine(char quote)
1515 | {
1516 | var isBasic = quote == TomlSyntax.BASIC_STRING_SYMBOL;
1517 | var sb = new StringBuilder();
1518 | var escaped = false;
1519 | var skipWhitespace = false;
1520 | var skipWhitespaceLineSkipped = false;
1521 | var quotesEncountered = 0;
1522 | var first = true;
1523 | int cur;
1524 | while ((cur = ConsumeChar()) >= 0)
1525 | {
1526 | var c = (char) cur;
1527 | if (TomlSyntax.MustBeEscaped(c, true))
1528 | {
1529 | AddError($"The character U+{(int) c:X8} must be escaped!");
1530 | return null;
1531 | }
1532 | // Trim the first newline
1533 | if (first && TomlSyntax.IsNewLine(c))
1534 | {
1535 | if (TomlSyntax.IsLineBreak(c))
1536 | first = false;
1537 | else
1538 | AdvanceLine();
1539 | continue;
1540 | }
1541 |
1542 | first = false;
1543 | //TODO: Reuse ProcessQuotedValueCharacter
1544 | // Skip the current character if it is going to be escaped later
1545 | if (escaped)
1546 | {
1547 | sb.Append(c);
1548 | escaped = false;
1549 | continue;
1550 | }
1551 |
1552 | // If we are currently skipping empty spaces, skip
1553 | if (skipWhitespace)
1554 | {
1555 | if (TomlSyntax.IsEmptySpace(c))
1556 | {
1557 | if (TomlSyntax.IsLineBreak(c))
1558 | {
1559 | skipWhitespaceLineSkipped = true;
1560 | AdvanceLine();
1561 | }
1562 | continue;
1563 | }
1564 |
1565 | if (!skipWhitespaceLineSkipped)
1566 | {
1567 | AddError("Non-whitespace character after trim marker.");
1568 | return null;
1569 | }
1570 |
1571 | skipWhitespaceLineSkipped = false;
1572 | skipWhitespace = false;
1573 | }
1574 |
1575 | // If we encounter an escape sequence...
1576 | if (isBasic && c == TomlSyntax.ESCAPE_SYMBOL)
1577 | {
1578 | var next = reader.Peek();
1579 | var nc = (char) next;
1580 | if (next >= 0)
1581 | {
1582 | // ...and the next char is empty space, we must skip all whitespaces
1583 | if (TomlSyntax.IsEmptySpace(nc))
1584 | {
1585 | skipWhitespace = true;
1586 | continue;
1587 | }
1588 |
1589 | // ...and we have \" or \, skip the character
1590 | if (nc == quote || nc == TomlSyntax.ESCAPE_SYMBOL) escaped = true;
1591 | }
1592 | }
1593 |
1594 | // Count the consecutive quotes
1595 | if (c == quote)
1596 | quotesEncountered++;
1597 | else
1598 | quotesEncountered = 0;
1599 |
1600 | // If the are three quotes, count them as closing quotes
1601 | if (quotesEncountered == 3) break;
1602 |
1603 | sb.Append(c);
1604 | }
1605 |
1606 | // TOML actually allows to have five ending quotes like
1607 | // """"" => "" belong to the string + """ is the actual ending
1608 | quotesEncountered = 0;
1609 | while ((cur = reader.Peek()) >= 0)
1610 | {
1611 | var c = (char) cur;
1612 | if (c == quote && ++quotesEncountered < 3)
1613 | {
1614 | sb.Append(c);
1615 | ConsumeChar();
1616 | }
1617 | else break;
1618 | }
1619 |
1620 | // Remove last two quotes (third one wasn't included by default)
1621 | sb.Length -= 2;
1622 | if (!isBasic) return sb.ToString();
1623 | if (sb.ToString().TryUnescape(out var res, out var ex)) return res;
1624 | AddError(ex.Message);
1625 | return null;
1626 | }
1627 |
1628 | #endregion
1629 |
1630 | #region Node creation
1631 |
1632 | private bool InsertNode(TomlNode node, TomlNode root, IList path)
1633 | {
1634 | var latestNode = root;
1635 | if (path.Count > 1)
1636 | for (var index = 0; index < path.Count - 1; index++)
1637 | {
1638 | var subkey = path[index];
1639 | if (latestNode.TryGetNode(subkey, out var currentNode))
1640 | {
1641 | if (currentNode.HasValue)
1642 | return AddError($"The key {".".Join(path)} already has a value assigned to it!");
1643 | }
1644 | else
1645 | {
1646 | currentNode = new TomlTable();
1647 | latestNode[subkey] = currentNode;
1648 | }
1649 |
1650 | latestNode = currentNode;
1651 | if (latestNode is TomlTable { IsInline: true })
1652 | return AddError($"Cannot assign {".".Join(path)} because it will edit an immutable table.");
1653 | }
1654 |
1655 | if (latestNode.HasKey(path[path.Count - 1]))
1656 | return AddError($"The key {".".Join(path)} is already defined!");
1657 | latestNode[path[path.Count - 1]] = node;
1658 | node.CollapseLevel = path.Count - 1;
1659 | return true;
1660 | }
1661 |
1662 | private TomlTable CreateTable(TomlNode root, IList path, bool arrayTable)
1663 | {
1664 | if (path.Count == 0) return null;
1665 | var latestNode = root;
1666 | for (var index = 0; index < path.Count; index++)
1667 | {
1668 | var subkey = path[index];
1669 |
1670 | if (latestNode.TryGetNode(subkey, out var node))
1671 | {
1672 | if (node.IsArray && arrayTable)
1673 | {
1674 | var arr = (TomlArray) node;
1675 |
1676 | if (!arr.IsTableArray)
1677 | {
1678 | AddError($"The array {".".Join(path)} cannot be redefined as an array table!");
1679 | return null;
1680 | }
1681 |
1682 | if (index == path.Count - 1)
1683 | {
1684 | latestNode = new TomlTable();
1685 | arr.Add(latestNode);
1686 | break;
1687 | }
1688 |
1689 | latestNode = arr[arr.ChildrenCount - 1];
1690 | continue;
1691 | }
1692 |
1693 | if (node is TomlTable { IsInline: true })
1694 | {
1695 | AddError($"Cannot create table {".".Join(path)} because it will edit an immutable table.");
1696 | return null;
1697 | }
1698 |
1699 | if (node.HasValue)
1700 | {
1701 | if (node is not TomlArray { IsTableArray: true } array)
1702 | {
1703 | AddError($"The key {".".Join(path)} has a value assigned to it!");
1704 | return null;
1705 | }
1706 |
1707 | latestNode = array[array.ChildrenCount - 1];
1708 | continue;
1709 | }
1710 |
1711 | if (index == path.Count - 1)
1712 | {
1713 | if (arrayTable && !node.IsArray)
1714 | {
1715 | AddError($"The table {".".Join(path)} cannot be redefined as an array table!");
1716 | return null;
1717 | }
1718 |
1719 | if (node is TomlTable { isImplicit: false })
1720 | {
1721 | AddError($"The table {".".Join(path)} is defined multiple times!");
1722 | return null;
1723 | }
1724 | }
1725 | }
1726 | else
1727 | {
1728 | if (index == path.Count - 1 && arrayTable)
1729 | {
1730 | var table = new TomlTable();
1731 | var arr = new TomlArray
1732 | {
1733 | IsTableArray = true
1734 | };
1735 | arr.Add(table);
1736 | latestNode[subkey] = arr;
1737 | latestNode = table;
1738 | break;
1739 | }
1740 |
1741 | node = new TomlTable { isImplicit = true };
1742 | latestNode[subkey] = node;
1743 | }
1744 |
1745 | latestNode = node;
1746 | }
1747 |
1748 | var result = (TomlTable) latestNode;
1749 | result.isImplicit = false;
1750 | return result;
1751 | }
1752 |
1753 | #endregion
1754 |
1755 | #region Misc parsing
1756 |
1757 | private string ParseComment()
1758 | {
1759 | ConsumeChar();
1760 | var commentLine = reader.ReadLine()?.Trim() ?? "";
1761 | if (commentLine.Any(ch => TomlSyntax.MustBeEscaped(ch)))
1762 | AddError("Comment must not contain control characters other than tab.", false);
1763 | return commentLine;
1764 | }
1765 | #endregion
1766 | }
1767 |
1768 | #endregion
1769 |
1770 | public static class TOML
1771 | {
1772 | public static bool ForceASCII { get; set; } = false;
1773 |
1774 | public static TomlTable Parse(TextReader reader)
1775 | {
1776 | using var parser = new TOMLParser(reader) {ForceASCII = ForceASCII};
1777 | return parser.Parse();
1778 | }
1779 | }
1780 |
1781 | #region Exception Types
1782 |
1783 | public class TomlFormatException : Exception
1784 | {
1785 | public TomlFormatException(string message) : base(message) { }
1786 | }
1787 |
1788 | public class TomlParseException : Exception
1789 | {
1790 | public TomlParseException(TomlTable parsed, IEnumerable exceptions) :
1791 | base("TOML file contains format errors")
1792 | {
1793 | ParsedTable = parsed;
1794 | SyntaxErrors = exceptions;
1795 | }
1796 |
1797 | public TomlTable ParsedTable { get; }
1798 |
1799 | public IEnumerable SyntaxErrors { get; }
1800 | }
1801 |
1802 | public class TomlSyntaxException : Exception
1803 | {
1804 | public TomlSyntaxException(string message, TOMLParser.ParseState state, int line, int col) : base(message)
1805 | {
1806 | ParseState = state;
1807 | Line = line;
1808 | Column = col;
1809 | }
1810 |
1811 | public TOMLParser.ParseState ParseState { get; }
1812 |
1813 | public int Line { get; }
1814 |
1815 | public int Column { get; }
1816 | }
1817 |
1818 | #endregion
1819 |
1820 | #region Parse utilities
1821 |
1822 | internal static class TomlSyntax
1823 | {
1824 | #region Type Patterns
1825 |
1826 | public const string TRUE_VALUE = "true";
1827 | public const string FALSE_VALUE = "false";
1828 | public const string NAN_VALUE = "nan";
1829 | public const string POS_NAN_VALUE = "+nan";
1830 | public const string NEG_NAN_VALUE = "-nan";
1831 | public const string INF_VALUE = "inf";
1832 | public const string POS_INF_VALUE = "+inf";
1833 | public const string NEG_INF_VALUE = "-inf";
1834 |
1835 | public static bool IsBoolean(string s) => s is TRUE_VALUE or FALSE_VALUE;
1836 |
1837 | public static bool IsPosInf(string s) => s is INF_VALUE or POS_INF_VALUE;
1838 |
1839 | public static bool IsNegInf(string s) => s == NEG_INF_VALUE;
1840 |
1841 | public static bool IsNaN(string s) => s is NAN_VALUE or POS_NAN_VALUE or NEG_NAN_VALUE;
1842 |
1843 | public static bool IsInteger(string s) => IntegerPattern.IsMatch(s);
1844 |
1845 | public static bool IsFloat(string s) => FloatPattern.IsMatch(s);
1846 |
1847 | public static bool IsIntegerWithBase(string s, out int numberBase)
1848 | {
1849 | numberBase = 10;
1850 | var match = BasedIntegerPattern.Match(s);
1851 | if (!match.Success) return false;
1852 | IntegerBases.TryGetValue(match.Groups["base"].Value, out numberBase);
1853 | return true;
1854 | }
1855 |
1856 | /**
1857 | * A pattern to verify the integer value according to the TOML specification.
1858 | */
1859 | public static readonly Regex IntegerPattern =
1860 | new(@"^(\+|-)?(?!_)(0|(?!0)(_?\d)*)$", RegexOptions.Compiled);
1861 |
1862 | /**
1863 | * A pattern to verify a special 0x, 0o and 0b forms of an integer according to the TOML specification.
1864 | */
1865 | public static readonly Regex BasedIntegerPattern =
1866 | new(@"^0(?x|b|o)(?!_)(_?[0-9A-F])*$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
1867 |
1868 | /**
1869 | * A pattern to verify the float value according to the TOML specification.
1870 | */
1871 | public static readonly Regex FloatPattern =
1872 | new(@"^(\+|-)?(?!_)(0|(?!0)(_?\d)+)(((e(\+|-)?(?!_)(_?\d)+)?)|(\.(?!_)(_?\d)+(e(\+|-)?(?!_)(_?\d)+)?))$",
1873 | RegexOptions.Compiled | RegexOptions.IgnoreCase);
1874 |
1875 | /**
1876 | * A helper dictionary to map TOML base codes into the radii.
1877 | */
1878 | public static readonly Dictionary IntegerBases = new()
1879 | {
1880 | ["x"] = 16,
1881 | ["o"] = 8,
1882 | ["b"] = 2
1883 | };
1884 |
1885 | /**
1886 | * A helper dictionary to map non-decimal bases to their TOML identifiers
1887 | */
1888 | public static readonly Dictionary BaseIdentifiers = new()
1889 | {
1890 | [2] = "b",
1891 | [8] = "o",
1892 | [16] = "x"
1893 | };
1894 |
1895 | public const string RFC3339EmptySeparator = " ";
1896 | public const string ISO861Separator = "T";
1897 | public const string ISO861ZeroZone = "+00:00";
1898 | public const string RFC3339ZeroZone = "Z";
1899 |
1900 | /**
1901 | * Valid date formats with timezone as per RFC3339.
1902 | */
1903 | public static readonly string[] RFC3339Formats =
1904 | {
1905 | "yyyy'-'MM-ddTHH':'mm':'ssK", "yyyy'-'MM-ddTHH':'mm':'ss'.'fK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffK",
1906 | "yyyy'-'MM-ddTHH':'mm':'ss'.'fffK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffK",
1907 | "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffffK",
1908 | "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffffK"
1909 | };
1910 |
1911 | /**
1912 | * Valid date formats without timezone (assumes local) as per RFC3339.
1913 | */
1914 | public static readonly string[] RFC3339LocalDateTimeFormats =
1915 | {
1916 | "yyyy'-'MM-ddTHH':'mm':'ss", "yyyy'-'MM-ddTHH':'mm':'ss'.'f", "yyyy'-'MM-ddTHH':'mm':'ss'.'ff",
1917 | "yyyy'-'MM-ddTHH':'mm':'ss'.'fff", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffff",
1918 | "yyyy'-'MM-ddTHH':'mm':'ss'.'fffff", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffff",
1919 | "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffff"
1920 | };
1921 |
1922 | /**
1923 | * Valid full date format as per TOML spec.
1924 | */
1925 | public static readonly string LocalDateFormat = "yyyy'-'MM'-'dd";
1926 |
1927 | /**
1928 | * Valid time formats as per TOML spec.
1929 | */
1930 | public static readonly string[] RFC3339LocalTimeFormats =
1931 | {
1932 | "HH':'mm':'ss", "HH':'mm':'ss'.'f", "HH':'mm':'ss'.'ff", "HH':'mm':'ss'.'fff", "HH':'mm':'ss'.'ffff",
1933 | "HH':'mm':'ss'.'fffff", "HH':'mm':'ss'.'ffffff", "HH':'mm':'ss'.'fffffff"
1934 | };
1935 |
1936 | #endregion
1937 |
1938 | #region Character definitions
1939 |
1940 | public const char ARRAY_END_SYMBOL = ']';
1941 | public const char ITEM_SEPARATOR = ',';
1942 | public const char ARRAY_START_SYMBOL = '[';
1943 | public const char BASIC_STRING_SYMBOL = '\"';
1944 | public const char COMMENT_SYMBOL = '#';
1945 | public const char ESCAPE_SYMBOL = '\\';
1946 | public const char KEY_VALUE_SEPARATOR = '=';
1947 | public const char NEWLINE_CARRIAGE_RETURN_CHARACTER = '\r';
1948 | public const char NEWLINE_CHARACTER = '\n';
1949 | public const char SUBKEY_SEPARATOR = '.';
1950 | public const char TABLE_END_SYMBOL = ']';
1951 | public const char TABLE_START_SYMBOL = '[';
1952 | public const char INLINE_TABLE_START_SYMBOL = '{';
1953 | public const char INLINE_TABLE_END_SYMBOL = '}';
1954 | public const char LITERAL_STRING_SYMBOL = '\'';
1955 | public const char INT_NUMBER_SEPARATOR = '_';
1956 |
1957 | public static readonly char[] NewLineCharacters = {NEWLINE_CHARACTER, NEWLINE_CARRIAGE_RETURN_CHARACTER};
1958 |
1959 | public static bool IsQuoted(char c) => c is BASIC_STRING_SYMBOL or LITERAL_STRING_SYMBOL;
1960 |
1961 | public static bool IsWhiteSpace(char c) => c is ' ' or '\t';
1962 |
1963 | public static bool IsNewLine(char c) => c is NEWLINE_CHARACTER or NEWLINE_CARRIAGE_RETURN_CHARACTER;
1964 |
1965 | public static bool IsLineBreak(char c) => c == NEWLINE_CHARACTER;
1966 |
1967 | public static bool IsEmptySpace(char c) => IsWhiteSpace(c) || IsNewLine(c);
1968 |
1969 | public static bool IsBareKey(char c) =>
1970 | c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' or '_' or '-';
1971 |
1972 | public static bool MustBeEscaped(char c, bool allowNewLines = false)
1973 | {
1974 | var result = c is (>= '\u0000' and <= '\u0008') or '\u000b' or '\u000c' or (>= '\u000e' and <= '\u001f') or '\u007f';
1975 | if (!allowNewLines)
1976 | result |= c is >= '\u000a' and <= '\u000e';
1977 | return result;
1978 | }
1979 |
1980 | public static bool IsValueSeparator(char c) =>
1981 | c is ITEM_SEPARATOR or ARRAY_END_SYMBOL or INLINE_TABLE_END_SYMBOL;
1982 |
1983 | #endregion
1984 | }
1985 |
1986 | internal static class StringUtils
1987 | {
1988 | public static string AsKey(this string key)
1989 | {
1990 | var quote = key == string.Empty || key.Any(c => !TomlSyntax.IsBareKey(c));
1991 | return !quote ? key : $"{TomlSyntax.BASIC_STRING_SYMBOL}{key.Escape()}{TomlSyntax.BASIC_STRING_SYMBOL}";
1992 | }
1993 |
1994 | public static string Join(this string self, IEnumerable subItems)
1995 | {
1996 | var sb = new StringBuilder();
1997 | var first = true;
1998 |
1999 | foreach (var subItem in subItems)
2000 | {
2001 | if (!first) sb.Append(self);
2002 | first = false;
2003 | sb.Append(subItem);
2004 | }
2005 |
2006 | return sb.ToString();
2007 | }
2008 |
2009 | public delegate bool TryDateParseDelegate(string s, string format, IFormatProvider ci, DateTimeStyles dts, out T dt);
2010 |
2011 | public static bool TryParseDateTime(string s,
2012 | string[] formats,
2013 | DateTimeStyles styles,
2014 | TryDateParseDelegate parser,
2015 | out T dateTime,
2016 | out int parsedFormat)
2017 | {
2018 | parsedFormat = 0;
2019 | dateTime = default;
2020 | for (var i = 0; i < formats.Length; i++)
2021 | {
2022 | var format = formats[i];
2023 | if (!parser(s, format, CultureInfo.InvariantCulture, styles, out dateTime)) continue;
2024 | parsedFormat = i;
2025 | return true;
2026 | }
2027 |
2028 | return false;
2029 | }
2030 |
2031 | public static void AsComment(this string self, TextWriter tw)
2032 | {
2033 | foreach (var line in self.Split(TomlSyntax.NEWLINE_CHARACTER))
2034 | tw.WriteLine($"{TomlSyntax.COMMENT_SYMBOL} {line.Trim()}");
2035 | }
2036 |
2037 | public static string RemoveAll(this string txt, char toRemove)
2038 | {
2039 | var sb = new StringBuilder(txt.Length);
2040 | foreach (var c in txt.Where(c => c != toRemove))
2041 | sb.Append(c);
2042 | return sb.ToString();
2043 | }
2044 |
2045 | public static string Escape(this string txt, bool escapeNewlines = true)
2046 | {
2047 | var stringBuilder = new StringBuilder(txt.Length + 2);
2048 | for (var i = 0; i < txt.Length; i++)
2049 | {
2050 | var c = txt[i];
2051 |
2052 | static string CodePoint(string txt, ref int i, char c) => char.IsSurrogatePair(txt, i)
2053 | ? $"\\U{char.ConvertToUtf32(txt, i++):X8}"
2054 | : $"\\u{(ushort) c:X4}";
2055 |
2056 | stringBuilder.Append(c switch
2057 | {
2058 | '\b' => @"\b",
2059 | '\t' => @"\t",
2060 | '\n' when escapeNewlines => @"\n",
2061 | '\f' => @"\f",
2062 | '\r' when escapeNewlines => @"\r",
2063 | '\\' => @"\\",
2064 | '\"' => @"\""",
2065 | var _ when TomlSyntax.MustBeEscaped(c, !escapeNewlines) || TOML.ForceASCII && c > sbyte.MaxValue =>
2066 | CodePoint(txt, ref i, c),
2067 | var _ => c
2068 | });
2069 | }
2070 |
2071 | return stringBuilder.ToString();
2072 | }
2073 |
2074 | public static bool TryUnescape(this string txt, out string unescaped, out Exception exception)
2075 | {
2076 | try
2077 | {
2078 | exception = null;
2079 | unescaped = txt.Unescape();
2080 | return true;
2081 | }
2082 | catch (Exception e)
2083 | {
2084 | exception = e;
2085 | unescaped = null;
2086 | return false;
2087 | }
2088 | }
2089 |
2090 | public static string Unescape(this string txt)
2091 | {
2092 | if (string.IsNullOrEmpty(txt)) return txt;
2093 | var stringBuilder = new StringBuilder(txt.Length);
2094 | for (var i = 0; i < txt.Length;)
2095 | {
2096 | var num = txt.IndexOf('\\', i);
2097 | var next = num + 1;
2098 | if (num < 0 || num == txt.Length - 1) num = txt.Length;
2099 | stringBuilder.Append(txt, i, num - i);
2100 | if (num >= txt.Length) break;
2101 | var c = txt[next];
2102 |
2103 | static string CodePoint(int next, string txt, ref int num, int size)
2104 | {
2105 | if (next + size >= txt.Length) throw new Exception("Undefined escape sequence!");
2106 | num += size;
2107 | return char.ConvertFromUtf32(Convert.ToInt32(txt.Substring(next + 1, size), 16));
2108 | }
2109 |
2110 | stringBuilder.Append(c switch
2111 | {
2112 | 'b' => "\b",
2113 | 't' => "\t",
2114 | 'n' => "\n",
2115 | 'f' => "\f",
2116 | 'r' => "\r",
2117 | '\'' => "\'",
2118 | '\"' => "\"",
2119 | '\\' => "\\",
2120 | 'u' => CodePoint(next, txt, ref num, 4),
2121 | 'U' => CodePoint(next, txt, ref num, 8),
2122 | var _ => throw new Exception("Undefined escape sequence!")
2123 | });
2124 | i = num + 2;
2125 | }
2126 |
2127 | return stringBuilder.ToString();
2128 | }
2129 | }
2130 |
2131 | #endregion
2132 | }
--------------------------------------------------------------------------------
/src/Echoes.SampleApp.Translations/Echoes.SampleApp.Translations.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net8.0
5 | enable
6 | enable
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/src/Echoes.SampleApp.Translations/Translations/Strings.toml:
--------------------------------------------------------------------------------
1 | [echoes_config]
2 | generated_class_name = "Strings"
3 | generated_namespace = "Echoes.SampleApp.Translations"
4 |
5 | [translations]
6 | hello_world = 'Hello World'
7 | greeting = 'Hello {0}, how are you?'
--------------------------------------------------------------------------------
/src/Echoes.SampleApp.Translations/Translations/Strings_de.toml:
--------------------------------------------------------------------------------
1 | hello_world = 'Hallo Welt'
2 | greeting = 'Hallo {0}, wie geht es dir?'
--------------------------------------------------------------------------------
/src/Echoes.SampleApp.Translations/Translations/Strings_zh.toml:
--------------------------------------------------------------------------------
1 | hello_world = '世界您好'
--------------------------------------------------------------------------------
/src/Echoes.SampleApp/App.axaml:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/src/Echoes.SampleApp/App.axaml.cs:
--------------------------------------------------------------------------------
1 | using Avalonia;
2 | using Avalonia.Controls.ApplicationLifetimes;
3 | using Avalonia.Markup.Xaml;
4 |
5 | namespace Echoes.SampleApp;
6 |
7 | public partial class App : Application
8 | {
9 | public override void Initialize()
10 | {
11 | AvaloniaXamlLoader.Load(this);
12 | }
13 |
14 | public override void OnFrameworkInitializationCompleted()
15 | {
16 | if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
17 | {
18 | desktop.MainWindow = new MainWindow
19 | {
20 | DataContext = new MainWindowViewModel(),
21 | };
22 | }
23 |
24 | base.OnFrameworkInitializationCompleted();
25 | }
26 | }
--------------------------------------------------------------------------------
/src/Echoes.SampleApp/Assets/avalonia-logo.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Voyonic-Systems/Echoes/c6b12de1f72869ca96683e3212d4598314507c80/src/Echoes.SampleApp/Assets/avalonia-logo.ico
--------------------------------------------------------------------------------
/src/Echoes.SampleApp/Echoes.SampleApp.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | WinExe
4 | net8.0
5 | enable
6 | true
7 | app.manifest
8 | true
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | MainWindow.axaml
32 | Code
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/src/Echoes.SampleApp/MainWindow.axaml:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
16 |
19 |
22 |
25 |
26 |
27 |
29 |
30 |
32 |
33 |
35 |
36 |
37 |
38 |
42 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/src/Echoes.SampleApp/MainWindow.axaml.cs:
--------------------------------------------------------------------------------
1 | using Avalonia.Controls;
2 |
3 | namespace Echoes.SampleApp;
4 |
5 | public partial class MainWindow : Window
6 | {
7 | public MainWindow()
8 | {
9 | InitializeComponent();
10 | DataContext = new MainWindowViewModel();
11 | }
12 | }
--------------------------------------------------------------------------------
/src/Echoes.SampleApp/MainWindowViewModel.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 | using System.ComponentModel;
3 | using System.Globalization;
4 | using System.Runtime.CompilerServices;
5 |
6 | namespace Echoes.SampleApp;
7 |
8 | public class MainWindowViewModel : INotifyPropertyChanged
9 | {
10 | private string _name;
11 |
12 | public string Name
13 | {
14 | get => _name;
15 | set => SetField(ref _name, value);
16 | }
17 |
18 | public void SetCultureCommand(object parameter)
19 | {
20 | switch (parameter)
21 | {
22 | case "english":
23 | TranslationProvider.SetCulture(CultureInfo.GetCultureInfo("en-US"));
24 | break;
25 |
26 | case "german":
27 | TranslationProvider.SetCulture(CultureInfo.GetCultureInfo("de-DE"));
28 | break;
29 |
30 | case "chinese":
31 | TranslationProvider.SetCulture(CultureInfo.GetCultureInfo("zh-CN"));
32 | break;
33 | }
34 | }
35 |
36 | public event PropertyChangedEventHandler? PropertyChanged;
37 |
38 | protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
39 | {
40 | PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
41 | }
42 |
43 | protected bool SetField(ref T field, T value, [CallerMemberName] string? propertyName = null)
44 | {
45 | if (EqualityComparer.Default.Equals(field, value)) return false;
46 | field = value;
47 | OnPropertyChanged(propertyName);
48 | return true;
49 | }
50 | }
--------------------------------------------------------------------------------
/src/Echoes.SampleApp/Program.cs:
--------------------------------------------------------------------------------
1 | using Avalonia;
2 | using System;
3 |
4 | namespace Echoes.SampleApp;
5 |
6 | sealed class Program
7 | {
8 | // Initialization code. Don't use any Avalonia, third-party APIs or any
9 | // SynchronizationContext-reliant code before AppMain is called: things aren't initialized
10 | // yet and stuff might break.
11 | [STAThread]
12 | public static void Main(string[] args) => BuildAvaloniaApp()
13 | .StartWithClassicDesktopLifetime(args);
14 |
15 | // Avalonia configuration, don't remove; also used by visual designer.
16 | public static AppBuilder BuildAvaloniaApp()
17 | => AppBuilder.Configure()
18 | .UsePlatformDetect()
19 | .WithInterFont()
20 | .LogToTrace();
21 | }
--------------------------------------------------------------------------------
/src/Echoes.SampleApp/app.manifest:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
8 |
9 |
10 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/src/Echoes.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Echoes", "Echoes\Echoes.csproj", "{763648F3-CF4D-4052-A2B0-855449ACF68A}"
4 | EndProject
5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Echoes.SampleApp", "Echoes.SampleApp\Echoes.SampleApp.csproj", "{4DAC7EC9-4AB6-4C19-A693-178BF1096301}"
6 | EndProject
7 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Echoes.Generator", "Echoes.Generator\Echoes.Generator.csproj", "{1BF2FFA0-990E-4ABA-BDDE-DFC689076D5A}"
8 | EndProject
9 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Echoes.Generator.Tests", "Echoes.Generator.Tests\Echoes.Generator.Tests.csproj", "{C8261FBE-3393-4CA7-B1F2-AC7266828DF2}"
10 | EndProject
11 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Echoes.SampleApp.Translations", "Echoes.SampleApp.Translations\Echoes.SampleApp.Translations.csproj", "{6F12EF5D-EC8B-47B6-A545-884E85827103}"
12 | EndProject
13 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "misc", "misc", "{DCBA01BE-BC2F-4101-B800-953450C70B45}"
14 | ProjectSection(SolutionItems) = preProject
15 | global.json = global.json
16 | Directory.Build.props = Directory.Build.props
17 | ..\README.md = ..\README.md
18 | EndProjectSection
19 | EndProject
20 | Global
21 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
22 | Debug|Any CPU = Debug|Any CPU
23 | Release|Any CPU = Release|Any CPU
24 | EndGlobalSection
25 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
26 | {763648F3-CF4D-4052-A2B0-855449ACF68A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
27 | {763648F3-CF4D-4052-A2B0-855449ACF68A}.Debug|Any CPU.Build.0 = Debug|Any CPU
28 | {763648F3-CF4D-4052-A2B0-855449ACF68A}.Release|Any CPU.ActiveCfg = Release|Any CPU
29 | {763648F3-CF4D-4052-A2B0-855449ACF68A}.Release|Any CPU.Build.0 = Release|Any CPU
30 | {4DAC7EC9-4AB6-4C19-A693-178BF1096301}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
31 | {4DAC7EC9-4AB6-4C19-A693-178BF1096301}.Debug|Any CPU.Build.0 = Debug|Any CPU
32 | {4DAC7EC9-4AB6-4C19-A693-178BF1096301}.Release|Any CPU.ActiveCfg = Release|Any CPU
33 | {4DAC7EC9-4AB6-4C19-A693-178BF1096301}.Release|Any CPU.Build.0 = Release|Any CPU
34 | {1BF2FFA0-990E-4ABA-BDDE-DFC689076D5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
35 | {1BF2FFA0-990E-4ABA-BDDE-DFC689076D5A}.Debug|Any CPU.Build.0 = Debug|Any CPU
36 | {1BF2FFA0-990E-4ABA-BDDE-DFC689076D5A}.Release|Any CPU.ActiveCfg = Release|Any CPU
37 | {1BF2FFA0-990E-4ABA-BDDE-DFC689076D5A}.Release|Any CPU.Build.0 = Release|Any CPU
38 | {C8261FBE-3393-4CA7-B1F2-AC7266828DF2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
39 | {C8261FBE-3393-4CA7-B1F2-AC7266828DF2}.Debug|Any CPU.Build.0 = Debug|Any CPU
40 | {C8261FBE-3393-4CA7-B1F2-AC7266828DF2}.Release|Any CPU.ActiveCfg = Release|Any CPU
41 | {C8261FBE-3393-4CA7-B1F2-AC7266828DF2}.Release|Any CPU.Build.0 = Release|Any CPU
42 | {6F12EF5D-EC8B-47B6-A545-884E85827103}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
43 | {6F12EF5D-EC8B-47B6-A545-884E85827103}.Debug|Any CPU.Build.0 = Debug|Any CPU
44 | {6F12EF5D-EC8B-47B6-A545-884E85827103}.Release|Any CPU.ActiveCfg = Release|Any CPU
45 | {6F12EF5D-EC8B-47B6-A545-884E85827103}.Release|Any CPU.Build.0 = Release|Any CPU
46 | EndGlobalSection
47 | EndGlobal
48 |
--------------------------------------------------------------------------------
/src/Echoes/Echoes.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net8.0
5 | enable
6 |
7 |
8 | $(EchoesVersion)
9 | $(EchoesVersion)
10 | Voyonic Systems GmbH
11 | icon.png
12 | Echoes
13 | Echoes
14 | Echoes
15 | Simple type safe translations for Avalonia
16 | MIT
17 | https://github.com/Voyonic-Systems/Echoes
18 | true
19 | true
20 | true
21 |
22 | $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb
23 |
24 | README.md
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/src/Echoes/FileTranslationProvider.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Immutable;
3 | using System.Globalization;
4 | using System.IO;
5 | using System.Linq;
6 | using System.Reflection;
7 | using Tommy;
8 |
9 | namespace Echoes;
10 |
11 | public class FileTranslationProvider
12 | {
13 | private readonly string _embeddedResourceKey;
14 | private readonly Assembly _assembly;
15 |
16 | private readonly ImmutableDictionary _invariantTranslations;
17 | private (CultureInfo Culture, ImmutableDictionary Lookup)? _translations;
18 |
19 | public FileTranslationProvider(Assembly assembly, string embeddedResourceKey)
20 | {
21 | _embeddedResourceKey = embeddedResourceKey;
22 | _assembly = assembly;
23 |
24 | _invariantTranslations =
25 | ReadResource(assembly, embeddedResourceKey)
26 | ?? throw new Exception("Embedded resource could not be found. ");
27 |
28 | _translations = null;
29 | }
30 |
31 | public string? ReadTranslation(string key, CultureInfo culture)
32 | {
33 | if (culture == null)
34 | throw new ArgumentNullException(nameof(culture));
35 |
36 | var lookup = _translations?.Lookup;
37 | var lookupCulture = _translations?.Culture;
38 |
39 | if (lookup == null || (!lookupCulture?.Equals(culture) ?? false))
40 | {
41 | var fileName = Path.GetFileNameWithoutExtension(_embeddedResourceKey);
42 | var fullName = fileName + "_" + culture.TwoLetterISOLanguageName + ".toml";
43 |
44 | var fullMatch = ReadResource(_assembly, fullName) ?? ImmutableDictionary.Empty;
45 | _translations = (culture, fullMatch);
46 |
47 | lookup = fullMatch;
48 | }
49 |
50 | if (lookup!.TryGetValue(key, out var result))
51 | {
52 | return result;
53 | }
54 | else if (_invariantTranslations.TryGetValue(key, out var invariantResult))
55 | {
56 | return invariantResult;
57 | }
58 | else
59 | {
60 | return null;
61 | }
62 | }
63 |
64 | private static ImmutableDictionary? ReadResource(Assembly assembly, string file)
65 | {
66 | var resourceNames = assembly.GetManifestResourceNames();
67 |
68 | var resourcePath =
69 | resourceNames
70 | .FirstOrDefault(str => str.EndsWith(file.Replace("/", ".").Replace(@"\", "."), StringComparison.OrdinalIgnoreCase));
71 |
72 | if (resourcePath == null)
73 | return null;
74 |
75 | using var stream = assembly.GetManifestResourceStream(resourcePath);
76 |
77 | if (stream == null)
78 | return null;
79 |
80 | using var reader = new StreamReader(stream);
81 |
82 | var root = TOML.Parse(reader);
83 |
84 | if (root.RawTable.TryGetValue("translations", out var translations))
85 | {
86 | var immutableDict = ImmutableDictionary.CreateBuilder();
87 |
88 | foreach (var pair in translations.AsTable.RawTable)
89 | {
90 | if (pair.Value.IsString)
91 | {
92 | immutableDict.Add(pair.Key, pair.Value.AsString);
93 | }
94 | }
95 |
96 | return immutableDict.ToImmutable();
97 | }
98 | else
99 | {
100 | var immutableDict = ImmutableDictionary.CreateBuilder();
101 |
102 | foreach (var pair in root.RawTable)
103 | {
104 | if (pair.Value.IsString)
105 | {
106 | immutableDict.Add(pair.Key, pair.Value.AsString);
107 | }
108 | }
109 |
110 | return immutableDict.ToImmutable();
111 | }
112 | }
113 | }
--------------------------------------------------------------------------------
/src/Echoes/Lib/Tommy.cs:
--------------------------------------------------------------------------------
1 | #region LICENSE
2 |
3 | /*
4 | * MIT License
5 | *
6 | * Copyright (c) 2020 Denis Zhidkikh
7 | *
8 | * Permission is hereby granted, free of charge, to any person obtaining a copy
9 | * of this software and associated documentation files (the "Software"), to deal
10 | * in the Software without restriction, including without limitation the rights
11 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12 | * copies of the Software, and to permit persons to whom the Software is
13 | * furnished to do so, subject to the following conditions:
14 | *
15 | * The above copyright notice and this permission notice shall be included in all
16 | * copies or substantial portions of the Software.
17 | *
18 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24 | * SOFTWARE.
25 | */
26 |
27 | #endregion
28 |
29 | using System;
30 | using System.Collections;
31 | using System.Collections.Generic;
32 | using System.Globalization;
33 | using System.IO;
34 | using System.Linq;
35 | using System.Text;
36 | using System.Text.RegularExpressions;
37 |
38 | namespace Tommy
39 | {
40 | #region TOML Nodes
41 |
42 | public abstract class TomlNode : IEnumerable
43 | {
44 | public virtual bool HasValue { get; } = false;
45 | public virtual bool IsArray { get; } = false;
46 | public virtual bool IsTable { get; } = false;
47 | public virtual bool IsString { get; } = false;
48 | public virtual bool IsInteger { get; } = false;
49 | public virtual bool IsFloat { get; } = false;
50 | public bool IsDateTime => IsDateTimeLocal || IsDateTimeOffset;
51 | public virtual bool IsDateTimeLocal { get; } = false;
52 | public virtual bool IsDateTimeOffset { get; } = false;
53 | public virtual bool IsBoolean { get; } = false;
54 | public virtual string Comment { get; set; }
55 | public virtual int CollapseLevel { get; set; }
56 |
57 | public virtual TomlTable AsTable => this as TomlTable;
58 | public virtual TomlString AsString => this as TomlString;
59 | public virtual TomlInteger AsInteger => this as TomlInteger;
60 | public virtual TomlFloat AsFloat => this as TomlFloat;
61 | public virtual TomlBoolean AsBoolean => this as TomlBoolean;
62 | public virtual TomlDateTimeLocal AsDateTimeLocal => this as TomlDateTimeLocal;
63 | public virtual TomlDateTimeOffset AsDateTimeOffset => this as TomlDateTimeOffset;
64 | public virtual TomlDateTime AsDateTime => this as TomlDateTime;
65 | public virtual TomlArray AsArray => this as TomlArray;
66 |
67 | public virtual int ChildrenCount => 0;
68 |
69 | public virtual TomlNode this[string key]
70 | {
71 | get => null;
72 | set { }
73 | }
74 |
75 | public virtual TomlNode this[int index]
76 | {
77 | get => null;
78 | set { }
79 | }
80 |
81 | public virtual IEnumerable Children
82 | {
83 | get { yield break; }
84 | }
85 |
86 | public virtual IEnumerable Keys
87 | {
88 | get { yield break; }
89 | }
90 |
91 | public IEnumerator GetEnumerator() => Children.GetEnumerator();
92 |
93 | public virtual bool TryGetNode(string key, out TomlNode node)
94 | {
95 | node = null;
96 | return false;
97 | }
98 |
99 | public virtual bool HasKey(string key) => false;
100 |
101 | public virtual bool HasItemAt(int index) => false;
102 |
103 | public virtual void Add(string key, TomlNode node) { }
104 |
105 | public virtual void Add(TomlNode node) { }
106 |
107 | public virtual void Delete(TomlNode node) { }
108 |
109 | public virtual void Delete(string key) { }
110 |
111 | public virtual void Delete(int index) { }
112 |
113 | public virtual void AddRange(IEnumerable nodes)
114 | {
115 | foreach (var tomlNode in nodes) Add(tomlNode);
116 | }
117 |
118 | public virtual void WriteTo(TextWriter tw, string name = null) => tw.WriteLine(ToInlineToml());
119 |
120 | public virtual string ToInlineToml() => ToString();
121 |
122 | #region Native type to TOML cast
123 |
124 | public static implicit operator TomlNode(string value) => new TomlString {Value = value};
125 |
126 | public static implicit operator TomlNode(bool value) => new TomlBoolean {Value = value};
127 |
128 | public static implicit operator TomlNode(long value) => new TomlInteger {Value = value};
129 |
130 | public static implicit operator TomlNode(float value) => new TomlFloat {Value = value};
131 |
132 | public static implicit operator TomlNode(double value) => new TomlFloat {Value = value};
133 |
134 | public static implicit operator TomlNode(DateTime value) => new TomlDateTimeLocal {Value = value};
135 |
136 | public static implicit operator TomlNode(DateTimeOffset value) => new TomlDateTimeOffset {Value = value};
137 |
138 | public static implicit operator TomlNode(TomlNode[] nodes)
139 | {
140 | var result = new TomlArray();
141 | result.AddRange(nodes);
142 | return result;
143 | }
144 |
145 | #endregion
146 |
147 | #region TOML to native type cast
148 |
149 | public static implicit operator string(TomlNode value) => value.ToString();
150 |
151 | public static implicit operator int(TomlNode value) => (int) value.AsInteger.Value;
152 |
153 | public static implicit operator long(TomlNode value) => value.AsInteger.Value;
154 |
155 | public static implicit operator float(TomlNode value) => (float) value.AsFloat.Value;
156 |
157 | public static implicit operator double(TomlNode value) => value.AsFloat.Value;
158 |
159 | public static implicit operator bool(TomlNode value) => value.AsBoolean.Value;
160 |
161 | public static implicit operator DateTime(TomlNode value) => value.AsDateTimeLocal.Value;
162 |
163 | public static implicit operator DateTimeOffset(TomlNode value) => value.AsDateTimeOffset.Value;
164 |
165 | #endregion
166 | }
167 |
168 | public class TomlString : TomlNode
169 | {
170 | public override bool HasValue { get; } = true;
171 | public override bool IsString { get; } = true;
172 | public bool IsMultiline { get; set; }
173 | public bool MultilineTrimFirstLine { get; set; }
174 | public bool PreferLiteral { get; set; }
175 |
176 | public string Value { get; set; }
177 |
178 | public override string ToString() => Value;
179 |
180 | public override string ToInlineToml()
181 | {
182 | var newLine = @"
183 | ";
184 | // Automatically convert literal to non-literal if there are too many literal string symbols
185 | if (Value.IndexOf(new string(TomlSyntax.LITERAL_STRING_SYMBOL, IsMultiline ? 3 : 1), StringComparison.Ordinal) != -1 && PreferLiteral) PreferLiteral = false;
186 | var quotes = new string(PreferLiteral ? TomlSyntax.LITERAL_STRING_SYMBOL : TomlSyntax.BASIC_STRING_SYMBOL,
187 | IsMultiline ? 3 : 1);
188 | var result = PreferLiteral ? Value : Value.Escape(!IsMultiline);
189 | if (IsMultiline)
190 | result = result.Replace("\r\n", "\n").Replace("\n", newLine);
191 | if (IsMultiline && (MultilineTrimFirstLine || !MultilineTrimFirstLine && result.StartsWith(newLine)))
192 | result = $"{newLine}{result}";
193 | return $"{quotes}{result}{quotes}";
194 | }
195 | }
196 |
197 | public class TomlInteger : TomlNode
198 | {
199 | public enum Base
200 | {
201 | Binary = 2,
202 | Octal = 8,
203 | Decimal = 10,
204 | Hexadecimal = 16
205 | }
206 |
207 | public override bool IsInteger { get; } = true;
208 | public override bool HasValue { get; } = true;
209 | public Base IntegerBase { get; set; } = Base.Decimal;
210 |
211 | public long Value { get; set; }
212 |
213 | public override string ToString() => Value.ToString();
214 |
215 | public override string ToInlineToml() =>
216 | IntegerBase != Base.Decimal
217 | ? $"0{TomlSyntax.BaseIdentifiers[(int) IntegerBase]}{Convert.ToString(Value, (int) IntegerBase)}"
218 | : Value.ToString(CultureInfo.InvariantCulture);
219 | }
220 |
221 | public class TomlFloat : TomlNode, IFormattable
222 | {
223 | public override bool IsFloat { get; } = true;
224 | public override bool HasValue { get; } = true;
225 |
226 | public double Value { get; set; }
227 |
228 | public override string ToString() => Value.ToString(CultureInfo.InvariantCulture);
229 |
230 | public string ToString(string format, IFormatProvider formatProvider) => Value.ToString(format, formatProvider);
231 |
232 | public string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider);
233 |
234 | public override string ToInlineToml() =>
235 | Value switch
236 | {
237 | var v when double.IsNaN(v) => TomlSyntax.NAN_VALUE,
238 | var v when double.IsPositiveInfinity(v) => TomlSyntax.INF_VALUE,
239 | var v when double.IsNegativeInfinity(v) => TomlSyntax.NEG_INF_VALUE,
240 | var v => v.ToString("G", CultureInfo.InvariantCulture).ToLowerInvariant()
241 | };
242 | }
243 |
244 | public class TomlBoolean : TomlNode
245 | {
246 | public override bool IsBoolean { get; } = true;
247 | public override bool HasValue { get; } = true;
248 |
249 | public bool Value { get; set; }
250 |
251 | public override string ToString() => Value.ToString();
252 |
253 | public override string ToInlineToml() => Value ? TomlSyntax.TRUE_VALUE : TomlSyntax.FALSE_VALUE;
254 | }
255 |
256 | public class TomlDateTime : TomlNode, IFormattable
257 | {
258 | public int SecondsPrecision { get; set; }
259 | public override bool HasValue { get; } = true;
260 | public virtual string ToString(string format, IFormatProvider formatProvider) => string.Empty;
261 | public virtual string ToString(IFormatProvider formatProvider) => string.Empty;
262 | protected virtual string ToInlineTomlInternal() => string.Empty;
263 |
264 | public override string ToInlineToml() => ToInlineTomlInternal()
265 | .Replace(TomlSyntax.RFC3339EmptySeparator, TomlSyntax.ISO861Separator)
266 | .Replace(TomlSyntax.ISO861ZeroZone, TomlSyntax.RFC3339ZeroZone);
267 | }
268 |
269 | public class TomlDateTimeOffset : TomlDateTime
270 | {
271 | public override bool IsDateTimeOffset { get; } = true;
272 | public DateTimeOffset Value { get; set; }
273 |
274 | public override string ToString() => Value.ToString(CultureInfo.InvariantCulture);
275 | public override string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider);
276 |
277 | public override string ToString(string format, IFormatProvider formatProvider) =>
278 | Value.ToString(format, formatProvider);
279 |
280 | protected override string ToInlineTomlInternal() => Value.ToString(TomlSyntax.RFC3339Formats[SecondsPrecision]);
281 | }
282 |
283 | public class TomlDateTimeLocal : TomlDateTime
284 | {
285 | public enum DateTimeStyle
286 | {
287 | Date,
288 | Time,
289 | DateTime
290 | }
291 |
292 | public override bool IsDateTimeLocal { get; } = true;
293 | public DateTimeStyle Style { get; set; } = DateTimeStyle.DateTime;
294 | public DateTime Value { get; set; }
295 |
296 | public override string ToString() => Value.ToString(CultureInfo.InvariantCulture);
297 |
298 | public override string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider);
299 |
300 | public override string ToString(string format, IFormatProvider formatProvider) =>
301 | Value.ToString(format, formatProvider);
302 |
303 | public override string ToInlineToml() =>
304 | Style switch
305 | {
306 | DateTimeStyle.Date => Value.ToString(TomlSyntax.LocalDateFormat),
307 | DateTimeStyle.Time => Value.ToString(TomlSyntax.RFC3339LocalTimeFormats[SecondsPrecision]),
308 | var _ => Value.ToString(TomlSyntax.RFC3339LocalDateTimeFormats[SecondsPrecision])
309 | };
310 | }
311 |
312 | public class TomlArray : TomlNode
313 | {
314 | private List values;
315 |
316 | public override bool HasValue { get; } = true;
317 | public override bool IsArray { get; } = true;
318 | public bool IsMultiline { get; set; }
319 | public bool IsTableArray { get; set; }
320 | public List RawArray => values ??= new List();
321 |
322 | public override TomlNode this[int index]
323 | {
324 | get
325 | {
326 | if (index < RawArray.Count) return RawArray[index];
327 | var lazy = new TomlLazy(this);
328 | this[index] = lazy;
329 | return lazy;
330 | }
331 | set
332 | {
333 | if (index == RawArray.Count)
334 | RawArray.Add(value);
335 | else
336 | RawArray[index] = value;
337 | }
338 | }
339 |
340 | public override int ChildrenCount => RawArray.Count;
341 |
342 | public override IEnumerable Children => RawArray.AsEnumerable();
343 |
344 | public override void Add(TomlNode node) => RawArray.Add(node);
345 |
346 | public override void AddRange(IEnumerable nodes) => RawArray.AddRange(nodes);
347 |
348 | public override void Delete(TomlNode node) => RawArray.Remove(node);
349 |
350 | public override void Delete(int index) => RawArray.RemoveAt(index);
351 |
352 | public override string ToString() => ToString(false);
353 |
354 | public string ToString(bool multiline)
355 | {
356 | var sb = new StringBuilder();
357 | sb.Append(TomlSyntax.ARRAY_START_SYMBOL);
358 | if (ChildrenCount != 0)
359 | {
360 | var newLine = @"
361 | ";
362 |
363 | var arrayStart = multiline ? $"{newLine} " : " ";
364 | var arraySeparator = multiline ? $"{TomlSyntax.ITEM_SEPARATOR}{newLine} " : $"{TomlSyntax.ITEM_SEPARATOR} ";
365 | var arrayEnd = multiline ? newLine : " ";
366 | sb.Append(arrayStart)
367 | .Append(arraySeparator.Join(RawArray.Select(n => n.ToInlineToml())))
368 | .Append(arrayEnd);
369 | }
370 | sb.Append(TomlSyntax.ARRAY_END_SYMBOL);
371 | return sb.ToString();
372 | }
373 |
374 | public override void WriteTo(TextWriter tw, string name = null)
375 | {
376 | // If it's a normal array, write it as usual
377 | if (!IsTableArray)
378 | {
379 | tw.WriteLine(ToString(IsMultiline));
380 | return;
381 | }
382 |
383 | if (Comment is not null)
384 | {
385 | tw.WriteLine();
386 | Comment.AsComment(tw);
387 | }
388 | tw.Write(TomlSyntax.ARRAY_START_SYMBOL);
389 | tw.Write(TomlSyntax.ARRAY_START_SYMBOL);
390 | tw.Write(name);
391 | tw.Write(TomlSyntax.ARRAY_END_SYMBOL);
392 | tw.Write(TomlSyntax.ARRAY_END_SYMBOL);
393 | tw.WriteLine();
394 |
395 | var first = true;
396 |
397 | foreach (var tomlNode in RawArray)
398 | {
399 | if (tomlNode is not TomlTable tbl)
400 | throw new TomlFormatException("The array is marked as array table but contains non-table nodes!");
401 |
402 | // Ensure it's parsed as a section
403 | tbl.IsInline = false;
404 |
405 | if (!first)
406 | {
407 | tw.WriteLine();
408 |
409 | Comment?.AsComment(tw);
410 | tw.Write(TomlSyntax.ARRAY_START_SYMBOL);
411 | tw.Write(TomlSyntax.ARRAY_START_SYMBOL);
412 | tw.Write(name);
413 | tw.Write(TomlSyntax.ARRAY_END_SYMBOL);
414 | tw.Write(TomlSyntax.ARRAY_END_SYMBOL);
415 | tw.WriteLine();
416 | }
417 |
418 | first = false;
419 |
420 | // Don't write section since it's already written here
421 | tbl.WriteTo(tw, name, false);
422 | }
423 | }
424 | }
425 |
426 | public class TomlTable : TomlNode
427 | {
428 | private Dictionary children;
429 | internal bool isImplicit;
430 |
431 | public override bool HasValue { get; } = false;
432 | public override bool IsTable { get; } = true;
433 | public bool IsInline { get; set; }
434 | public Dictionary RawTable => children ??= new Dictionary();
435 |
436 | public override TomlNode this[string key]
437 | {
438 | get
439 | {
440 | if (RawTable.TryGetValue(key, out var result)) return result;
441 | var lazy = new TomlLazy(this);
442 | RawTable[key] = lazy;
443 | return lazy;
444 | }
445 | set => RawTable[key] = value;
446 | }
447 |
448 | public override int ChildrenCount => RawTable.Count;
449 | public override IEnumerable Children => RawTable.Select(kv => kv.Value);
450 | public override IEnumerable Keys => RawTable.Select(kv => kv.Key);
451 | public override bool HasKey(string key) => RawTable.ContainsKey(key);
452 | public override void Add(string key, TomlNode node) => RawTable.Add(key, node);
453 | public override bool TryGetNode(string key, out TomlNode node) => RawTable.TryGetValue(key, out node);
454 | public override void Delete(TomlNode node) => RawTable.Remove(RawTable.First(kv => kv.Value == node).Key);
455 | public override void Delete(string key) => RawTable.Remove(key);
456 |
457 | public override string ToString()
458 | {
459 | var sb = new StringBuilder();
460 | sb.Append(TomlSyntax.INLINE_TABLE_START_SYMBOL);
461 |
462 | if (ChildrenCount != 0)
463 | {
464 | var collapsed = CollectCollapsedItems(normalizeOrder: false);
465 |
466 | if (collapsed.Count != 0)
467 | sb.Append(' ')
468 | .Append($"{TomlSyntax.ITEM_SEPARATOR} ".Join(collapsed.Select(n =>
469 | $"{n.Key} {TomlSyntax.KEY_VALUE_SEPARATOR} {n.Value.ToInlineToml()}")));
470 | sb.Append(' ');
471 | }
472 |
473 | sb.Append(TomlSyntax.INLINE_TABLE_END_SYMBOL);
474 | return sb.ToString();
475 | }
476 |
477 | private LinkedList> CollectCollapsedItems(string prefix = "", int level = 0, bool normalizeOrder = true)
478 | {
479 | var nodes = new LinkedList>();
480 | var postNodes = normalizeOrder ? new LinkedList>() : nodes;
481 |
482 | foreach (var keyValuePair in RawTable)
483 | {
484 | var node = keyValuePair.Value;
485 | var key = keyValuePair.Key.AsKey();
486 |
487 | if (node is TomlTable tbl)
488 | {
489 | var subnodes = tbl.CollectCollapsedItems($"{prefix}{key}.", level + 1, normalizeOrder);
490 | // Write main table first before writing collapsed items
491 | if (subnodes.Count == 0 && node.CollapseLevel == level)
492 | {
493 | postNodes.AddLast(new KeyValuePair($"{prefix}{key}", node));
494 | }
495 | foreach (var kv in subnodes)
496 | postNodes.AddLast(kv);
497 | }
498 | else if (node.CollapseLevel == level)
499 | nodes.AddLast(new KeyValuePair($"{prefix}{key}", node));
500 | }
501 |
502 | if (normalizeOrder)
503 | foreach (var kv in postNodes)
504 | nodes.AddLast(kv);
505 |
506 | return nodes;
507 | }
508 |
509 | public override void WriteTo(TextWriter tw, string name = null) => WriteTo(tw, name, true);
510 |
511 | internal void WriteTo(TextWriter tw, string name, bool writeSectionName)
512 | {
513 | // The table is inline table
514 | if (IsInline && name != null)
515 | {
516 | tw.WriteLine(ToInlineToml());
517 | return;
518 | }
519 |
520 | var collapsedItems = CollectCollapsedItems();
521 |
522 | if (collapsedItems.Count == 0)
523 | return;
524 |
525 | var hasRealValues = !collapsedItems.All(n => n.Value is TomlTable {IsInline: false} or TomlArray {IsTableArray: true});
526 |
527 | Comment?.AsComment(tw);
528 |
529 | if (name != null && (hasRealValues || Comment != null) && writeSectionName)
530 | {
531 | tw.Write(TomlSyntax.ARRAY_START_SYMBOL);
532 | tw.Write(name);
533 | tw.Write(TomlSyntax.ARRAY_END_SYMBOL);
534 | tw.WriteLine();
535 | }
536 | else if (Comment != null) // Add some spacing between the first node and the comment
537 | {
538 | tw.WriteLine();
539 | }
540 |
541 | var namePrefix = name == null ? "" : $"{name}.";
542 | var first = true;
543 |
544 | foreach (var collapsedItem in collapsedItems)
545 | {
546 | var key = collapsedItem.Key;
547 | if (collapsedItem.Value is TomlArray {IsTableArray: true} or TomlTable {IsInline: false})
548 | {
549 | if (!first) tw.WriteLine();
550 | first = false;
551 | collapsedItem.Value.WriteTo(tw, $"{namePrefix}{key}");
552 | continue;
553 | }
554 | first = false;
555 |
556 | collapsedItem.Value.Comment?.AsComment(tw);
557 | tw.Write(key);
558 | tw.Write(' ');
559 | tw.Write(TomlSyntax.KEY_VALUE_SEPARATOR);
560 | tw.Write(' ');
561 |
562 | collapsedItem.Value.WriteTo(tw, $"{namePrefix}{key}");
563 | }
564 | }
565 | }
566 |
567 | internal class TomlLazy : TomlNode
568 | {
569 | private readonly TomlNode parent;
570 | private TomlNode replacement;
571 |
572 | public TomlLazy(TomlNode parent) => this.parent = parent;
573 |
574 | public override TomlNode this[int index]
575 | {
576 | get => Set()[index];
577 | set => Set()[index] = value;
578 | }
579 |
580 | public override TomlNode this[string key]
581 | {
582 | get => Set()[key];
583 | set => Set()[key] = value;
584 | }
585 |
586 | public override void Add(TomlNode node) => Set().Add(node);
587 |
588 | public override void Add(string key, TomlNode node) => Set().Add(key, node);
589 |
590 | public override void AddRange(IEnumerable nodes) => Set().AddRange(nodes);
591 |
592 | private TomlNode Set() where T : TomlNode, new()
593 | {
594 | if (replacement != null) return replacement;
595 |
596 | var newNode = new T
597 | {
598 | Comment = Comment
599 | };
600 |
601 | if (parent.IsTable)
602 | {
603 | var key = parent.Keys.FirstOrDefault(s => parent.TryGetNode(s, out var node) && node.Equals(this));
604 | if (key == null) return default(T);
605 |
606 | parent[key] = newNode;
607 | }
608 | else if (parent.IsArray)
609 | {
610 | var index = parent.Children.TakeWhile(child => child != this).Count();
611 | if (index == parent.ChildrenCount) return default(T);
612 | parent[index] = newNode;
613 | }
614 | else
615 | {
616 | return default(T);
617 | }
618 |
619 | replacement = newNode;
620 | return newNode;
621 | }
622 | }
623 |
624 | #endregion
625 |
626 | #region Parser
627 |
628 | public class TOMLParser : IDisposable
629 | {
630 | public enum ParseState
631 | {
632 | None,
633 | KeyValuePair,
634 | SkipToNextLine,
635 | Table
636 | }
637 |
638 | private readonly TextReader reader;
639 | private ParseState currentState;
640 | private int line, col;
641 | private List syntaxErrors;
642 |
643 | public TOMLParser(TextReader reader)
644 | {
645 | this.reader = reader;
646 | line = col = 0;
647 | }
648 |
649 | public bool ForceASCII { get; set; }
650 |
651 | public void Dispose() => reader?.Dispose();
652 |
653 | public TomlTable Parse()
654 | {
655 | syntaxErrors = new List();
656 | line = col = 1;
657 | var rootNode = new TomlTable();
658 | var currentNode = rootNode;
659 | currentState = ParseState.None;
660 | var keyParts = new List();
661 | var arrayTable = false;
662 | StringBuilder latestComment = null;
663 | var firstComment = true;
664 |
665 | int currentChar;
666 | while ((currentChar = reader.Peek()) >= 0)
667 | {
668 | var c = (char) currentChar;
669 |
670 | if (currentState == ParseState.None)
671 | {
672 | // Skip white space
673 | if (TomlSyntax.IsWhiteSpace(c)) goto consume_character;
674 |
675 | if (TomlSyntax.IsNewLine(c))
676 | {
677 | // Check if there are any comments and so far no items being declared
678 | if (latestComment != null && firstComment)
679 | {
680 | rootNode.Comment = latestComment.ToString().TrimEnd();
681 | latestComment = null;
682 | firstComment = false;
683 | }
684 |
685 | if (TomlSyntax.IsLineBreak(c))
686 | AdvanceLine();
687 |
688 | goto consume_character;
689 | }
690 |
691 | // Start of a comment; ignore until newline
692 | if (c == TomlSyntax.COMMENT_SYMBOL)
693 | {
694 | latestComment ??= new StringBuilder();
695 | latestComment.AppendLine(ParseComment());
696 | AdvanceLine(1);
697 | continue;
698 | }
699 |
700 | // Encountered a non-comment value. The comment must belong to it (ignore possible newlines)!
701 | firstComment = false;
702 |
703 | if (c == TomlSyntax.TABLE_START_SYMBOL)
704 | {
705 | currentState = ParseState.Table;
706 | goto consume_character;
707 | }
708 |
709 | if (TomlSyntax.IsBareKey(c) || TomlSyntax.IsQuoted(c))
710 | {
711 | currentState = ParseState.KeyValuePair;
712 | }
713 | else
714 | {
715 | AddError($"Unexpected character \"{c}\"");
716 | continue;
717 | }
718 | }
719 |
720 | if (currentState == ParseState.KeyValuePair)
721 | {
722 | var keyValuePair = ReadKeyValuePair(keyParts);
723 |
724 | if (keyValuePair == null)
725 | {
726 | latestComment = null;
727 | keyParts.Clear();
728 |
729 | if (currentState != ParseState.None)
730 | AddError("Failed to parse key-value pair!");
731 | continue;
732 | }
733 |
734 | keyValuePair.Comment = latestComment?.ToString()?.TrimEnd();
735 | var inserted = InsertNode(keyValuePair, currentNode, keyParts);
736 | latestComment = null;
737 | keyParts.Clear();
738 | if (inserted)
739 | currentState = ParseState.SkipToNextLine;
740 | continue;
741 | }
742 |
743 | if (currentState == ParseState.Table)
744 | {
745 | if (keyParts.Count == 0)
746 | {
747 | // We have array table
748 | if (c == TomlSyntax.TABLE_START_SYMBOL)
749 | {
750 | // Consume the character
751 | ConsumeChar();
752 | arrayTable = true;
753 | }
754 |
755 | if (!ReadKeyName(ref keyParts, TomlSyntax.TABLE_END_SYMBOL))
756 | {
757 | keyParts.Clear();
758 | continue;
759 | }
760 |
761 | if (keyParts.Count == 0)
762 | {
763 | AddError("Table name is emtpy.");
764 | arrayTable = false;
765 | latestComment = null;
766 | keyParts.Clear();
767 | }
768 |
769 | continue;
770 | }
771 |
772 | if (c == TomlSyntax.TABLE_END_SYMBOL)
773 | {
774 | if (arrayTable)
775 | {
776 | // Consume the ending bracket so we can peek the next character
777 | ConsumeChar();
778 | var nextChar = reader.Peek();
779 | if (nextChar < 0 || (char) nextChar != TomlSyntax.TABLE_END_SYMBOL)
780 | {
781 | AddError($"Array table {".".Join(keyParts)} has only one closing bracket.");
782 | keyParts.Clear();
783 | arrayTable = false;
784 | latestComment = null;
785 | continue;
786 | }
787 | }
788 |
789 | currentNode = CreateTable(rootNode, keyParts, arrayTable);
790 | if (currentNode != null)
791 | {
792 | currentNode.IsInline = false;
793 | currentNode.Comment = latestComment?.ToString()?.TrimEnd();
794 | }
795 |
796 | keyParts.Clear();
797 | arrayTable = false;
798 | latestComment = null;
799 |
800 | if (currentNode == null)
801 | {
802 | if (currentState != ParseState.None)
803 | AddError("Error creating table array!");
804 | // Reset a node to root in order to try and continue parsing
805 | currentNode = rootNode;
806 | continue;
807 | }
808 |
809 | currentState = ParseState.SkipToNextLine;
810 | goto consume_character;
811 | }
812 |
813 | if (keyParts.Count != 0)
814 | {
815 | AddError($"Unexpected character \"{c}\"");
816 | keyParts.Clear();
817 | arrayTable = false;
818 | latestComment = null;
819 | }
820 | }
821 |
822 | if (currentState == ParseState.SkipToNextLine)
823 | {
824 | if (TomlSyntax.IsWhiteSpace(c) || c == TomlSyntax.NEWLINE_CARRIAGE_RETURN_CHARACTER)
825 | goto consume_character;
826 |
827 | if (c is TomlSyntax.COMMENT_SYMBOL or TomlSyntax.NEWLINE_CHARACTER)
828 | {
829 | currentState = ParseState.None;
830 | AdvanceLine();
831 |
832 | if (c == TomlSyntax.COMMENT_SYMBOL)
833 | {
834 | col++;
835 | ParseComment();
836 | continue;
837 | }
838 |
839 | goto consume_character;
840 | }
841 |
842 | AddError($"Unexpected character \"{c}\" at the end of the line.");
843 | }
844 |
845 | consume_character:
846 | reader.Read();
847 | col++;
848 | }
849 |
850 | if (currentState != ParseState.None && currentState != ParseState.SkipToNextLine)
851 | AddError("Unexpected end of file!");
852 |
853 | if (syntaxErrors.Count > 0)
854 | throw new TomlParseException(rootNode, syntaxErrors);
855 |
856 | return rootNode;
857 | }
858 |
859 | private bool AddError(string message, bool skipLine = true)
860 | {
861 | syntaxErrors.Add(new TomlSyntaxException(message, currentState, line, col));
862 | // Skip the whole line in hope that it was only a single faulty value (and non-multiline one at that)
863 | if (skipLine)
864 | {
865 | reader.ReadLine();
866 | AdvanceLine(1);
867 | }
868 | currentState = ParseState.None;
869 | return false;
870 | }
871 |
872 | private void AdvanceLine(int startCol = 0)
873 | {
874 | line++;
875 | col = startCol;
876 | }
877 |
878 | private int ConsumeChar()
879 | {
880 | col++;
881 | return reader.Read();
882 | }
883 |
884 | #region Key-Value pair parsing
885 |
886 | /**
887 | * Reads a single key-value pair.
888 | * Assumes the cursor is at the first character that belong to the pair (including possible whitespace).
889 | * Consumes all characters that belong to the key and the value (ignoring possible trailing whitespace at the end).
890 | *
891 | * Example:
892 | * foo = "bar" ==> foo = "bar"
893 | * ^ ^
894 | */
895 | private TomlNode ReadKeyValuePair(List keyParts)
896 | {
897 | int cur;
898 | while ((cur = reader.Peek()) >= 0)
899 | {
900 | var c = (char) cur;
901 |
902 | if (TomlSyntax.IsQuoted(c) || TomlSyntax.IsBareKey(c))
903 | {
904 | if (keyParts.Count != 0)
905 | {
906 | AddError("Encountered extra characters in key definition!");
907 | return null;
908 | }
909 |
910 | if (!ReadKeyName(ref keyParts, TomlSyntax.KEY_VALUE_SEPARATOR))
911 | return null;
912 |
913 | continue;
914 | }
915 |
916 | if (TomlSyntax.IsWhiteSpace(c))
917 | {
918 | ConsumeChar();
919 | continue;
920 | }
921 |
922 | if (c == TomlSyntax.KEY_VALUE_SEPARATOR)
923 | {
924 | ConsumeChar();
925 | return ReadValue();
926 | }
927 |
928 | AddError($"Unexpected character \"{c}\" in key name.");
929 | return null;
930 | }
931 |
932 | return null;
933 | }
934 |
935 | /**
936 | * Reads a single value.
937 | * Assumes the cursor is at the first character that belongs to the value (including possible starting whitespace).
938 | * Consumes all characters belonging to the value (ignoring possible trailing whitespace at the end).
939 | *
940 | * Example:
941 | * "test" ==> "test"
942 | * ^ ^
943 | */
944 | private TomlNode ReadValue(bool skipNewlines = false)
945 | {
946 | int cur;
947 | while ((cur = reader.Peek()) >= 0)
948 | {
949 | var c = (char) cur;
950 |
951 | if (TomlSyntax.IsWhiteSpace(c))
952 | {
953 | ConsumeChar();
954 | continue;
955 | }
956 |
957 | if (c == TomlSyntax.COMMENT_SYMBOL)
958 | {
959 | AddError("No value found!");
960 | return null;
961 | }
962 |
963 | if (TomlSyntax.IsNewLine(c))
964 | {
965 | if (skipNewlines)
966 | {
967 | reader.Read();
968 | AdvanceLine(1);
969 | continue;
970 | }
971 |
972 | AddError("Encountered a newline when expecting a value!");
973 | return null;
974 | }
975 |
976 | if (TomlSyntax.IsQuoted(c))
977 | {
978 | var isMultiline = IsTripleQuote(c, out var excess);
979 |
980 | // Error occurred in triple quote parsing
981 | if (currentState == ParseState.None)
982 | return null;
983 |
984 | var value = isMultiline
985 | ? ReadQuotedValueMultiLine(c)
986 | : ReadQuotedValueSingleLine(c, excess);
987 |
988 | if (value is null)
989 | return null;
990 |
991 | return new TomlString
992 | {
993 | Value = value,
994 | IsMultiline = isMultiline,
995 | PreferLiteral = c == TomlSyntax.LITERAL_STRING_SYMBOL
996 | };
997 | }
998 |
999 | return c switch
1000 | {
1001 | TomlSyntax.INLINE_TABLE_START_SYMBOL => ReadInlineTable(),
1002 | TomlSyntax.ARRAY_START_SYMBOL => ReadArray(),
1003 | var _ => ReadTomlValue()
1004 | };
1005 | }
1006 |
1007 | return null;
1008 | }
1009 |
1010 | /**
1011 | * Reads a single key name.
1012 | * Assumes the cursor is at the first character belonging to the key (with possible trailing whitespace if `skipWhitespace = true`).
1013 | * Consumes all the characters until the `until` character is met (but does not consume the character itself).
1014 | *
1015 | * Example 1:
1016 | * foo.bar ==> foo.bar (`skipWhitespace = false`, `until = ' '`)
1017 | * ^ ^
1018 | *
1019 | * Example 2:
1020 | * [ foo . bar ] ==> [ foo . bar ] (`skipWhitespace = true`, `until = ']'`)
1021 | * ^ ^
1022 | */
1023 | private bool ReadKeyName(ref List parts, char until)
1024 | {
1025 | var buffer = new StringBuilder();
1026 | var quoted = false;
1027 | var prevWasSpace = false;
1028 | int cur;
1029 | while ((cur = reader.Peek()) >= 0)
1030 | {
1031 | var c = (char) cur;
1032 |
1033 | // Reached the final character
1034 | if (c == until) break;
1035 |
1036 | if (TomlSyntax.IsWhiteSpace(c))
1037 | {
1038 | prevWasSpace = true;
1039 | goto consume_character;
1040 | }
1041 |
1042 | if (buffer.Length == 0) prevWasSpace = false;
1043 |
1044 | if (c == TomlSyntax.SUBKEY_SEPARATOR)
1045 | {
1046 | if (buffer.Length == 0 && !quoted)
1047 | return AddError($"Found an extra subkey separator in {".".Join(parts)}...");
1048 |
1049 | parts.Add(buffer.ToString());
1050 | buffer.Length = 0;
1051 | quoted = false;
1052 | prevWasSpace = false;
1053 | goto consume_character;
1054 | }
1055 |
1056 | if (prevWasSpace)
1057 | return AddError("Invalid spacing in key name");
1058 |
1059 | if (TomlSyntax.IsQuoted(c))
1060 | {
1061 | if (quoted)
1062 |
1063 | return AddError("Expected a subkey separator but got extra data instead!");
1064 |
1065 | if (buffer.Length != 0)
1066 | return AddError("Encountered a quote in the middle of subkey name!");
1067 |
1068 | // Consume the quote character and read the key name
1069 | col++;
1070 | buffer.Append(ReadQuotedValueSingleLine((char) reader.Read()));
1071 | quoted = true;
1072 | continue;
1073 | }
1074 |
1075 | if (TomlSyntax.IsBareKey(c))
1076 | {
1077 | buffer.Append(c);
1078 | goto consume_character;
1079 | }
1080 |
1081 | // If we see an invalid symbol, let the next parser handle it
1082 | break;
1083 |
1084 | consume_character:
1085 | reader.Read();
1086 | col++;
1087 | }
1088 |
1089 | if (buffer.Length == 0 && !quoted)
1090 | return AddError($"Found an extra subkey separator in {".".Join(parts)}...");
1091 |
1092 | parts.Add(buffer.ToString());
1093 |
1094 | return true;
1095 | }
1096 |
1097 | #endregion
1098 |
1099 | #region Non-string value parsing
1100 |
1101 | /**
1102 | * Reads the whole raw value until the first non-value character is encountered.
1103 | * Assumes the cursor start position at the first value character and consumes all characters that may be related to the value.
1104 | * Example:
1105 | *
1106 | * 1_0_0_0 ==> 1_0_0_0
1107 | * ^ ^
1108 | */
1109 | private string ReadRawValue()
1110 | {
1111 | var result = new StringBuilder();
1112 | int cur;
1113 | while ((cur = reader.Peek()) >= 0)
1114 | {
1115 | var c = (char) cur;
1116 | if (c == TomlSyntax.COMMENT_SYMBOL || TomlSyntax.IsNewLine(c) || TomlSyntax.IsValueSeparator(c)) break;
1117 | result.Append(c);
1118 | ConsumeChar();
1119 | }
1120 |
1121 | // Replace trim with manual space counting?
1122 | return result.ToString().Trim();
1123 | }
1124 |
1125 | /**
1126 | * Reads and parses a non-string, non-composite TOML value.
1127 | * Assumes the cursor at the first character that is related to the value (with possible spaces).
1128 | * Consumes all the characters that are related to the value.
1129 | *
1130 | * Example
1131 | * 1_0_0_0 # This is a comment
1132 | *
1133 | * ==> 1_0_0_0 # This is a comment
1134 | * ^ ^
1135 | */
1136 | private TomlNode ReadTomlValue()
1137 | {
1138 | var value = ReadRawValue();
1139 | TomlNode node = value switch
1140 | {
1141 | var v when TomlSyntax.IsBoolean(v) => bool.Parse(v),
1142 | var v when TomlSyntax.IsNaN(v) => double.NaN,
1143 | var v when TomlSyntax.IsPosInf(v) => double.PositiveInfinity,
1144 | var v when TomlSyntax.IsNegInf(v) => double.NegativeInfinity,
1145 | var v when TomlSyntax.IsInteger(v) => long.Parse(value.RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR),
1146 | CultureInfo.InvariantCulture),
1147 | var v when TomlSyntax.IsFloat(v) => double.Parse(value.RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR),
1148 | CultureInfo.InvariantCulture),
1149 | var v when TomlSyntax.IsIntegerWithBase(v, out var numberBase) => new TomlInteger
1150 | {
1151 | Value = Convert.ToInt64(value.Substring(2).RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), numberBase),
1152 | IntegerBase = (TomlInteger.Base) numberBase
1153 | },
1154 | var _ => null
1155 | };
1156 | if (node != null) return node;
1157 |
1158 | // Normalize by removing space separator
1159 | value = value.Replace(TomlSyntax.RFC3339EmptySeparator, TomlSyntax.ISO861Separator);
1160 | if (StringUtils.TryParseDateTime(value,
1161 | TomlSyntax.RFC3339LocalDateTimeFormats,
1162 | DateTimeStyles.AssumeLocal,
1163 | DateTime.TryParseExact,
1164 | out var dateTimeResult,
1165 | out var precision))
1166 | return new TomlDateTimeLocal
1167 | {
1168 | Value = dateTimeResult,
1169 | SecondsPrecision = precision
1170 | };
1171 |
1172 | if (DateTime.TryParseExact(value,
1173 | TomlSyntax.LocalDateFormat,
1174 | CultureInfo.InvariantCulture,
1175 | DateTimeStyles.AssumeLocal,
1176 | out dateTimeResult))
1177 | return new TomlDateTimeLocal
1178 | {
1179 | Value = dateTimeResult,
1180 | Style = TomlDateTimeLocal.DateTimeStyle.Date
1181 | };
1182 |
1183 | if (StringUtils.TryParseDateTime(value,
1184 | TomlSyntax.RFC3339LocalTimeFormats,
1185 | DateTimeStyles.AssumeLocal,
1186 | DateTime.TryParseExact,
1187 | out dateTimeResult,
1188 | out precision))
1189 | return new TomlDateTimeLocal
1190 | {
1191 | Value = dateTimeResult,
1192 | Style = TomlDateTimeLocal.DateTimeStyle.Time,
1193 | SecondsPrecision = precision
1194 | };
1195 |
1196 | if (StringUtils.TryParseDateTime(value,
1197 | TomlSyntax.RFC3339Formats,
1198 | DateTimeStyles.None,
1199 | DateTimeOffset.TryParseExact,
1200 | out var dateTimeOffsetResult,
1201 | out precision))
1202 | return new TomlDateTimeOffset
1203 | {
1204 | Value = dateTimeOffsetResult,
1205 | SecondsPrecision = precision
1206 | };
1207 |
1208 | AddError($"Value \"{value}\" is not a valid TOML value!");
1209 | return null;
1210 | }
1211 |
1212 | /**
1213 | * Reads an array value.
1214 | * Assumes the cursor is at the start of the array definition. Reads all character until the array closing bracket.
1215 | *
1216 | * Example:
1217 | * [1, 2, 3] ==> [1, 2, 3]
1218 | * ^ ^
1219 | */
1220 | private TomlArray ReadArray()
1221 | {
1222 | // Consume the start of array character
1223 | ConsumeChar();
1224 | var result = new TomlArray();
1225 | TomlNode currentValue = null;
1226 | var expectValue = true;
1227 |
1228 | int cur;
1229 | while ((cur = reader.Peek()) >= 0)
1230 | {
1231 | var c = (char) cur;
1232 |
1233 | if (c == TomlSyntax.ARRAY_END_SYMBOL)
1234 | {
1235 | ConsumeChar();
1236 | break;
1237 | }
1238 |
1239 | if (c == TomlSyntax.COMMENT_SYMBOL)
1240 | {
1241 | reader.ReadLine();
1242 | AdvanceLine(1);
1243 | continue;
1244 | }
1245 |
1246 | if (TomlSyntax.IsWhiteSpace(c) || TomlSyntax.IsNewLine(c))
1247 | {
1248 | if (TomlSyntax.IsLineBreak(c))
1249 | AdvanceLine();
1250 | goto consume_character;
1251 | }
1252 |
1253 | if (c == TomlSyntax.ITEM_SEPARATOR)
1254 | {
1255 | if (currentValue == null)
1256 | {
1257 | AddError("Encountered multiple value separators");
1258 | return null;
1259 | }
1260 |
1261 | result.Add(currentValue);
1262 | currentValue = null;
1263 | expectValue = true;
1264 | goto consume_character;
1265 | }
1266 |
1267 | if (!expectValue)
1268 | {
1269 | AddError("Missing separator between values");
1270 | return null;
1271 | }
1272 | currentValue = ReadValue(true);
1273 | if (currentValue == null)
1274 | {
1275 | if (currentState != ParseState.None)
1276 | AddError("Failed to determine and parse a value!");
1277 | return null;
1278 | }
1279 | expectValue = false;
1280 |
1281 | continue;
1282 | consume_character:
1283 | ConsumeChar();
1284 | }
1285 |
1286 | if (currentValue != null) result.Add(currentValue);
1287 | return result;
1288 | }
1289 |
1290 | /**
1291 | * Reads an inline table.
1292 | * Assumes the cursor is at the start of the table definition. Reads all character until the table closing bracket.
1293 | *
1294 | * Example:
1295 | * { test = "foo", value = 1 } ==> { test = "foo", value = 1 }
1296 | * ^ ^
1297 | */
1298 | private TomlNode ReadInlineTable()
1299 | {
1300 | ConsumeChar();
1301 | var result = new TomlTable {IsInline = true};
1302 | TomlNode currentValue = null;
1303 | var separator = false;
1304 | var keyParts = new List();
1305 | int cur;
1306 | while ((cur = reader.Peek()) >= 0)
1307 | {
1308 | var c = (char) cur;
1309 |
1310 | if (c == TomlSyntax.INLINE_TABLE_END_SYMBOL)
1311 | {
1312 | ConsumeChar();
1313 | break;
1314 | }
1315 |
1316 | if (c == TomlSyntax.COMMENT_SYMBOL)
1317 | {
1318 | AddError("Incomplete inline table definition!");
1319 | return null;
1320 | }
1321 |
1322 | if (TomlSyntax.IsNewLine(c))
1323 | {
1324 | AddError("Inline tables are only allowed to be on single line");
1325 | return null;
1326 | }
1327 |
1328 | if (TomlSyntax.IsWhiteSpace(c))
1329 | goto consume_character;
1330 |
1331 | if (c == TomlSyntax.ITEM_SEPARATOR)
1332 | {
1333 | if (currentValue == null)
1334 | {
1335 | AddError("Encountered multiple value separators in inline table!");
1336 | return null;
1337 | }
1338 |
1339 | if (!InsertNode(currentValue, result, keyParts))
1340 | return null;
1341 | keyParts.Clear();
1342 | currentValue = null;
1343 | separator = true;
1344 | goto consume_character;
1345 | }
1346 |
1347 | separator = false;
1348 | currentValue = ReadKeyValuePair(keyParts);
1349 | continue;
1350 |
1351 | consume_character:
1352 | ConsumeChar();
1353 | }
1354 |
1355 | if (separator)
1356 | {
1357 | AddError("Trailing commas are not allowed in inline tables.");
1358 | return null;
1359 | }
1360 |
1361 | if (currentValue != null && !InsertNode(currentValue, result, keyParts))
1362 | return null;
1363 |
1364 | return result;
1365 | }
1366 |
1367 | #endregion
1368 |
1369 | #region String parsing
1370 |
1371 | /**
1372 | * Checks if the string value a multiline string (i.e. a triple quoted string).
1373 | * Assumes the cursor is at the first quote character. Consumes the least amount of characters needed to determine if the string is multiline.
1374 | *
1375 | * If the result is false, returns the consumed character through the `excess` variable.
1376 | *
1377 | * Example 1:
1378 | * """test""" ==> """test"""
1379 | * ^ ^
1380 | *
1381 | * Example 2:
1382 | * "test" ==> "test" (doesn't return the first quote)
1383 | * ^ ^
1384 | *
1385 | * Example 3:
1386 | * "" ==> "" (returns the extra `"` through the `excess` variable)
1387 | * ^ ^
1388 | */
1389 | private bool IsTripleQuote(char quote, out char excess)
1390 | {
1391 | // Copypasta, but it's faster...
1392 |
1393 | int cur;
1394 | // Consume the first quote
1395 | ConsumeChar();
1396 | if ((cur = reader.Peek()) < 0)
1397 | {
1398 | excess = '\0';
1399 | return AddError("Unexpected end of file!");
1400 | }
1401 |
1402 | if ((char) cur != quote)
1403 | {
1404 | excess = '\0';
1405 | return false;
1406 | }
1407 |
1408 | // Consume the second quote
1409 | excess = (char) ConsumeChar();
1410 | if ((cur = reader.Peek()) < 0 || (char) cur != quote) return false;
1411 |
1412 | // Consume the final quote
1413 | ConsumeChar();
1414 | excess = '\0';
1415 | return true;
1416 | }
1417 |
1418 | /**
1419 | * A convenience method to process a single character within a quote.
1420 | */
1421 | private bool ProcessQuotedValueCharacter(char quote,
1422 | bool isNonLiteral,
1423 | char c,
1424 | StringBuilder sb,
1425 | ref bool escaped)
1426 | {
1427 | if (TomlSyntax.MustBeEscaped(c))
1428 | return AddError($"The character U+{(int) c:X8} must be escaped in a string!");
1429 |
1430 | if (escaped)
1431 | {
1432 | sb.Append(c);
1433 | escaped = false;
1434 | return false;
1435 | }
1436 |
1437 | if (c == quote) return true;
1438 | if (isNonLiteral && c == TomlSyntax.ESCAPE_SYMBOL)
1439 | escaped = true;
1440 | if (c == TomlSyntax.NEWLINE_CHARACTER)
1441 | return AddError("Encountered newline in single line string!");
1442 |
1443 | sb.Append(c);
1444 | return false;
1445 | }
1446 |
1447 | /**
1448 | * Reads a single-line string.
1449 | * Assumes the cursor is at the first character that belongs to the string.
1450 | * Consumes all characters that belong to the string (including the closing quote).
1451 | *
1452 | * Example:
1453 | * "test" ==> "test"
1454 | * ^ ^
1455 | */
1456 | private string ReadQuotedValueSingleLine(char quote, char initialData = '\0')
1457 | {
1458 | var isNonLiteral = quote == TomlSyntax.BASIC_STRING_SYMBOL;
1459 | var sb = new StringBuilder();
1460 | var escaped = false;
1461 |
1462 | if (initialData != '\0')
1463 | {
1464 | var shouldReturn =
1465 | ProcessQuotedValueCharacter(quote, isNonLiteral, initialData, sb, ref escaped);
1466 | if (currentState == ParseState.None) return null;
1467 | if (shouldReturn)
1468 | if (isNonLiteral)
1469 | {
1470 | if (sb.ToString().TryUnescape(out var res, out var ex)) return res;
1471 | AddError(ex.Message);
1472 | return null;
1473 | }
1474 | else
1475 | return sb.ToString();
1476 | }
1477 |
1478 | int cur;
1479 | var readDone = false;
1480 | while ((cur = reader.Read()) >= 0)
1481 | {
1482 | // Consume the character
1483 | col++;
1484 | var c = (char) cur;
1485 | readDone = ProcessQuotedValueCharacter(quote, isNonLiteral, c, sb, ref escaped);
1486 | if (readDone)
1487 | {
1488 | if (currentState == ParseState.None) return null;
1489 | break;
1490 | }
1491 | }
1492 |
1493 | if (!readDone)
1494 | {
1495 | AddError("Unclosed string.");
1496 | return null;
1497 | }
1498 |
1499 | if (!isNonLiteral) return sb.ToString();
1500 | if (sb.ToString().TryUnescape(out var unescaped, out var unescapedEx)) return unescaped;
1501 | AddError(unescapedEx.Message);
1502 | return null;
1503 | }
1504 |
1505 | /**
1506 | * Reads a multiline string.
1507 | * Assumes the cursor is at the first character that belongs to the string.
1508 | * Consumes all characters that belong to the string and the three closing quotes.
1509 | *
1510 | * Example:
1511 | * """test""" ==> """test"""
1512 | * ^ ^
1513 | */
1514 | private string ReadQuotedValueMultiLine(char quote)
1515 | {
1516 | var isBasic = quote == TomlSyntax.BASIC_STRING_SYMBOL;
1517 | var sb = new StringBuilder();
1518 | var escaped = false;
1519 | var skipWhitespace = false;
1520 | var skipWhitespaceLineSkipped = false;
1521 | var quotesEncountered = 0;
1522 | var first = true;
1523 | int cur;
1524 | while ((cur = ConsumeChar()) >= 0)
1525 | {
1526 | var c = (char) cur;
1527 | if (TomlSyntax.MustBeEscaped(c, true))
1528 | {
1529 | AddError($"The character U+{(int) c:X8} must be escaped!");
1530 | return null;
1531 | }
1532 | // Trim the first newline
1533 | if (first && TomlSyntax.IsNewLine(c))
1534 | {
1535 | if (TomlSyntax.IsLineBreak(c))
1536 | first = false;
1537 | else
1538 | AdvanceLine();
1539 | continue;
1540 | }
1541 |
1542 | first = false;
1543 | //TODO: Reuse ProcessQuotedValueCharacter
1544 | // Skip the current character if it is going to be escaped later
1545 | if (escaped)
1546 | {
1547 | sb.Append(c);
1548 | escaped = false;
1549 | continue;
1550 | }
1551 |
1552 | // If we are currently skipping empty spaces, skip
1553 | if (skipWhitespace)
1554 | {
1555 | if (TomlSyntax.IsEmptySpace(c))
1556 | {
1557 | if (TomlSyntax.IsLineBreak(c))
1558 | {
1559 | skipWhitespaceLineSkipped = true;
1560 | AdvanceLine();
1561 | }
1562 | continue;
1563 | }
1564 |
1565 | if (!skipWhitespaceLineSkipped)
1566 | {
1567 | AddError("Non-whitespace character after trim marker.");
1568 | return null;
1569 | }
1570 |
1571 | skipWhitespaceLineSkipped = false;
1572 | skipWhitespace = false;
1573 | }
1574 |
1575 | // If we encounter an escape sequence...
1576 | if (isBasic && c == TomlSyntax.ESCAPE_SYMBOL)
1577 | {
1578 | var next = reader.Peek();
1579 | var nc = (char) next;
1580 | if (next >= 0)
1581 | {
1582 | // ...and the next char is empty space, we must skip all whitespaces
1583 | if (TomlSyntax.IsEmptySpace(nc))
1584 | {
1585 | skipWhitespace = true;
1586 | continue;
1587 | }
1588 |
1589 | // ...and we have \" or \, skip the character
1590 | if (nc == quote || nc == TomlSyntax.ESCAPE_SYMBOL) escaped = true;
1591 | }
1592 | }
1593 |
1594 | // Count the consecutive quotes
1595 | if (c == quote)
1596 | quotesEncountered++;
1597 | else
1598 | quotesEncountered = 0;
1599 |
1600 | // If the are three quotes, count them as closing quotes
1601 | if (quotesEncountered == 3) break;
1602 |
1603 | sb.Append(c);
1604 | }
1605 |
1606 | // TOML actually allows to have five ending quotes like
1607 | // """"" => "" belong to the string + """ is the actual ending
1608 | quotesEncountered = 0;
1609 | while ((cur = reader.Peek()) >= 0)
1610 | {
1611 | var c = (char) cur;
1612 | if (c == quote && ++quotesEncountered < 3)
1613 | {
1614 | sb.Append(c);
1615 | ConsumeChar();
1616 | }
1617 | else break;
1618 | }
1619 |
1620 | // Remove last two quotes (third one wasn't included by default)
1621 | sb.Length -= 2;
1622 | if (!isBasic) return sb.ToString();
1623 | if (sb.ToString().TryUnescape(out var res, out var ex)) return res;
1624 | AddError(ex.Message);
1625 | return null;
1626 | }
1627 |
1628 | #endregion
1629 |
1630 | #region Node creation
1631 |
1632 | private bool InsertNode(TomlNode node, TomlNode root, IList path)
1633 | {
1634 | var latestNode = root;
1635 | if (path.Count > 1)
1636 | for (var index = 0; index < path.Count - 1; index++)
1637 | {
1638 | var subkey = path[index];
1639 | if (latestNode.TryGetNode(subkey, out var currentNode))
1640 | {
1641 | if (currentNode.HasValue)
1642 | return AddError($"The key {".".Join(path)} already has a value assigned to it!");
1643 | }
1644 | else
1645 | {
1646 | currentNode = new TomlTable();
1647 | latestNode[subkey] = currentNode;
1648 | }
1649 |
1650 | latestNode = currentNode;
1651 | if (latestNode is TomlTable { IsInline: true })
1652 | return AddError($"Cannot assign {".".Join(path)} because it will edit an immutable table.");
1653 | }
1654 |
1655 | if (latestNode.HasKey(path[path.Count - 1]))
1656 | return AddError($"The key {".".Join(path)} is already defined!");
1657 | latestNode[path[path.Count - 1]] = node;
1658 | node.CollapseLevel = path.Count - 1;
1659 | return true;
1660 | }
1661 |
1662 | private TomlTable CreateTable(TomlNode root, IList path, bool arrayTable)
1663 | {
1664 | if (path.Count == 0) return null;
1665 | var latestNode = root;
1666 | for (var index = 0; index < path.Count; index++)
1667 | {
1668 | var subkey = path[index];
1669 |
1670 | if (latestNode.TryGetNode(subkey, out var node))
1671 | {
1672 | if (node.IsArray && arrayTable)
1673 | {
1674 | var arr = (TomlArray) node;
1675 |
1676 | if (!arr.IsTableArray)
1677 | {
1678 | AddError($"The array {".".Join(path)} cannot be redefined as an array table!");
1679 | return null;
1680 | }
1681 |
1682 | if (index == path.Count - 1)
1683 | {
1684 | latestNode = new TomlTable();
1685 | arr.Add(latestNode);
1686 | break;
1687 | }
1688 |
1689 | latestNode = arr[arr.ChildrenCount - 1];
1690 | continue;
1691 | }
1692 |
1693 | if (node is TomlTable { IsInline: true })
1694 | {
1695 | AddError($"Cannot create table {".".Join(path)} because it will edit an immutable table.");
1696 | return null;
1697 | }
1698 |
1699 | if (node.HasValue)
1700 | {
1701 | if (node is not TomlArray { IsTableArray: true } array)
1702 | {
1703 | AddError($"The key {".".Join(path)} has a value assigned to it!");
1704 | return null;
1705 | }
1706 |
1707 | latestNode = array[array.ChildrenCount - 1];
1708 | continue;
1709 | }
1710 |
1711 | if (index == path.Count - 1)
1712 | {
1713 | if (arrayTable && !node.IsArray)
1714 | {
1715 | AddError($"The table {".".Join(path)} cannot be redefined as an array table!");
1716 | return null;
1717 | }
1718 |
1719 | if (node is TomlTable { isImplicit: false })
1720 | {
1721 | AddError($"The table {".".Join(path)} is defined multiple times!");
1722 | return null;
1723 | }
1724 | }
1725 | }
1726 | else
1727 | {
1728 | if (index == path.Count - 1 && arrayTable)
1729 | {
1730 | var table = new TomlTable();
1731 | var arr = new TomlArray
1732 | {
1733 | IsTableArray = true
1734 | };
1735 | arr.Add(table);
1736 | latestNode[subkey] = arr;
1737 | latestNode = table;
1738 | break;
1739 | }
1740 |
1741 | node = new TomlTable { isImplicit = true };
1742 | latestNode[subkey] = node;
1743 | }
1744 |
1745 | latestNode = node;
1746 | }
1747 |
1748 | var result = (TomlTable) latestNode;
1749 | result.isImplicit = false;
1750 | return result;
1751 | }
1752 |
1753 | #endregion
1754 |
1755 | #region Misc parsing
1756 |
1757 | private string ParseComment()
1758 | {
1759 | ConsumeChar();
1760 | var commentLine = reader.ReadLine()?.Trim() ?? "";
1761 | if (commentLine.Any(ch => TomlSyntax.MustBeEscaped(ch)))
1762 | AddError("Comment must not contain control characters other than tab.", false);
1763 | return commentLine;
1764 | }
1765 | #endregion
1766 | }
1767 |
1768 | #endregion
1769 |
1770 | public static class TOML
1771 | {
1772 | public static bool ForceASCII { get; set; } = false;
1773 |
1774 | public static TomlTable Parse(TextReader reader)
1775 | {
1776 | using var parser = new TOMLParser(reader) {ForceASCII = ForceASCII};
1777 | return parser.Parse();
1778 | }
1779 | }
1780 |
1781 | #region Exception Types
1782 |
1783 | public class TomlFormatException : Exception
1784 | {
1785 | public TomlFormatException(string message) : base(message) { }
1786 | }
1787 |
1788 | public class TomlParseException : Exception
1789 | {
1790 | public TomlParseException(TomlTable parsed, IEnumerable exceptions) :
1791 | base("TOML file contains format errors")
1792 | {
1793 | ParsedTable = parsed;
1794 | SyntaxErrors = exceptions;
1795 | }
1796 |
1797 | public TomlTable ParsedTable { get; }
1798 |
1799 | public IEnumerable SyntaxErrors { get; }
1800 | }
1801 |
1802 | public class TomlSyntaxException : Exception
1803 | {
1804 | public TomlSyntaxException(string message, TOMLParser.ParseState state, int line, int col) : base(message)
1805 | {
1806 | ParseState = state;
1807 | Line = line;
1808 | Column = col;
1809 | }
1810 |
1811 | public TOMLParser.ParseState ParseState { get; }
1812 |
1813 | public int Line { get; }
1814 |
1815 | public int Column { get; }
1816 | }
1817 |
1818 | #endregion
1819 |
1820 | #region Parse utilities
1821 |
1822 | internal static class TomlSyntax
1823 | {
1824 | #region Type Patterns
1825 |
1826 | public const string TRUE_VALUE = "true";
1827 | public const string FALSE_VALUE = "false";
1828 | public const string NAN_VALUE = "nan";
1829 | public const string POS_NAN_VALUE = "+nan";
1830 | public const string NEG_NAN_VALUE = "-nan";
1831 | public const string INF_VALUE = "inf";
1832 | public const string POS_INF_VALUE = "+inf";
1833 | public const string NEG_INF_VALUE = "-inf";
1834 |
1835 | public static bool IsBoolean(string s) => s is TRUE_VALUE or FALSE_VALUE;
1836 |
1837 | public static bool IsPosInf(string s) => s is INF_VALUE or POS_INF_VALUE;
1838 |
1839 | public static bool IsNegInf(string s) => s == NEG_INF_VALUE;
1840 |
1841 | public static bool IsNaN(string s) => s is NAN_VALUE or POS_NAN_VALUE or NEG_NAN_VALUE;
1842 |
1843 | public static bool IsInteger(string s) => IntegerPattern.IsMatch(s);
1844 |
1845 | public static bool IsFloat(string s) => FloatPattern.IsMatch(s);
1846 |
1847 | public static bool IsIntegerWithBase(string s, out int numberBase)
1848 | {
1849 | numberBase = 10;
1850 | var match = BasedIntegerPattern.Match(s);
1851 | if (!match.Success) return false;
1852 | IntegerBases.TryGetValue(match.Groups["base"].Value, out numberBase);
1853 | return true;
1854 | }
1855 |
1856 | /**
1857 | * A pattern to verify the integer value according to the TOML specification.
1858 | */
1859 | public static readonly Regex IntegerPattern =
1860 | new(@"^(\+|-)?(?!_)(0|(?!0)(_?\d)*)$", RegexOptions.Compiled);
1861 |
1862 | /**
1863 | * A pattern to verify a special 0x, 0o and 0b forms of an integer according to the TOML specification.
1864 | */
1865 | public static readonly Regex BasedIntegerPattern =
1866 | new(@"^0(?x|b|o)(?!_)(_?[0-9A-F])*$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
1867 |
1868 | /**
1869 | * A pattern to verify the float value according to the TOML specification.
1870 | */
1871 | public static readonly Regex FloatPattern =
1872 | new(@"^(\+|-)?(?!_)(0|(?!0)(_?\d)+)(((e(\+|-)?(?!_)(_?\d)+)?)|(\.(?!_)(_?\d)+(e(\+|-)?(?!_)(_?\d)+)?))$",
1873 | RegexOptions.Compiled | RegexOptions.IgnoreCase);
1874 |
1875 | /**
1876 | * A helper dictionary to map TOML base codes into the radii.
1877 | */
1878 | public static readonly Dictionary IntegerBases = new()
1879 | {
1880 | ["x"] = 16,
1881 | ["o"] = 8,
1882 | ["b"] = 2
1883 | };
1884 |
1885 | /**
1886 | * A helper dictionary to map non-decimal bases to their TOML identifiers
1887 | */
1888 | public static readonly Dictionary BaseIdentifiers = new()
1889 | {
1890 | [2] = "b",
1891 | [8] = "o",
1892 | [16] = "x"
1893 | };
1894 |
1895 | public const string RFC3339EmptySeparator = " ";
1896 | public const string ISO861Separator = "T";
1897 | public const string ISO861ZeroZone = "+00:00";
1898 | public const string RFC3339ZeroZone = "Z";
1899 |
1900 | /**
1901 | * Valid date formats with timezone as per RFC3339.
1902 | */
1903 | public static readonly string[] RFC3339Formats =
1904 | {
1905 | "yyyy'-'MM-ddTHH':'mm':'ssK", "yyyy'-'MM-ddTHH':'mm':'ss'.'fK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffK",
1906 | "yyyy'-'MM-ddTHH':'mm':'ss'.'fffK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffK",
1907 | "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffffK",
1908 | "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffffK"
1909 | };
1910 |
1911 | /**
1912 | * Valid date formats without timezone (assumes local) as per RFC3339.
1913 | */
1914 | public static readonly string[] RFC3339LocalDateTimeFormats =
1915 | {
1916 | "yyyy'-'MM-ddTHH':'mm':'ss", "yyyy'-'MM-ddTHH':'mm':'ss'.'f", "yyyy'-'MM-ddTHH':'mm':'ss'.'ff",
1917 | "yyyy'-'MM-ddTHH':'mm':'ss'.'fff", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffff",
1918 | "yyyy'-'MM-ddTHH':'mm':'ss'.'fffff", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffff",
1919 | "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffff"
1920 | };
1921 |
1922 | /**
1923 | * Valid full date format as per TOML spec.
1924 | */
1925 | public static readonly string LocalDateFormat = "yyyy'-'MM'-'dd";
1926 |
1927 | /**
1928 | * Valid time formats as per TOML spec.
1929 | */
1930 | public static readonly string[] RFC3339LocalTimeFormats =
1931 | {
1932 | "HH':'mm':'ss", "HH':'mm':'ss'.'f", "HH':'mm':'ss'.'ff", "HH':'mm':'ss'.'fff", "HH':'mm':'ss'.'ffff",
1933 | "HH':'mm':'ss'.'fffff", "HH':'mm':'ss'.'ffffff", "HH':'mm':'ss'.'fffffff"
1934 | };
1935 |
1936 | #endregion
1937 |
1938 | #region Character definitions
1939 |
1940 | public const char ARRAY_END_SYMBOL = ']';
1941 | public const char ITEM_SEPARATOR = ',';
1942 | public const char ARRAY_START_SYMBOL = '[';
1943 | public const char BASIC_STRING_SYMBOL = '\"';
1944 | public const char COMMENT_SYMBOL = '#';
1945 | public const char ESCAPE_SYMBOL = '\\';
1946 | public const char KEY_VALUE_SEPARATOR = '=';
1947 | public const char NEWLINE_CARRIAGE_RETURN_CHARACTER = '\r';
1948 | public const char NEWLINE_CHARACTER = '\n';
1949 | public const char SUBKEY_SEPARATOR = '.';
1950 | public const char TABLE_END_SYMBOL = ']';
1951 | public const char TABLE_START_SYMBOL = '[';
1952 | public const char INLINE_TABLE_START_SYMBOL = '{';
1953 | public const char INLINE_TABLE_END_SYMBOL = '}';
1954 | public const char LITERAL_STRING_SYMBOL = '\'';
1955 | public const char INT_NUMBER_SEPARATOR = '_';
1956 |
1957 | public static readonly char[] NewLineCharacters = {NEWLINE_CHARACTER, NEWLINE_CARRIAGE_RETURN_CHARACTER};
1958 |
1959 | public static bool IsQuoted(char c) => c is BASIC_STRING_SYMBOL or LITERAL_STRING_SYMBOL;
1960 |
1961 | public static bool IsWhiteSpace(char c) => c is ' ' or '\t';
1962 |
1963 | public static bool IsNewLine(char c) => c is NEWLINE_CHARACTER or NEWLINE_CARRIAGE_RETURN_CHARACTER;
1964 |
1965 | public static bool IsLineBreak(char c) => c == NEWLINE_CHARACTER;
1966 |
1967 | public static bool IsEmptySpace(char c) => IsWhiteSpace(c) || IsNewLine(c);
1968 |
1969 | public static bool IsBareKey(char c) =>
1970 | c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' or '_' or '-';
1971 |
1972 | public static bool MustBeEscaped(char c, bool allowNewLines = false)
1973 | {
1974 | var result = c is (>= '\u0000' and <= '\u0008') or '\u000b' or '\u000c' or (>= '\u000e' and <= '\u001f') or '\u007f';
1975 | if (!allowNewLines)
1976 | result |= c is >= '\u000a' and <= '\u000e';
1977 | return result;
1978 | }
1979 |
1980 | public static bool IsValueSeparator(char c) =>
1981 | c is ITEM_SEPARATOR or ARRAY_END_SYMBOL or INLINE_TABLE_END_SYMBOL;
1982 |
1983 | #endregion
1984 | }
1985 |
1986 | internal static class StringUtils
1987 | {
1988 | public static string AsKey(this string key)
1989 | {
1990 | var quote = key == string.Empty || key.Any(c => !TomlSyntax.IsBareKey(c));
1991 | return !quote ? key : $"{TomlSyntax.BASIC_STRING_SYMBOL}{key.Escape()}{TomlSyntax.BASIC_STRING_SYMBOL}";
1992 | }
1993 |
1994 | public static string Join(this string self, IEnumerable subItems)
1995 | {
1996 | var sb = new StringBuilder();
1997 | var first = true;
1998 |
1999 | foreach (var subItem in subItems)
2000 | {
2001 | if (!first) sb.Append(self);
2002 | first = false;
2003 | sb.Append(subItem);
2004 | }
2005 |
2006 | return sb.ToString();
2007 | }
2008 |
2009 | public delegate bool TryDateParseDelegate(string s, string format, IFormatProvider ci, DateTimeStyles dts, out T dt);
2010 |
2011 | public static bool TryParseDateTime(string s,
2012 | string[] formats,
2013 | DateTimeStyles styles,
2014 | TryDateParseDelegate parser,
2015 | out T dateTime,
2016 | out int parsedFormat)
2017 | {
2018 | parsedFormat = 0;
2019 | dateTime = default;
2020 | for (var i = 0; i < formats.Length; i++)
2021 | {
2022 | var format = formats[i];
2023 | if (!parser(s, format, CultureInfo.InvariantCulture, styles, out dateTime)) continue;
2024 | parsedFormat = i;
2025 | return true;
2026 | }
2027 |
2028 | return false;
2029 | }
2030 |
2031 | public static void AsComment(this string self, TextWriter tw)
2032 | {
2033 | foreach (var line in self.Split(TomlSyntax.NEWLINE_CHARACTER))
2034 | tw.WriteLine($"{TomlSyntax.COMMENT_SYMBOL} {line.Trim()}");
2035 | }
2036 |
2037 | public static string RemoveAll(this string txt, char toRemove)
2038 | {
2039 | var sb = new StringBuilder(txt.Length);
2040 | foreach (var c in txt.Where(c => c != toRemove))
2041 | sb.Append(c);
2042 | return sb.ToString();
2043 | }
2044 |
2045 | public static string Escape(this string txt, bool escapeNewlines = true)
2046 | {
2047 | var stringBuilder = new StringBuilder(txt.Length + 2);
2048 | for (var i = 0; i < txt.Length; i++)
2049 | {
2050 | var c = txt[i];
2051 |
2052 | static string CodePoint(string txt, ref int i, char c) => char.IsSurrogatePair(txt, i)
2053 | ? $"\\U{char.ConvertToUtf32(txt, i++):X8}"
2054 | : $"\\u{(ushort) c:X4}";
2055 |
2056 | stringBuilder.Append(c switch
2057 | {
2058 | '\b' => @"\b",
2059 | '\t' => @"\t",
2060 | '\n' when escapeNewlines => @"\n",
2061 | '\f' => @"\f",
2062 | '\r' when escapeNewlines => @"\r",
2063 | '\\' => @"\\",
2064 | '\"' => @"\""",
2065 | var _ when TomlSyntax.MustBeEscaped(c, !escapeNewlines) || TOML.ForceASCII && c > sbyte.MaxValue =>
2066 | CodePoint(txt, ref i, c),
2067 | var _ => c
2068 | });
2069 | }
2070 |
2071 | return stringBuilder.ToString();
2072 | }
2073 |
2074 | public static bool TryUnescape(this string txt, out string unescaped, out Exception exception)
2075 | {
2076 | try
2077 | {
2078 | exception = null;
2079 | unescaped = txt.Unescape();
2080 | return true;
2081 | }
2082 | catch (Exception e)
2083 | {
2084 | exception = e;
2085 | unescaped = null;
2086 | return false;
2087 | }
2088 | }
2089 |
2090 | public static string Unescape(this string txt)
2091 | {
2092 | if (string.IsNullOrEmpty(txt)) return txt;
2093 | var stringBuilder = new StringBuilder(txt.Length);
2094 | for (var i = 0; i < txt.Length;)
2095 | {
2096 | var num = txt.IndexOf('\\', i);
2097 | var next = num + 1;
2098 | if (num < 0 || num == txt.Length - 1) num = txt.Length;
2099 | stringBuilder.Append(txt, i, num - i);
2100 | if (num >= txt.Length) break;
2101 | var c = txt[next];
2102 |
2103 | static string CodePoint(int next, string txt, ref int num, int size)
2104 | {
2105 | if (next + size >= txt.Length) throw new Exception("Undefined escape sequence!");
2106 | num += size;
2107 | return char.ConvertFromUtf32(Convert.ToInt32(txt.Substring(next + 1, size), 16));
2108 | }
2109 |
2110 | stringBuilder.Append(c switch
2111 | {
2112 | 'b' => "\b",
2113 | 't' => "\t",
2114 | 'n' => "\n",
2115 | 'f' => "\f",
2116 | 'r' => "\r",
2117 | '\'' => "\'",
2118 | '\"' => "\"",
2119 | '\\' => "\\",
2120 | 'u' => CodePoint(next, txt, ref num, 4),
2121 | 'U' => CodePoint(next, txt, ref num, 8),
2122 | var _ => throw new Exception("Undefined escape sequence!")
2123 | });
2124 | i = num + 2;
2125 | }
2126 |
2127 | return stringBuilder.ToString();
2128 | }
2129 | }
2130 |
2131 | #endregion
2132 | }
--------------------------------------------------------------------------------
/src/Echoes/MarkupExtension.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using Avalonia;
3 | using Avalonia.Data;
4 | using Avalonia.Markup.Xaml;
5 | using Avalonia.Markup.Xaml.MarkupExtensions;
6 |
7 | namespace Echoes;
8 |
9 | public sealed class Translate : MarkupExtension
10 | {
11 | private readonly TranslationUnit _unit;
12 |
13 | public Translate(TranslationUnit unit)
14 | {
15 | _unit = unit;
16 | }
17 |
18 | public override object ProvideValue(IServiceProvider serviceProvider)
19 | {
20 | return _unit.Value.ToBinding();
21 | }
22 | }
--------------------------------------------------------------------------------
/src/Echoes/TranslationProvider.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Concurrent;
3 | using System.Globalization;
4 | using System.Reflection;
5 |
6 | namespace Echoes;
7 |
8 | public static class TranslationProvider
9 | {
10 | private static CultureInfo _culture;
11 | private static ConcurrentDictionary _providers;
12 |
13 | public static event EventHandler OnCultureChanged;
14 |
15 | public static CultureInfo Culture => _culture;
16 |
17 | static TranslationProvider()
18 | {
19 | _culture = CultureInfo.CurrentUICulture;
20 | _providers = new ConcurrentDictionary();
21 | }
22 |
23 | public static void SetCulture(CultureInfo culture)
24 | {
25 | _culture = culture;
26 | OnCultureChanged?.Invoke(null, _culture);
27 | }
28 |
29 | public static string? ReadTranslation(Assembly assembly, string file, string key, CultureInfo culture)
30 | {
31 | if (_providers.TryGetValue(file, out var provider))
32 | {
33 | return provider.ReadTranslation(key, culture);
34 | }
35 | else
36 | {
37 | var newProvider = new FileTranslationProvider(assembly, file);
38 |
39 | _providers.TryAdd(file, newProvider);
40 |
41 | return newProvider.ReadTranslation(key, culture);
42 | }
43 | }
44 | }
--------------------------------------------------------------------------------
/src/Echoes/TranslationUnit.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Reactive.Subjects;
3 | using System.Reflection;
4 |
5 | namespace Echoes;
6 |
7 | public class TranslationUnit
8 | {
9 | private BehaviorSubject _value;
10 |
11 | public IObservable Value => _value;
12 |
13 | public string SourceFile { get; }
14 | public string Key { get; }
15 |
16 | public TranslationUnit(Assembly assembly, string sourceFile, string key)
17 | {
18 | SourceFile = sourceFile;
19 | Key = key;
20 |
21 | _value = new BehaviorSubject(TranslationProvider.ReadTranslation(assembly, sourceFile, key, TranslationProvider.Culture));
22 |
23 | TranslationProvider.OnCultureChanged += (sender, info) =>
24 | {
25 | _value.OnNext(TranslationProvider.ReadTranslation(assembly, sourceFile, key, info));
26 | };
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/global.json:
--------------------------------------------------------------------------------
1 | {
2 | "sdk": {
3 | "version": "8.0.0",
4 | "rollForward": "latestMinor",
5 | "allowPrerelease": false
6 | }
7 | }
--------------------------------------------------------------------------------