Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ Generates:

### Arrays

Define arrays with comma-separated values (commas are optional; a newline also separates items):
Define arrays with comma-separated values. A newline separates items too, so commas may be omitted **across lines** — but two items on the same line still need one:

```settex
settings {
Expand Down
2 changes: 1 addition & 1 deletion docs-site/Pages/Cli.razor
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<p>@(Fr ? "Installez l'outil global :" : "Install the global tool:")</p>
<CodeBlock Lang="bash" Code="dotnet tool install --global Settex.Cli" />

<p>@(Fr ? "Compilez un fichier — la sortie est placée à côté par défaut, ou dans un dossier avec " : "Compile a file — output goes next to it by default, or into a directory with ")<code>-o</code> :</p>
<p>@(Fr ? "Compilez un fichier — la sortie va dans le répertoire courant par défaut, ou dans un dossier avec " : "Compile a file — output goes into the current directory by default, or into a directory with ")<code>-o</code> :</p>
<CodeBlock Lang="bash" Code="@CliUsage" />

<p>@(Fr ? "La CLI produit des diagnostics précis et cliquables avec fichier, ligne et colonne :" : "The CLI reports precise, clickable diagnostics with file, line and column:")</p>
Expand Down
2 changes: 1 addition & 1 deletion docs-site/Pages/Language.razor
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
<CodeBlock Code="@Maps" />

<h2 id="arrays">@(Fr ? "Tableaux" : "Arrays")</h2>
<p>@(Fr ? "Valeurs séparées par des virgules — les virgules sont optionnelles, un retour à la ligne sépare aussi les éléments." : "Comma-separated values — commas are optional, a newline also separates items.")</p>
<p>@(Fr ? "Valeurs séparées par des virgules. Un retour à la ligne sépare aussi les éléments, donc la virgule peut être omise d'une ligne à l'autre — mais deux éléments sur la même ligne en exigent une." : "Comma-separated values. A newline separates items too, so a comma may be omitted across lines — but two items on the same line still need one.")</p>
<CodeBlock Code="@Arrays" />

<h2 id="objects">@(Fr ? "Objets littéraux dans les tableaux" : "Object literals in arrays")</h2>
Expand Down
2 changes: 1 addition & 1 deletion specs/specifications-V2.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ V2 inclut tout V1 + :

### 3.1 Structure attendue

* **exactement un** `settings { ... }` global
* **au moins un** `settings { ... }` global. Plusieurs blocs sont autorisés et fusionnent en profondeur dans l'ordre du document, ce qui est ce qui permet à un fichier inclus d'en contribuer un.
* 0..N `env "<Name>" { ... }`
* `include` et `let` autorisés au global et dans `env`

Expand Down
14 changes: 14 additions & 0 deletions src/Settex.LanguageServer/SettexCompletionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,20 @@ private string GetTextBeforeCursor(string text, Position position)
var lines = textBeforeCursor.Split('\n');
var lastLine = lines[^1].TrimStart();

// Drop the identifier being typed, if any, before looking for the dot. The
// editor re-requests completion on every keystroke, so requiring the line to end
// with a dot meant the property list appeared after "Server." and vanished the
// moment the user typed "P" — exactly when it becomes useful. What follows the
// dot is handled separately, by ExtractPartialWord, which filters the list.
var end = lastLine.Length;

while (end > 0 && (char.IsLetterOrDigit(lastLine[end - 1]) || lastLine[end - 1] == '_'))
{
end--;
}

lastLine = lastLine[..end];

// Pattern: Word1.Word2.Word3. (se termine par un point)
if (!lastLine.EndsWith('.'))
{
Expand Down
144 changes: 144 additions & 0 deletions tests/Settex.Core.Tests/Compilation/SurvivingInvariantTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
namespace Settex.Core.Tests.Compilation;

using System.Text.Json.Nodes;

using Settex.Compilation;
using Settex.Core.Evaluation;

using TUnit.Assertions;
using TUnit.Assertions.Extensions;
using TUnit.Core;

/// <summary>
/// Four invariants that mutation testing showed no test protected: each could be
/// removed from the production code with all tests still green.
/// </summary>
public class SurvivingInvariantTests
{
[Test]
public async Task Compile_LogicalAnd_DoesNotEvaluateItsRightSideWhenTheLeftIsFalseAsync()
{
// Short-circuiting is written and commented in the evaluator but nothing checked
// it. Observable only when the right side would fail: an undefined variable here
// must never be reached.
const string source = """
settings {
A = "yes" if false and undefinedVariable
}
""";

var (result, tempDir) = Compile(source);

try
{
await Assert.That(result.Success).IsTrue();
}
finally
{
Cleanup(tempDir);
}
}

[Test]
public async Task Compile_LogicalOr_DoesNotEvaluateItsRightSideWhenTheLeftIsTrueAsync()
{
const string source = """
settings {
A = "yes" if true or undefinedVariable
}
""";

var (result, tempDir) = Compile(source);

try
{
await Assert.That(result.Success).IsTrue();
}
finally
{
Cleanup(tempDir);
}
}

[Test]
public async Task Compile_TwoLetsOfTheSameName_AreRejectedAsync()
{
// A deliberate validation with a user-facing message, and no test anywhere.
var (result, tempDir) = Compile("let a = 1\nlet a = 2\nsettings { A = a }");

try
{
await Assert.That(result.Success).IsFalse();
await Assert.That(result.Errors.Any(e => e.Message.Contains("Duplicate"))).IsTrue();
}
finally
{
Cleanup(tempDir);
}
}

[Test]
public async Task Compile_GeneratedFiles_UseLineFeedEndingsAsync()
{
// The writer normalises to LF on purpose. Changing it would churn every diff on
// every platform, and nothing pinned it.
var (result, tempDir) = Compile("settings {\n A = 1\n B = 2\n}");

try
{
await Assert.That(result.Success).IsTrue();

var json = await File.ReadAllTextAsync(Path.Combine(tempDir, "output", "appsettings.json"));

await Assert.That(json).Contains("\n");
await Assert.That(json).DoesNotContain("\r\n");
}
finally
{
Cleanup(tempDir);
}
}

[Test]
public async Task Analyze_ArrayEntriesDifferingOnlyInCase_AreOneEntryAsync()
{
// The flattened entry keys are compared case-insensitively, like every other
// configuration key. Comparing them ordinally would report a field as leaking
// when the override does redefine it, only with different casing.
var model = new SettingsModel(
new JsonObject
{
["Svcs"] = new JsonArray(new JsonObject { ["Name"] = "a", ["Port"] = 1 }),
},
new()
{
["Dev"] = new JsonObject
{
["Svcs"] = new JsonArray(new JsonObject { ["name"] = "b", ["port"] = 2 }),
},
});

var diagnostics = ArrayLayeringAnalyzer.Analyze(model);

await Assert.That(diagnostics).IsEmpty();
}

private static (CompilationResult Result, string TempDir) Compile(string source)
{
var tempDir = Path.Combine(Path.GetTempPath(), "SettexInvariants", Guid.NewGuid().ToString());
Directory.CreateDirectory(tempDir);

var sourceFile = Path.Combine(tempDir, "appsettings.settex");
File.WriteAllText(sourceFile, source);

return (new SettexCompiler().Compile(sourceFile, Path.Combine(tempDir, "output")), tempDir);
}

private static void Cleanup(string directory)
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
}
32 changes: 32 additions & 0 deletions tests/Settex.LanguageServer.Tests/CompletionHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,38 @@ public async Task Completion_AfterADot_OffersThePropertiesOfThatObjectAsync()
await Assert.That(items.Any(i => i.Label == "Other")).IsFalse();
}

/// <summary>
/// The editor re-requests completion on every keystroke. Requiring the line to end
/// with a dot meant the property list appeared after "Server." and vanished the
/// moment the user typed "P" — exactly when it becomes useful. This is the case the
/// previous round could not characterise; the answer was that it did not work.
/// </summary>
[Test]
public async Task Completion_AfterADotWithAPartialWord_StillOffersAndFiltersAsync()
{
const string source = """
settings {
Server {
Host = "localhost"
Port = 8080
}
}
env "Dev" {
settings {
Server.Port = 1
}
}
""";

// Cursor after "Server.Po" (0-based line 8, column 17).
var items = await CompleteAsync(source, 8, 17);

await Assert.That(items.Any(i => i.Label == "Port")).IsTrue();

// And the list is narrowed to what matches.
await Assert.That(items.Any(i => i.Label == "Host")).IsFalse();
}

[Test]
public async Task Completion_AtTopLevel_OffersTheLanguageKeywordsAsync()
{
Expand Down
Loading