From 1f754d3b83de4756799fb359f05912715372ef6f Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 15 Nov 2026 09:31:16 -0500 Subject: [PATCH 1/3] Keep the source generator running on [Blocks(null)] and builder names that differ in case --- docs/planning/backend-technical-spec.md | 6 +- src/FluentCMS.Cli/README.md | 2 +- .../Emit/FieldsEmitter.cs | 6 +- src/FluentCMS.Generators/SymbolReader.cs | 3 +- tests/FluentCMS.Cli.Tests/GenerateTests.cs | 14 +++++ .../EmitterTests.cs | 13 ++++ .../GeneratorTests.cs | 60 +++++++++++++++++++ 7 files changed, 97 insertions(+), 7 deletions(-) diff --git a/docs/planning/backend-technical-spec.md b/docs/planning/backend-technical-spec.md index 274d07279..249e10caf 100644 --- a/docs/planning/backend-technical-spec.md +++ b/docs/planning/backend-technical-spec.md @@ -1660,7 +1660,7 @@ Codes `FCMS3350` to `FCMS3359` are the host's and the tool's (an unknown role, s - TypeScript: one module, `fluentcms.ts`, which imports `@fluentcms/client` as the namespace `fluentcms`, so no name of the schema clashes with an export: an interface per content type (extending `Entry`, with the system fields), component (`SeoComponent`) and block type (`HeroBlock`, with `type` as its literal and an optional `id`); a blocks field is the union of its block types, a relation `Relation`, the reference or the populated entry, a media field `MediaReference`, rich text `RichText`, a select the union of its options, and a localized type has `fallbacks` with its localizable fields. The builders are those the C# emitter gives, by name, with their members in camel case (`ArticleFields.sections.hero.heading.eq("x")`), and `createClient(options)` binds each content type to a collection or singleton client. - C#: a class per content type (`[ContentType("article")]`, deriving from `Entry`), component (`SeoComponent`) and block type (`HeroBlock`, deriving from the generated `Block`, which names its derived types for `type` and reads one it does not know as itself), with `JsonPropertyName` on every property; a relation is `Relation`, a media field `MediaReference`, rich text and JSON `JsonElement`, and a localized type has `{Name}Fallbacks`. The builders are those of the emitter of B-95 over the field types of `FluentCMS.Client`, so that `ArticleFields.Title.Contains("x")` reads the same in a host and in a client. Every file carries the schema hash in its header and is written only when it changed; a file with the header of an earlier run that is no longer generated, such as the class of a removed type, is removed. -- `--check` writes nothing, and exits 1 with `FCMS3372`, naming each file that differs, is missing or is no longer generated, when the files on disk are not what the schema gives. A field type without a generated type is JSON or `unknown` with `FCMS3375`, and a name that gives no identifier or is taken, in any case, gets nothing, with `FCMS3376`; both are warnings and the command exits 0. A class name is a file name, so `foo-bar` and `foobar`, `FooBar` and `Foobar`, would be one file where a file system ignores case: the second gets no type, in C# and in TypeScript alike, and a set of files whose names differ in case alone is refused before anything is written or checked (`FCMS3380`), whatever gave them ([#3070](https://github.com/fluentcms/FluentCMS/issues/3070)). A C# file of the command's that names another namespace is another run's: it is neither removed as no longer generated nor counted so by `--check`, and a run that would write over one is refused with nothing written (`FCMS3381`), so two namespaces need two folders, as the tool's README says. Other codes: `FCMS3371` for a request for the OpenAPI document of an instance that fails (with the status and the problem's code, never the key), `FCMS3373` for files that cannot be written, `FCMS3374` for a namespace that is not one and `FCMS3378` for a host that wrote no snapshot or document. Codes `FCMS3370` to `FCMS3381` of the range `FCMS3370` to `FCMS3389`. +- `--check` writes nothing, and exits 1 with `FCMS3372`, naming each file that differs, is missing or is no longer generated, when the files on disk are not what the schema gives. A field type without a generated type is JSON or `unknown` with `FCMS3375`, and a name that gives no identifier or is taken, in any case, gets nothing, with `FCMS3376`; both are warnings and the command exits 0. A class name is a file name, so `foo-bar` and `foobar`, `FooBar` and `Foobar`, would be one file where a file system ignores case: the second gets no type, in C# and in TypeScript alike, and no builder (`FCMS0100`, 6.4, [#3071](https://github.com/fluentcms/FluentCMS/issues/3071)), and a set of files whose names differ in case alone is refused before anything is written or checked (`FCMS3380`), whatever gave them ([#3070](https://github.com/fluentcms/FluentCMS/issues/3070)). A C# file of the command's that names another namespace is another run's: it is neither removed as no longer generated nor counted so by `--check`, and a run that would write over one is refused with nothing written (`FCMS3381`), so two namespaces need two folders, as the tool's README says. Other codes: `FCMS3371` for a request for the OpenAPI document of an instance that fails (with the status and the problem's code, never the key), `FCMS3373` for files that cannot be written, `FCMS3374` for a namespace that is not one and `FCMS3378` for a host that wrote no snapshot or document. Codes `FCMS3370` to `FCMS3381` of the range `FCMS3370` to `FCMS3389`. - The clients: `FluentCmsClient` over the caller's `HttpClient`, which carries the key or the bearer token, and `FluentCmsClient` of `@fluentcms/client` over `fetch` with the headers given; an answer that is not a success throws `FluentCmsProblemException` or `FluentCmsProblem` with the status, `code`, `title`, `detail`, `errors` and `traceId` of the problem. A success whose body is not JSON, such as a page of a proxy, throws the same type with the status, no code and the detail `The answer is not JSON.`, and not the exception of a JSON reader, and an empty success, whether it says so by its length or not, answers nothing ([#3070](https://github.com/fluentcms/FluentCMS/issues/3070)). A write leaves out the system fields and sends a relation or a media field as its id, and an update sends `If-Match` with the version read. Both write a query as the bracket grammar of 4.4 in the order and with the escaping of the server's writer, and are held to one list of filters and the query strings they give, `clients/filter-cases.json`, whose cases on the Blog sample's fields a test sends to its Delivery API, checking below it that the content service is given the query each case builds, with its operands as the case names them ([#3070](https://github.com/fluentcms/FluentCMS/issues/3070)), so the query strings both clients are held to read on the server as they were built. A moment is written in UTC with `Z` and no trailing zeros in its fraction, which a JavaScript `Date` and a `DateTimeOffset` both give. ### 6.2 Templates @@ -1683,13 +1683,13 @@ The `Templates` job of CI packs every package of the build with the version `1.0 `FluentCMS.Generators` emits, per content type in the host: a typed filter and sort builder (`ArticleFields.Title.Contains("x")`) and block union helpers. The field types the builders return are in the SDK. `FluentCMS.Analyzers` reports `FCMS0xxx` diagnostics: invalid attribute combinations, reserved names, slug source missing, relation to a non-content type, `Localizable` on a non-localized type, a filtered field without `Indexed` (info). Each ships as a package of its own that a host references. -**Source generator as built (B-95).** `FluentCMS.Generators` is an incremental generator that a host references as an analyzer, as the Blog sample and the reference host do. It finds the classes marked `[Collection]`, `[Singleton]`, `[Component]` or `[Block]` and reads them as the attribute reader does (4.2): the same names (`JsonNamingPolicy.CamelCase` for a field, `KebabCaseLower` for a type, ported because the generator runs on netstandard2.0), the inherited properties first, and only the properties with one field attribute and a public getter and setter. A class the reader refuses gets no builder and no word from the generator, since the host reports it at start and the analyzers in the editor. It emits, in the namespace of the class: +**Source generator as built (B-95).** `FluentCMS.Generators` is an incremental generator that a host references as an analyzer, as the Blog sample and the reference host do. It finds the classes marked `[Collection]`, `[Singleton]`, `[Component]` or `[Block]` and reads them as the attribute reader does (4.2): the same names (`JsonNamingPolicy.CamelCase` for a field, `KebabCaseLower` for a type, ported because the generator runs on netstandard2.0), the inherited properties first, and only the properties with one field attribute and a public getter and setter. A class the reader refuses gets no builder and no word from the generator, since the host reports it at start and the analyzers in the editor, and so does a field it refuses or stops on, such as `[Blocks(null)]`, which leaves its class a builder without that member and the other classes theirs ([#3071](https://github.com/fluentcms/FluentCMS/issues/3071)). It emits, in the namespace of the class: - `{Type}Fields` for a collection or singleton, a static class with a member per field, so that `ArticleFields.Title.Contains("x")` gives the same `FieldFilter` as `new FieldFilter("title", FilterOperator.Contains, "x")` and `ArticleFields.PublishedOn.Descending()` a `SortField`. - `{Name}ComponentFields` for a component, whose members carry the path of the field that holds it: `ArticleFields.Seo.MetaTitle` is `seo.metaTitle`, at any depth of single components. A repeated component is JSON a filter cannot reach and gets no member. - `{Name}BlockFields` for a block type, with a member for each scalar field one level inside the list (Q7), and for each blocks field a nested class over `BlocksField` with the block types of the field and their presence: `ArticleFields.Sections.Hero.Heading.Eq("x")` is `sections.hero.heading`, `ArticleFields.Sections.HasHero()` is `hasBlock` with `hero`. -The class names come from the schema names, not the CLR names, so that a client emits the same code from a snapshot; the suffixes keep a component or a block from taking the name of a collection, and a name that still clashes, such as a collection `x-block` beside a block `x`, gives the builder to the first and reports the other with `FCMS0100`. A block type whose name has a hyphen gets no builder (`FCMS0104`), because a segment of a filter path holds only letters, digits and underscores; its block lists still have `Has{Name}()`. The members are named from the field names. The builders are public, so a library that declares content types shares its builders with the host. +The class names come from the schema names, not the CLR names, so that a client emits the same code from a snapshot; the suffixes keep a component or a block from taking the name of a collection, and a name that still clashes, such as a collection `x-block` beside a block `x`, gives the builder to the first and reports the other with `FCMS0100`. Class names are compared ignoring case, as Roslyn compares the names of sources, each named after its class, and as `generate` compares its files: `x-y` and `xy` would give `XYFields` and `XyFields`, and only `x-y` gets a builder ([#3071](https://github.com/fluentcms/FluentCMS/issues/3071)). A block type whose name has a hyphen gets no builder (`FCMS0104`), because a segment of a filter path holds only letters, digits and underscores; its block lists still have `Has{Name}()`. The members are named from the field names. The builders are public, so a library that declares content types shares its builders with the host. The field types are in the SDK, in `FluentCMS.Content`, each a `QueryField` with its path and only the operators the engine accepts for its kind: `TextField` (`text`, `slug`, `email`, `url`), `LongTextField`, `RichTextField`, `NumberField` (`long` for `integer`, `decimal`), `DateField` (`DateOnly` for `date`, `DateTimeOffset` for `dateTime`), `BooleanField`, `SelectField` and `SelectListField` for a single and a multiple select, `RelationField` and `MediaField` by id, `JsonField` with `Key(name)` giving a `JsonKeyField`, `BlocksField`, and inside a block `BlockTextField`, `BlockValueField` and `BlockContainsField`. A field that the engine can sort has `Ascending()` and `Descending()`. A select takes the option as the API shows it (`video`), not the enum member. System fields such as `createdAt` have no builder yet. diff --git a/src/FluentCMS.Cli/README.md b/src/FluentCMS.Cli/README.md index 41a4ad1cf..566010da8 100644 --- a/src/FluentCMS.Cli/README.md +++ b/src/FluentCMS.Cli/README.md @@ -10,6 +10,6 @@ dotnet fluentcms migrations add Initial dotnet fluentcms migrate ``` -`generate csharp --namespace --output ` writes a file for each class into the folder and removes the files it wrote before and no longer generates. A folder holds the code of one namespace: a run with another `--namespace` neither removes nor writes over the files of the first, and is refused with `FCMS3381` when it would write one of them. Generate each namespace into a folder of its own, and to change the namespace of a folder, remove its generated files first. Two classes whose names differ in case alone would be one file where the file system ignores case, so the second type gets no class (`FCMS3376`), and files whose names differ in case alone are never written (`FCMS3380`). +`generate csharp --namespace --output ` writes a file for each class into the folder and removes the files it wrote before and no longer generates. A folder holds the code of one namespace: a run with another `--namespace` neither removes nor writes over the files of the first, and is refused with `FCMS3381` when it would write one of them. Generate each namespace into a folder of its own, and to change the namespace of a folder, remove its generated files first. Two classes whose names differ in case alone would be one file where the file system ignores case, so the second type gets no class (`FCMS3376`) and no builder (`FCMS0100`), and files whose names differ in case alone are never written (`FCMS3380`). [Run with Docker](https://github.com/fluentcms/FluentCMS/blob/main/docs/docker.md#administer-an-instance) lists the commands, which the image runs too. diff --git a/src/FluentCMS.Generators/Emit/FieldsEmitter.cs b/src/FluentCMS.Generators/Emit/FieldsEmitter.cs index 633bce4a7..1f2a1d3e8 100644 --- a/src/FluentCMS.Generators/Emit/FieldsEmitter.cs +++ b/src/FluentCMS.Generators/Emit/FieldsEmitter.cs @@ -112,8 +112,10 @@ public EmitResult Emit(IEnumerable types) .ThenBy(type => type.Namespace, StringComparer.Ordinal) .ToList(); + // A source is named after its class, and Roslyn compares the names of sources ignoring case, as the tool compares + // its files, so XYFields and XyFields are one name. List builders = []; - var classNames = new HashSet(StringComparer.Ordinal); + var classNames = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var type in ordered) { var group = GroupOf(type.Kind); @@ -140,7 +142,7 @@ public EmitResult Emit(IEnumerable types) var builder = new Builder(type, className); if (_builders.ContainsKey((group, type.Name)) || !classNames.Add(builder.FullName)) { - Report(EmitCodes.TypeSkipped, $"The {Label(type.Kind)} '{type.Name}' gets no field builder, because {className} is the builder of another type.", type, null); + Report(EmitCodes.TypeSkipped, $"The {Label(type.Kind)} '{type.Name}' gets no field builder, because {className} is the builder of another type, in this case or another.", type, null); continue; } diff --git a/src/FluentCMS.Generators/SymbolReader.cs b/src/FluentCMS.Generators/SymbolReader.cs index cddffac10..31a72cc0e 100644 --- a/src/FluentCMS.Generators/SymbolReader.cs +++ b/src/FluentCMS.Generators/SymbolReader.cs @@ -173,9 +173,10 @@ private static IEnumerable Properties(INamedTypeSymbol type) ? new FieldDescription(name, fieldType, multiple, NameOf(component), EquatableArray.Empty) : null; + // [Blocks(null)] gives an array whose Values throw when enumerated; the attribute reader stops on it at start. case "blocks": List blocks = []; - foreach (var argument in attribute.ConstructorArguments.Where(argument => argument.Kind == TypedConstantKind.Array).SelectMany(argument => argument.Values)) + foreach (var argument in attribute.ConstructorArguments.Where(argument => argument.Kind == TypedConstantKind.Array && !argument.IsNull).SelectMany(argument => argument.Values)) { if (argument.Value is not INamedTypeSymbol block || KindOf(block)?.Kind != BuilderKind.Block) { diff --git a/tests/FluentCMS.Cli.Tests/GenerateTests.cs b/tests/FluentCMS.Cli.Tests/GenerateTests.cs index d7d2eb58c..2d032253a 100644 --- a/tests/FluentCMS.Cli.Tests/GenerateTests.cs +++ b/tests/FluentCMS.Cli.Tests/GenerateTests.cs @@ -349,6 +349,20 @@ public void TwoTypesWhoseClassNamesDifferInCaseAloneGetOneClass() typeScript.Diagnostics.ShouldContain(diagnostic => diagnostic.Code == "FCMS3376" && diagnostic.Message.Contains("'foobar'", StringComparison.Ordinal)); } + // The second type gets no builder either, so the files of such a schema are written. + [Fact] + public async Task TwoTypesWhoseBuilderNamesDifferInCaseAloneGetOneBuilderAndAreWritten() + { + var directory = Out("builders"); + var schema = WriteSnapshot("""{ "collections": [ { "name": "foo-bar", "fields": [ { "name": "title", "type": "text" } ] }, { "name": "foobar", "fields": [ { "name": "title", "type": "text" } ] } ] }"""); + + var result = await blog.RunAsync(["generate", "csharp", "--schema", schema, "--namespace", "Cases", "--output", directory]); + + result.ExitCode.ShouldBe(0, result.Error); + Directory.EnumerateFiles(directory).Select(Path.GetFileName).Order(StringComparer.Ordinal).ShouldBe(["FooBar.g.cs", "FooBarFields.g.cs"]); + result.Error.ShouldContain("FCMS0100: The collection 'foobar' gets no field builder"); + } + // The identifiers TypeScript reserves, createClient and schemaHash, are no names of files, so they are taken as written // and the types create-client and schema-hash keep their classes, in TypeScript as in C#. [Fact] diff --git a/tests/FluentCMS.Generators.Tests/EmitterTests.cs b/tests/FluentCMS.Generators.Tests/EmitterTests.cs index 52e54ea79..ef8024340 100644 --- a/tests/FluentCMS.Generators.Tests/EmitterTests.cs +++ b/tests/FluentCMS.Generators.Tests/EmitterTests.cs @@ -47,6 +47,19 @@ public void TwoTypesWithOneBuilderNameKeepTheFirst() diagnostic.TypeName.ShouldBe("a1"); } + // The generator's sources and the tool's files are named after the classes and compared ignoring case. + [Fact] + public void TwoBuilderNamesThatDifferInCaseAloneKeepTheFirst() + { + var result = FieldsEmitter.Emit( + [Type("xy", BuilderKind.Collection), Type("x-y", BuilderKind.Collection), Type("xy", BuilderKind.Component), Type("x-y", BuilderKind.Component)], + EmitOptions.Sdk); + + result.Sources.Select(source => source.HintName).ShouldBe(["Client.XYFields.g.cs", "Client.XYComponentFields.g.cs"]); + result.Diagnostics.Select(diagnostic => (diagnostic.Code, diagnostic.Kind, diagnostic.TypeName)) + .ShouldBe([("FCMS0100", BuilderKind.Collection, "xy"), ("FCMS0100", BuilderKind.Component, "xy")]); + } + [Fact] public void ATypeWhoseNameGivesNoClassNameIsReported() { diff --git a/tests/FluentCMS.Generators.Tests/GeneratorTests.cs b/tests/FluentCMS.Generators.Tests/GeneratorTests.cs index 0b784fe95..b36caf004 100644 --- a/tests/FluentCMS.Generators.Tests/GeneratorTests.cs +++ b/tests/FluentCMS.Generators.Tests/GeneratorTests.cs @@ -309,6 +309,66 @@ public sealed class Article : ContentItem diagnostic.Location.GetLineSpan().StartLinePosition.Line.ShouldBe(13); } + // The names of the sources are compared ignoring case, so x-y and xy, XYFields and XyFields, would be one source. + [Fact] + public void TwoBuildersWhoseNamesDifferInCaseAloneKeepTheFirstAndStopNoOther() + { + const string source = """ + using FluentCMS.Schema; + + namespace Shop; + + [Collection("x-y")] + public sealed class First : ContentItem + { + } + + [Collection("xy")] + public sealed class Second : ContentItem + { + } + """; + + var result = Generation.Run(Generation.Compile(("Shop.cs", source), ("Product.cs", Product))); + + result.Exception.ShouldBeNull(); + result.GeneratedSources.Select(generated => generated.HintName).ShouldBe(["Shop.ProductFields.g.cs", "Shop.XYFields.g.cs", "Shop.SizeComponentFields.g.cs"], ignoreOrder: true); + var diagnostic = result.Diagnostics.ShouldHaveSingleItem(); + diagnostic.Id.ShouldBe("FCMS0100"); + diagnostic.GetMessage(System.Globalization.CultureInfo.InvariantCulture).ShouldContain("'xy'"); + diagnostic.Location.GetLineSpan().StartLinePosition.Line.ShouldBe(10); + } + + // [Blocks(null)] stops the attribute reader at start, so the field gets no builder and no word. + [Fact] + public void ABlocksFieldWithoutAListOfBlockTypesGetsNoBuilderAndStopsNoOther() + { + const string source = """ + using FluentCMS.Schema; + + namespace Shop; + + [Collection("page")] + public sealed class Page : ContentItem + { + [Text] + public string? Title { get; set; } + + [Blocks(null)] + public BlockList Body { get; set; } = []; + } + """; + + var result = Generation.Run(Generation.Compile(("Page.cs", source), ("Product.cs", Product))); + + result.Exception.ShouldBeNull(); + result.Diagnostics.ShouldBeEmpty(); + result.GeneratedSources.Select(generated => generated.HintName).ShouldBe(["Shop.PageFields.g.cs", "Shop.ProductFields.g.cs", "Shop.SizeComponentFields.g.cs"], ignoreOrder: true); + var text = Generation.Generated(result, "Shop.PageFields.g.cs"); + text.ShouldContain("TextField Title"); + text.ShouldNotContain("Body"); + } + [Fact] public void AClassTheAttributeReaderRefusesGetsNoBuilder() { From 8fedacfff71341d93ca41dce802f023c94e8b719 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 15 Nov 2026 09:57:33 -0500 Subject: [PATCH 2/3] Refuse [Blocks(null)] with FCMS1211 and keep a hyphenated block's name for its builder --- docs/analyzers.md | 2 +- docs/planning/backend-technical-spec.md | 4 ++-- src/FluentCMS.Analyzers/SchemaReader.cs | 10 ++++++---- .../Schema/AttributeSchemaReader.cs | 3 ++- src/FluentCMS.Generators/Emit/FieldsEmitter.cs | 14 ++++++++------ src/FluentCMS.Generators/SymbolReader.cs | 2 +- tests/FluentCMS.Cli.Tests/GenerateTests.cs | 17 +++++++++++++++++ .../Schema/AttributeReaderTests.cs | 13 +++++++++++++ .../Analyzers/CombinationRuleTests.cs | 13 ++++++++++--- .../FluentCMS.Generators.Tests/EmitterTests.cs | 10 ++++++++++ .../GeneratorTests.cs | 2 +- 11 files changed, 71 insertions(+), 19 deletions(-) diff --git a/docs/analyzers.md b/docs/analyzers.md index 5c72aee1f..0dd76b634 100644 --- a/docs/analyzers.md +++ b/docs/analyzers.md @@ -60,7 +60,7 @@ Error, for `FCMS1210`. A `[Component]` property holds a class, or a list of a cl ### FCMS0011 -Error, for `FCMS1211`. `[Blocks]` lists no block types. List them, as in `[Blocks(typeof(HeroBlock))]`. +Error, for `FCMS1211`. `[Blocks]` lists no block types, or is given `null` for them. List them, as in `[Blocks(typeof(HeroBlock))]`. ### FCMS0012 diff --git a/docs/planning/backend-technical-spec.md b/docs/planning/backend-technical-spec.md index 249e10caf..135ee3e6d 100644 --- a/docs/planning/backend-technical-spec.md +++ b/docs/planning/backend-technical-spec.md @@ -1683,13 +1683,13 @@ The `Templates` job of CI packs every package of the build with the version `1.0 `FluentCMS.Generators` emits, per content type in the host: a typed filter and sort builder (`ArticleFields.Title.Contains("x")`) and block union helpers. The field types the builders return are in the SDK. `FluentCMS.Analyzers` reports `FCMS0xxx` diagnostics: invalid attribute combinations, reserved names, slug source missing, relation to a non-content type, `Localizable` on a non-localized type, a filtered field without `Indexed` (info). Each ships as a package of its own that a host references. -**Source generator as built (B-95).** `FluentCMS.Generators` is an incremental generator that a host references as an analyzer, as the Blog sample and the reference host do. It finds the classes marked `[Collection]`, `[Singleton]`, `[Component]` or `[Block]` and reads them as the attribute reader does (4.2): the same names (`JsonNamingPolicy.CamelCase` for a field, `KebabCaseLower` for a type, ported because the generator runs on netstandard2.0), the inherited properties first, and only the properties with one field attribute and a public getter and setter. A class the reader refuses gets no builder and no word from the generator, since the host reports it at start and the analyzers in the editor, and so does a field it refuses or stops on, such as `[Blocks(null)]`, which leaves its class a builder without that member and the other classes theirs ([#3071](https://github.com/fluentcms/FluentCMS/issues/3071)). It emits, in the namespace of the class: +**Source generator as built (B-95).** `FluentCMS.Generators` is an incremental generator that a host references as an analyzer, as the Blog sample and the reference host do. It finds the classes marked `[Collection]`, `[Singleton]`, `[Component]` or `[Block]` and reads them as the attribute reader does (4.2): the same names (`JsonNamingPolicy.CamelCase` for a field, `KebabCaseLower` for a type, ported because the generator runs on netstandard2.0), the inherited properties first, and only the properties with one field attribute and a public getter and setter. A class the reader refuses gets no builder and no word from the generator, since the host reports it at start and the analyzers in the editor. A field the reader refuses gets no member and no word either, and its class keeps a builder: `[Blocks(null)]`, which the host refuses with `FCMS1211` and the analyzers with `FCMS0011`, as for `[Blocks]` with no types, leaves the other members and the other classes their builders ([#3071](https://github.com/fluentcms/FluentCMS/issues/3071)). It emits, in the namespace of the class: - `{Type}Fields` for a collection or singleton, a static class with a member per field, so that `ArticleFields.Title.Contains("x")` gives the same `FieldFilter` as `new FieldFilter("title", FilterOperator.Contains, "x")` and `ArticleFields.PublishedOn.Descending()` a `SortField`. - `{Name}ComponentFields` for a component, whose members carry the path of the field that holds it: `ArticleFields.Seo.MetaTitle` is `seo.metaTitle`, at any depth of single components. A repeated component is JSON a filter cannot reach and gets no member. - `{Name}BlockFields` for a block type, with a member for each scalar field one level inside the list (Q7), and for each blocks field a nested class over `BlocksField` with the block types of the field and their presence: `ArticleFields.Sections.Hero.Heading.Eq("x")` is `sections.hero.heading`, `ArticleFields.Sections.HasHero()` is `hasBlock` with `hero`. -The class names come from the schema names, not the CLR names, so that a client emits the same code from a snapshot; the suffixes keep a component or a block from taking the name of a collection, and a name that still clashes, such as a collection `x-block` beside a block `x`, gives the builder to the first and reports the other with `FCMS0100`. Class names are compared ignoring case, as Roslyn compares the names of sources, each named after its class, and as `generate` compares its files: `x-y` and `xy` would give `XYFields` and `XyFields`, and only `x-y` gets a builder ([#3071](https://github.com/fluentcms/FluentCMS/issues/3071)). A block type whose name has a hyphen gets no builder (`FCMS0104`), because a segment of a filter path holds only letters, digits and underscores; its block lists still have `Has{Name}()`. The members are named from the field names. The builders are public, so a library that declares content types shares its builders with the host. +The class names come from the schema names, not the CLR names, so that a client emits the same code from a snapshot; the suffixes keep a component or a block from taking the name of a collection, and a name that still clashes, such as a collection `x-block` beside a block `x`, gives the builder to the first and reports the other with `FCMS0100`. Class names are compared ignoring case, as Roslyn compares the names of sources, each named after its class, and as `generate` compares its files: `x-y` and `xy` would give `XYFields` and `XyFields`, and only `x-y` gets a builder ([#3071](https://github.com/fluentcms/FluentCMS/issues/3071)). A block type whose name has a hyphen gets no builder (`FCMS0104`), because a segment of a filter path holds only letters, digits and underscores; its block lists still have `Has{Name}()`. It still takes its class name, as it takes its class in `generate`, so that the class and the builder of a name belong to one type: beside the block type `a-b`, `ab` gets no builder (`FCMS0100`), as it gets no class (`FCMS3376`) ([#3071](https://github.com/fluentcms/FluentCMS/issues/3071)). The members are named from the field names. The builders are public, so a library that declares content types shares its builders with the host. The field types are in the SDK, in `FluentCMS.Content`, each a `QueryField` with its path and only the operators the engine accepts for its kind: `TextField` (`text`, `slug`, `email`, `url`), `LongTextField`, `RichTextField`, `NumberField` (`long` for `integer`, `decimal`), `DateField` (`DateOnly` for `date`, `DateTimeOffset` for `dateTime`), `BooleanField`, `SelectField` and `SelectListField` for a single and a multiple select, `RelationField` and `MediaField` by id, `JsonField` with `Key(name)` giving a `JsonKeyField`, `BlocksField`, and inside a block `BlockTextField`, `BlockValueField` and `BlockContainsField`. A field that the engine can sort has `Ascending()` and `Descending()`. A select takes the option as the API shows it (`video`), not the enum member. System fields such as `createdAt` have no builder yet. diff --git a/src/FluentCMS.Analyzers/SchemaReader.cs b/src/FluentCMS.Analyzers/SchemaReader.cs index 95d8c2530..06c66076a 100644 --- a/src/FluentCMS.Analyzers/SchemaReader.cs +++ b/src/FluentCMS.Analyzers/SchemaReader.cs @@ -272,8 +272,9 @@ private static bool ReadFieldType(SchemaField field, string location, List Array(AttributeData attribute) => - attribute.ConstructorArguments.Length == 1 && attribute.ConstructorArguments[0].Kind == TypedConstantKind.Array + attribute.ConstructorArguments.Length == 1 && attribute.ConstructorArguments[0] is { Kind: TypedConstantKind.Array, IsNull: false } ? attribute.ConstructorArguments[0].Values : ImmutableArray.Empty; diff --git a/src/FluentCMS.Core/Schema/AttributeSchemaReader.cs b/src/FluentCMS.Core/Schema/AttributeSchemaReader.cs index 4e317a60e..7f9607b45 100644 --- a/src/FluentCMS.Core/Schema/AttributeSchemaReader.cs +++ b/src/FluentCMS.Core/Schema/AttributeSchemaReader.cs @@ -350,7 +350,8 @@ private static void ReadRelation(RelationAttribute relation, PropertyInfo proper private void ReadBlocks(BlocksAttribute blocks, string location, List> steps, Action fail) { - if (blocks.BlockTypes.Count == 0) + // [Blocks(null)] gives no list at all. + if (blocks.BlockTypes is not { Count: > 0 }) { fail(SchemaErrorCodes.InvalidField, $"blocks field {location} must list the block types it accepts"); return; diff --git a/src/FluentCMS.Generators/Emit/FieldsEmitter.cs b/src/FluentCMS.Generators/Emit/FieldsEmitter.cs index 1f2a1d3e8..df02e8ec6 100644 --- a/src/FluentCMS.Generators/Emit/FieldsEmitter.cs +++ b/src/FluentCMS.Generators/Emit/FieldsEmitter.cs @@ -132,17 +132,19 @@ public EmitResult Emit(IEnumerable types) _ => "Fields", }; - // A filter path names the block type as a segment, which may hold only letters, digits and underscores. - if (group == Group.Block && type.Name.Contains('-')) + // The name is taken before a block type with a hyphen is left out, since the tool gives that type its class: + // the class and the builder of a name then belong to one type. + var builder = new Builder(type, className); + if (_builders.ContainsKey((group, type.Name)) || !classNames.Add(builder.FullName)) { - Report(EmitCodes.BlockNotReachable, $"The block type '{type.Name}' gets no field builder, because a filter path cannot name a block type with a hyphen; a block list still has Has{Pascal(type.Name)}().", type, null); + Report(EmitCodes.TypeSkipped, $"The {Label(type.Kind)} '{type.Name}' gets no field builder, because another type takes the name {className}, in this case or another.", type, null); continue; } - var builder = new Builder(type, className); - if (_builders.ContainsKey((group, type.Name)) || !classNames.Add(builder.FullName)) + // A filter path names the block type as a segment, which may hold only letters, digits and underscores. + if (group == Group.Block && type.Name.Contains('-')) { - Report(EmitCodes.TypeSkipped, $"The {Label(type.Kind)} '{type.Name}' gets no field builder, because {className} is the builder of another type, in this case or another.", type, null); + Report(EmitCodes.BlockNotReachable, $"The block type '{type.Name}' gets no field builder, because a filter path cannot name a block type with a hyphen; a block list still has Has{Pascal(type.Name)}().", type, null); continue; } diff --git a/src/FluentCMS.Generators/SymbolReader.cs b/src/FluentCMS.Generators/SymbolReader.cs index 31a72cc0e..1ae643514 100644 --- a/src/FluentCMS.Generators/SymbolReader.cs +++ b/src/FluentCMS.Generators/SymbolReader.cs @@ -173,7 +173,7 @@ private static IEnumerable Properties(INamedTypeSymbol type) ? new FieldDescription(name, fieldType, multiple, NameOf(component), EquatableArray.Empty) : null; - // [Blocks(null)] gives an array whose Values throw when enumerated; the attribute reader stops on it at start. + // [Blocks(null)] gives an array whose Values throw when enumerated; the host refuses it at start. case "blocks": List blocks = []; foreach (var argument in attribute.ConstructorArguments.Where(argument => argument.Kind == TypedConstantKind.Array && !argument.IsNull).SelectMany(argument => argument.Values)) diff --git a/tests/FluentCMS.Cli.Tests/GenerateTests.cs b/tests/FluentCMS.Cli.Tests/GenerateTests.cs index 2d032253a..9d763f217 100644 --- a/tests/FluentCMS.Cli.Tests/GenerateTests.cs +++ b/tests/FluentCMS.Cli.Tests/GenerateTests.cs @@ -363,6 +363,23 @@ public async Task TwoTypesWhoseBuilderNamesDifferInCaseAloneGetOneBuilderAndAreW result.Error.ShouldContain("FCMS0100: The collection 'foobar' gets no field builder"); } + // The block type a-b has a class and, for its hyphen, no builder; it takes its name from ab in both, so ab gets neither + // its class nor its builder, in C# and in TypeScript alike. + [Fact] + public void ABlockTypeWithAHyphenTakesTheNameOfItsClassAndOfItsBuilder() + { + var schema = BlogInstance.Schema("""{ "blocks": [ { "name": "a-b", "fields": [ { "name": "label", "type": "text" } ] }, { "name": "ab", "fields": [ { "name": "label", "type": "text" } ] } ] }"""); + + var csharp = CSharpEmitter.Emit(schema, "Cases"); + var typeScript = TypeScriptEmitter.Emit(schema).Files.Single().Text; + + csharp.Files.Select(file => file.Name).Order(StringComparer.Ordinal).ShouldBe(["ABBlock.g.cs", "Block.g.cs"]); + csharp.Diagnostics.ShouldContain(diagnostic => diagnostic.Code == "FCMS0100" && diagnostic.Message.Contains("'ab'", StringComparison.Ordinal)); + csharp.Diagnostics.ShouldContain(diagnostic => diagnostic.Code == "FCMS3376" && diagnostic.Message.Contains("'ab'", StringComparison.Ordinal)); + typeScript.ShouldContain("export interface ABBlock ", Case.Sensitive); + typeScript.ShouldNotContain("AbBlock", Case.Sensitive); + } + // The identifiers TypeScript reserves, createClient and schemaHash, are no names of files, so they are taken as written // and the types create-client and schema-hash keep their classes, in TypeScript as in C#. [Fact] diff --git a/tests/FluentCMS.Core.Tests/Schema/AttributeReaderTests.cs b/tests/FluentCMS.Core.Tests/Schema/AttributeReaderTests.cs index 5d48cbb41..79e63db8b 100644 --- a/tests/FluentCMS.Core.Tests/Schema/AttributeReaderTests.cs +++ b/tests/FluentCMS.Core.Tests/Schema/AttributeReaderTests.cs @@ -243,6 +243,12 @@ public void BlocksFieldWithoutBlocksIsFCMS1211() ShouldReport(typeof(EmptyBlocks), "FCMS1211", "blocks field EmptyBlocks.Sections must list the block types"); } + [Fact] + public void BlocksFieldWithANullListIsFCMS1211() + { + ShouldReport(typeof(NullBlocks), "FCMS1211", "blocks field NullBlocks.Sections must list the block types"); + } + [Fact] public void SelectWithoutOptionsOnAStringIsFCMS1216() { @@ -452,6 +458,13 @@ private sealed class EmptyBlocks : ContentItem public BlockList Sections { get; set; } = []; } + [Collection] + private sealed class NullBlocks : ContentItem + { + [Blocks(null!)] + public BlockList Sections { get; set; } = []; + } + [Collection] private sealed class SelectWithoutOptions : ContentItem { diff --git a/tests/FluentCMS.Generators.Tests/Analyzers/CombinationRuleTests.cs b/tests/FluentCMS.Generators.Tests/Analyzers/CombinationRuleTests.cs index d2ff27e93..2989c8518 100644 --- a/tests/FluentCMS.Generators.Tests/Analyzers/CombinationRuleTests.cs +++ b/tests/FluentCMS.Generators.Tests/Analyzers/CombinationRuleTests.cs @@ -219,13 +219,11 @@ public sealed class Page : ContentItem public sealed class LeftToTheHostTests { [Fact] - public Task ANullListOfOptionsOrBlockTypesIsNotReported() => Analysis.Verify(Analysis.Usings + """ + public Task ANullListOfOptionsIsNotReported() => Analysis.Verify(Analysis.Usings + """ [Collection("page")] public sealed class Page : ContentItem { [Select(null!)] public string Kind { get; set; } = ""; - - [Blocks(null!)] public BlockList Body { get; set; } = new(); } """); @@ -319,6 +317,15 @@ public sealed class Page : ContentItem } """, "FCMS1211"); + [Fact] + public Task BlocksWithANullListAreReported() => Analysis.Reports(""" + [Collection("page")] + public sealed class Page : ContentItem + { + [{|FCMS0011:Blocks(null!)|}] public BlockList Body { get; set; } = new(); + } + """, "FCMS1211"); + [Fact] public Task BlocksWithATypeAreAccepted() => Analysis.Accepts(""" [Block("hero")] diff --git a/tests/FluentCMS.Generators.Tests/EmitterTests.cs b/tests/FluentCMS.Generators.Tests/EmitterTests.cs index ef8024340..6433e0107 100644 --- a/tests/FluentCMS.Generators.Tests/EmitterTests.cs +++ b/tests/FluentCMS.Generators.Tests/EmitterTests.cs @@ -108,6 +108,16 @@ public void ABlockTypeWithAHyphenKeepsOnlyItsPresenceHelper() (diagnostic.Code, diagnostic.TypeName, diagnostic.Kind).ShouldBe(("FCMS0104", "call-to-action", BuilderKind.Block)); } + // a-b takes ABBlockFields though it gets no builder, as it takes ABBlock in the tool, so ab gets neither. + [Fact] + public void ABlockTypeWithAHyphenTakesItsNameFromAnotherInAnyCase() + { + var result = FieldsEmitter.Emit([Type("ab", BuilderKind.Block, Field("label", "text")), Type("a-b", BuilderKind.Block, Field("label", "text"))], EmitOptions.Sdk); + + result.Sources.ShouldBeEmpty(); + result.Diagnostics.Select(diagnostic => (diagnostic.Code, diagnostic.TypeName)).ShouldBe([("FCMS0104", "a-b"), ("FCMS0100", "ab")]); + } + [Fact] public void AMemberNameThatClashesWithObjectOrTheBuilderIsReported() { diff --git a/tests/FluentCMS.Generators.Tests/GeneratorTests.cs b/tests/FluentCMS.Generators.Tests/GeneratorTests.cs index b36caf004..424739206 100644 --- a/tests/FluentCMS.Generators.Tests/GeneratorTests.cs +++ b/tests/FluentCMS.Generators.Tests/GeneratorTests.cs @@ -339,7 +339,7 @@ public sealed class Second : ContentItem diagnostic.Location.GetLineSpan().StartLinePosition.Line.ShouldBe(10); } - // [Blocks(null)] stops the attribute reader at start, so the field gets no builder and no word. + // The host refuses [Blocks(null)] at start and the analyzers in the editor, so the field gets no builder and no word. [Fact] public void ABlocksFieldWithoutAListOfBlockTypesGetsNoBuilderAndStopsNoOther() { From 55ba0700111a1c44d635c2d59f624eac89480505 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 15 Nov 2026 10:07:11 -0500 Subject: [PATCH 3/3] Refuse [Select(null)] with FCMS1216 and report it in the analyzers --- docs/analyzers.md | 4 +-- docs/planning/backend-technical-spec.md | 2 +- src/FluentCMS.Analyzers/SchemaReader.cs | 11 ++----- .../Schema/AttributeSchemaReader.cs | 5 ++-- .../Schema/AttributeReaderTests.cs | 21 ++++++++++++++ .../Analyzers/CombinationRuleTests.cs | 29 +++++++++++++------ 6 files changed, 50 insertions(+), 22 deletions(-) diff --git a/docs/analyzers.md b/docs/analyzers.md index 0dd76b634..85b1d1c97 100644 --- a/docs/analyzers.md +++ b/docs/analyzers.md @@ -44,11 +44,11 @@ Error, for `FCMS1216`. A field property has no public getter or no public setter ### FCMS0007 -Error, for `FCMS1216`. `[Select]` on an enum lists options. The members of the enum are the options; remove the list. +Error, for `FCMS1216`. `[Select]` on an enum lists options, or is given `null` for them. The members of the enum are the options; remove the list. ### FCMS0008 -Error, for `FCMS1216`. `[Select]` on a property that is not an enum lists no options. List them, as in `[Select("news", "blog")]`. +Error, for `FCMS1216`. `[Select]` on a property that is not an enum lists no options, or is given `null` for them. List them, as in `[Select("news", "blog")]`. ### FCMS0009 diff --git a/docs/planning/backend-technical-spec.md b/docs/planning/backend-technical-spec.md index 135ee3e6d..8f55b82f5 100644 --- a/docs/planning/backend-technical-spec.md +++ b/docs/planning/backend-technical-spec.md @@ -1683,7 +1683,7 @@ The `Templates` job of CI packs every package of the build with the version `1.0 `FluentCMS.Generators` emits, per content type in the host: a typed filter and sort builder (`ArticleFields.Title.Contains("x")`) and block union helpers. The field types the builders return are in the SDK. `FluentCMS.Analyzers` reports `FCMS0xxx` diagnostics: invalid attribute combinations, reserved names, slug source missing, relation to a non-content type, `Localizable` on a non-localized type, a filtered field without `Indexed` (info). Each ships as a package of its own that a host references. -**Source generator as built (B-95).** `FluentCMS.Generators` is an incremental generator that a host references as an analyzer, as the Blog sample and the reference host do. It finds the classes marked `[Collection]`, `[Singleton]`, `[Component]` or `[Block]` and reads them as the attribute reader does (4.2): the same names (`JsonNamingPolicy.CamelCase` for a field, `KebabCaseLower` for a type, ported because the generator runs on netstandard2.0), the inherited properties first, and only the properties with one field attribute and a public getter and setter. A class the reader refuses gets no builder and no word from the generator, since the host reports it at start and the analyzers in the editor. A field the reader refuses gets no member and no word either, and its class keeps a builder: `[Blocks(null)]`, which the host refuses with `FCMS1211` and the analyzers with `FCMS0011`, as for `[Blocks]` with no types, leaves the other members and the other classes their builders ([#3071](https://github.com/fluentcms/FluentCMS/issues/3071)). It emits, in the namespace of the class: +**Source generator as built (B-95).** `FluentCMS.Generators` is an incremental generator that a host references as an analyzer, as the Blog sample and the reference host do. It finds the classes marked `[Collection]`, `[Singleton]`, `[Component]` or `[Block]` and reads them as the attribute reader does (4.2): the same names (`JsonNamingPolicy.CamelCase` for a field, `KebabCaseLower` for a type, ported because the generator runs on netstandard2.0), the inherited properties first, and only the properties with one field attribute and a public getter and setter. A class the reader refuses gets no builder and no word from the generator, since the host reports it at start and the analyzers in the editor. Nor does it report a field the reader refuses, such as `[Blocks(null)]`, which the host refuses with `FCMS1211` and the analyzers with `FCMS0011`, as for `[Blocks]` with no types, or `[Select(null)]`, which the host refuses with `FCMS1216` and the analyzers with `FCMS0007` on an enum and `FCMS0008` elsewhere, as for a list on an enum and no options elsewhere. A blocks field it cannot read gets no member, and the other members and classes keep their builders ([#3071](https://github.com/fluentcms/FluentCMS/issues/3071)). It emits, in the namespace of the class: - `{Type}Fields` for a collection or singleton, a static class with a member per field, so that `ArticleFields.Title.Contains("x")` gives the same `FieldFilter` as `new FieldFilter("title", FilterOperator.Contains, "x")` and `ArticleFields.PublishedOn.Descending()` a `SortField`. - `{Name}ComponentFields` for a component, whose members carry the path of the field that holds it: `ArticleFields.Seo.MetaTitle` is `seo.metaTitle`, at any depth of single components. A repeated component is JSON a filter cannot reach and gets no member. diff --git a/src/FluentCMS.Analyzers/SchemaReader.cs b/src/FluentCMS.Analyzers/SchemaReader.cs index 06c66076a..acb33e591 100644 --- a/src/FluentCMS.Analyzers/SchemaReader.cs +++ b/src/FluentCMS.Analyzers/SchemaReader.cs @@ -272,13 +272,6 @@ private static bool ReadFieldType(SchemaField field, string location, List 0) + var isNull = attribute.ConstructorArguments is { Length: 1 } arguments && arguments[0].IsNull; + if (element.TypeKind == TypeKind.Enum && (options > 0 || isNull)) { findings?.Add(new Finding(Rules.EnumWithOptions, LocationOf(attribute), [location])); return false; diff --git a/src/FluentCMS.Core/Schema/AttributeSchemaReader.cs b/src/FluentCMS.Core/Schema/AttributeSchemaReader.cs index 7f9607b45..396b8bbea 100644 --- a/src/FluentCMS.Core/Schema/AttributeSchemaReader.cs +++ b/src/FluentCMS.Core/Schema/AttributeSchemaReader.cs @@ -261,8 +261,9 @@ private void ReadFieldType(FieldAttribute attribute, PropertyInfo property, stri steps.Add(f => f.Setting(FieldSettings.Source, SchemaNaming.ToCamelCase(source))); break; + // [Select(null)] gives no list at all, which is refused as a list on an enum and as no options elsewhere. case SelectAttribute select: - if (element.IsEnum && select.Options.Count > 0) + if (element.IsEnum && select.Options is not { Count: 0 }) { fail(SchemaErrorCodes.InvalidProperty, $"property {location} is an enum, so its members are the options and [Select] must not list any"); } @@ -270,7 +271,7 @@ private void ReadFieldType(FieldAttribute attribute, PropertyInfo property, stri { steps.Add(f => f.Setting(FieldSettings.Options, Enum.GetNames(element).Select(SchemaNaming.ToCamelCase).ToArray())); } - else if (select.Options.Count > 0) + else if (select.Options is { Count: > 0 }) { steps.Add(f => f.Setting(FieldSettings.Options, select.Options)); } diff --git a/tests/FluentCMS.Core.Tests/Schema/AttributeReaderTests.cs b/tests/FluentCMS.Core.Tests/Schema/AttributeReaderTests.cs index 79e63db8b..d27e78089 100644 --- a/tests/FluentCMS.Core.Tests/Schema/AttributeReaderTests.cs +++ b/tests/FluentCMS.Core.Tests/Schema/AttributeReaderTests.cs @@ -261,6 +261,13 @@ public void SelectWithOptionsOnAnEnumIsFCMS1216() ShouldReport(typeof(SelectEnumWithOptions), "FCMS1216", "SelectEnumWithOptions.Kind is an enum"); } + [Fact] + public void SelectWithANullListIsFCMS1216OnAStringAndOnAnEnum() + { + ShouldReport(typeof(SelectWithNullOptions), "FCMS1216", "SelectWithNullOptions.Kind is not an enum, so [Select] must list its options"); + ShouldReport(typeof(SelectEnumWithNullOptions), "FCMS1216", "SelectEnumWithNullOptions.Kind is an enum"); + } + [Fact] public void TwoMaximumLengthsAreFCMS1216() { @@ -479,6 +486,20 @@ private sealed class SelectEnumWithOptions : ContentItem public ArticleCategory Kind { get; set; } } + [Collection] + private sealed class SelectWithNullOptions : ContentItem + { + [Select(null!)] + public string Kind { get; set; } = ""; + } + + [Collection] + private sealed class SelectEnumWithNullOptions : ContentItem + { + [Select(null!)] + public ArticleCategory Kind { get; set; } + } + [Collection] private sealed class ConflictingLengths : ContentItem { diff --git a/tests/FluentCMS.Generators.Tests/Analyzers/CombinationRuleTests.cs b/tests/FluentCMS.Generators.Tests/Analyzers/CombinationRuleTests.cs index 2989c8518..82b9f9655 100644 --- a/tests/FluentCMS.Generators.Tests/Analyzers/CombinationRuleTests.cs +++ b/tests/FluentCMS.Generators.Tests/Analyzers/CombinationRuleTests.cs @@ -182,6 +182,17 @@ public sealed class Page : ContentItem } """, "FCMS1216"); + [Fact] + public Task ANullListOnAnEnumIsReported() => Analysis.Reports(""" + public enum Kind { News, Blog } + + [Collection("page")] + public sealed class Page : ContentItem + { + [{|FCMS0007:Select(null!)|}] public Kind Kind { get; set; } + } + """, "FCMS1216"); + [Fact] public Task AnEnumWithoutOptionsIsAccepted() => Analysis.Accepts(""" public enum Kind { News, Blog } @@ -206,27 +217,27 @@ public sealed class Page : ContentItem """, "FCMS1216"); [Fact] - public Task AStringWithOptionsIsAccepted() => Analysis.Accepts(""" + public Task AStringWithANullListIsReported() => Analysis.Reports(""" [Collection("page")] public sealed class Page : ContentItem { - [Select("news", "blog")] public string Kind { get; set; } = ""; + [{|FCMS0008:Select(null!)|}] public string Kind { get; set; } = ""; } - """); -} + """, "FCMS1216"); -// What the reader cannot read without an exception, or what depends on a plugin the analyzers cannot see, is left to the host. -public sealed class LeftToTheHostTests -{ [Fact] - public Task ANullListOfOptionsIsNotReported() => Analysis.Verify(Analysis.Usings + """ + public Task AStringWithOptionsIsAccepted() => Analysis.Accepts(""" [Collection("page")] public sealed class Page : ContentItem { - [Select(null!)] public string Kind { get; set; } = ""; + [Select("news", "blog")] public string Kind { get; set; } = ""; } """); +} +// What depends on a plugin the analyzers cannot see is left to the host. +public sealed class LeftToTheHostTests +{ [Fact] public Task AUniqueMediaFieldIsNotReported() => Analysis.Verify(Analysis.Usings + """ [Collection("page")]