├── .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 | } --------------------------------------------------------------------------------