From e0a2c92ad5253cd7f9e88d663f25b1bc75bf889b Mon Sep 17 00:00:00 2001 From: AntoineGautier Date: Fri, 9 Oct 2026 11:43:36 +0200 Subject: [PATCH 1/3] Update specification --- docs/selection-keys.md | 93 ++++++++++++++++++++++++++---------------- 1 file changed, 58 insertions(+), 35 deletions(-) diff --git a/docs/selection-keys.md b/docs/selection-keys.md index 00cb6f51..9c753e57 100644 --- a/docs/selection-keys.md +++ b/docs/selection-keys.md @@ -17,7 +17,7 @@ All design choices comply with the [Modelica Language Specification 3.7](https:/ ``` key = rootClass "-" elementPath -rootClass = className (fully qualified name of the template class, without leading "."; config.templatePath) +rootClass = className (fully qualified name of the class to extend, without leading "."; see Resolution rules) elementPath = elementName (path of the element relative to rootClass) className = C-IDENT { "." C-IDENT } @@ -39,13 +39,16 @@ Examples: - **Why not `rootClass.elementPath`.** In Modelica, `A.c` only references an element of `A` if `A` is a package. Templates are models: their components have no formal path until instantiated. The dotted form is therefore not a valid Modelica reference, and it cannot be split without looking up which prefix is a class. - **Quoted component identifiers.** The single quotes are part of the identifier: `'x'` and `x` are distinct identifiers (MLS §2.3.1), hence distinct keys. Quotes are never added or removed. - **No quoted class identifiers.** MLS allows `Q-IDENT` for classes, but a quoted class cannot be stored with the directory hierarchy mapping (MLS §13.4.1): `'Test'.mo` and `Test.mo` are the same file name. Tools fall back to storing such a class in the enclosing `package.mo`. This dead angle of the specification is not supported. A quoted identifier anywhere in a class name (template, class of a component, short class, redeclared class, enumeration type) is rejected with an explicit error by the server parser, and keys containing one are invalid. Values are never silently misinterpreted. -- **Mapping to Modelica.** `rootClass` is the class to extend; `elementPath` is the component reference in the class modification: +- **Mapping to Modelica.** `rootClass` is the class to extend; `elementPath` is the name of the modified element in the class modification. The attributes of the value (see Values) give the kind of modification, without any lookup: | Key | Value | Modelica | |---|---|---| - | `T-a.b.p` | `true` | `extends T(a(b(p=true)))` | - | `T-a.b` | `P.C` (replaceable component) | `extends T(a(redeclare P.C b))` | - | `T-a.S` | `P.C` (replaceable short class `S`) | `extends T(a(redeclare model S = P.C))` | + | `T-a.b.p` | `{ "value": true }` | `extends T(a(b(p=true)))` | + | `T-a.b` | `{ "redeclare": "P.C" }` (replaceable component) | `extends T(a(redeclare P.C b))` | + | `T-a.S` | `{ "redeclare": "P.C", "kindOfClass": "model" }` (replaceable short class `S`) | `extends T(a(redeclare model S = P.C))` | + | `T-a.b` and `T-a.b.p` | `{ "redeclare": "P.C" }`, `{ "value": true }` | `extends T(a(redeclare P.C b(p=true)))` | + + Keys sharing a prefix are written as one nested modifier, and the modifications of the elements of a redeclared component go into its redeclaration, as in the last row. This is the merged form defined by MLS §7.2.4. ## Parsing and normalization @@ -63,16 +66,26 @@ The same scanner provides the name helpers (split into identifiers, last identif ## Values +A key holds an assignment, a redeclaration, or both, in separate attributes named as in `templates.json` (#495): `value` as the `value` of an option, `redeclare` as the `redeclare` of a modifier. + ``` -value = Boolean | number | string | className | enumLiteral +value = Boolean | number | string | enumLiteral +redeclare = className +kindOfClass = class-prefixes without "partial" (MLS appendix A), e.g. "model", "record", "package", "expandable connector" enumLiteral = className "." E-IDENT (enumeration type, then literal: quoted literals are supported) ``` -| Element | Value | Example | +| Element | Attributes | Example | |---|---|---| -| Replaceable component or short class | `className` of the redeclared class | `Buildings.Templates.Components.Coils.WaterBasedHeating` | -| Enumeration parameter | `enumLiteral` | `P.Types.Valve.TwoWayModulating`, `P.Types.Valve.'two-way'` | -| Other parameters | Boolean, number, or string | `true`, `0.7` | +| Replaceable component | `redeclare`: class of the component | `{ "redeclare": "Buildings.Templates.Components.Coils.WaterBasedHeating" }` | +| Replaceable short class | `redeclare`: class of the short class; `kindOfClass` | `{ "redeclare": "P.C", "kindOfClass": "model" }` | +| Enumeration parameter | `value`: `enumLiteral` | `P.Types.Valve.TwoWayModulating`, `P.Types.Valve.'two-way'` | +| Other parameters | `value`: Boolean, number, or string | `true`, `0.7` | +| Replaceable record with a binding (`redeclare R rec = localRec`) | `redeclare` and `value` | not produced by the configuration panel today | + +- **Kind of modification.** The attributes are set from the declaration of the element when the value is written to the stores. They are never inferred from the value. +- **`kindOfClass`** is present if and only if the redeclared element is a class (short class element), and absent for a component. It holds the class prefixes of the replaceable class declaration (the specialized class keyword, MLS §4.7, with its `operator`, `expandable`, `pure` or `impure` prefix), i.e. the prefixes repeated in the redeclaration: `redeclare S = P.C`. With it, the payload is sufficient to write the modification without looking up `templates.json`. +- **Consumers that only need values.** The MBL templates bind a `typ` parameter in every class that can be redeclared (type introspection: Modelica has no `isOfType()`, its type system being structural). Consumers that do not write Modelica, such as the sequence document, read `value` only, through these `typ` parameters (see Mappings). - **Enumeration literals** may be quoted (`type Valve = enumeration('two-way', threeWay)`), but the enumeration type must not be (see the quoted class identifier rule). The literal is the last identifier of the value, extracted with the scanner. `P.Types.Valve.'two-way'` and `P.Types.Valve.two_way` are distinct values. - **Normalization.** Quoted literals are normalized like quoted component identifiers, so that values compare equal (client `==`, mogrifier `EQUALS`/`ANY`/`NOT_EQUALS`) whatever the escape spelling in the source. @@ -82,7 +95,9 @@ enumLiteral = className "." E-IDENT (enumeration type, then literal: quoted `elementPath` is the **resolved** path, i.e., the path returned by `resolvePaths`: -1. **Inner/outer.** Path modifiers (`template.pathModifiers`) are applied: an element reached through an `outer` component is keyed at the `inner` declaration. +1. **Inner/outer.** An `outer` component is a reference to the `inner` component with the same name in an enclosing instance (MLS §5.4). The elements of the referenced component can only be modified at the `inner` declaration, so they are keyed there: + - **`inner` declared in the template.** The path is rewritten with `template.pathModifiers`, which the parser builds by mapping the path of each `outer` component to the path of its `inner`. In `Buildings.Templates.AirHandlersFans.VAVMultiZone`, the controller `ctl` holds an `outer` reference to the coil `coiCoo` (`inner replaceable … coiCoo`), and `pathModifiers` holds `ctl.coiCoo → coiCoo`. The parameter `ctl.coiCoo.typ` is keyed `Buildings.Templates.AirHandlersFans.VAVMultiZone-coiCoo.typ`. + - **`inner` declared outside the template.** This is the case of `datAll` (`outer parameter Buildings.Templates.Data.AllSystems datAll` in the controllers), whose `inner` is declared once in the model that instantiates all the configurations of a project. `rootClass` is then the class of the `outer` declaration, and the value is a project-level value (`project.values`): `ctl.datAll.stdEne` is keyed `Buildings.Templates.Data.AllSystems-stdEne`. The Modelica export writes it into the project data record: `extends Buildings.Templates.Data.AllSystems(stdEne=…)`. 2. **Record bindings.** A parameter reached through a bound record (`mod(rec=localRec)` or `Rec rec = localRec`) is keyed at the binding target (`localRec.p`, not `mod.rec.p`), which is the only place the value can be modified. 3. **Replaceable components.** The choice is keyed at the component: `T-coiHea`. 4. **Replaceable short classes.** The choice is keyed at the short class element, in the scope of the class that declares it: `T-.`, or `T-` when declared in the template itself (no leading dot). One key sets the type of every instance declared with that short class in that scope. @@ -126,19 +141,12 @@ Every computation of an element's class uses `effectiveClass`: `resolveInstance` - An identifier that cannot be resolved yields `null`. On `main`, `_instancePathToOption` instead returns the path of the enclosing class (`interpreter.ts:342-344`), which is a silently wrong answer. - Results are cached per `ConfigContext`, by path prefix. This is valid because a context is rebuilt whenever a selection changes. -## Keys exempt from the grammar - -| Key | Example | Reason | -|---|---|---| -| Project-level settings | `Buildings.Templates.Data.AllSystems.stdEne` | Project singleton bound to the `outer datAll` of every template; unambiguous. Kept unchanged (normalizing to `Buildings.Templates.Data.AllSystems-stdEne` is deferred). | - -The system type of a configuration and the pipeline flags (`DEL_INFO_BOX` on `main`) are not keys: they are fields of the payload (see Payload). - ## Stores -- `config.selections`: values entered by the user. This is the only source for Modelica export. -- `config.evaluatedValues`: values derived by the interpreter, same key space. Never exported. -- `project.selections`, `project.evaluatedValues`: project-level settings (exempt keys). +- `config.selections`: values entered by the user. This is the only source of the class modification of an exported configuration class. +- `config.evaluatedValues`: values derived by the interpreter, same key space. Never written to the class modification. Records derived from a configuration use them: the configuration record holds the evaluated `cfg.*` values as `final` modifications (see [ModelicaExport](https://github.com/AntoineGautier/ModelicaExport), `UserProject.*.Configuration`). +- `project.selections`, `project.evaluatedValues`: project-level values, keyed `Buildings.Templates.Data.AllSystems-` (rule 1). On `main` they are keyed `Buildings.Templates.Data.AllSystems.` (`EditDetailsModal`, interpreter, mappings). +- **Value objects.** All four stores map each key to `{ value?, redeclare?, kindOfClass? }`: the value objects of the payload without `origin`, which is given by the store. The attributes are set once, when the value is written, by the code that holds the option of the element (`SlideOut` and `EditDetailsModal` for selections, `getEvaluatedValues` for evaluated values). Every reader (interpreter, display mapping, payload builder) takes them from the stores. This supersedes the flat selection schema of #495. - **Persistence.** No migration: the local storage key includes the client version, which is bumped on every build. - **Projects.** Every configuration belongs to exactly one existing project: - `ConfigInterface.projectId` (required) is set by `configStore.add()` to the active project id. @@ -162,9 +170,9 @@ The payload is the exchange format between the client and every consumer (sequen "projects": [ { "id": "6f0c…", - "name": "Office building", + "description": "Office building", "values": { - "Buildings.Templates.Data.AllSystems.stdEne": { + "Buildings.Templates.Data.AllSystems-stdEne": { "value": "Buildings.Controls.OBC.ASHRAE.G36.Types.EnergyStandard.ASHRAE90_1", "origin": "user" } @@ -172,11 +180,13 @@ The payload is the exchange format between the client and every consumer (sequen "configurations": [ { "id": "a41e…", - "name": "Perimeter boxes", + "description": "Perimeter boxes", "quantity": 12, "systemType": "Buildings.Templates.ZoneEquipment", "template": "Buildings.Templates.ZoneEquipment.VAVBoxReheat", "values": { + "Buildings.Templates.ZoneEquipment.VAVBoxReheat-coiHea": { "redeclare": "Buildings.Templates.Components.Coils.ElectricHeating", "origin": "user" }, + "Buildings.Templates.ZoneEquipment.VAVBoxReheat-coiHea.typ": { "value": "Buildings.Templates.Components.Types.Coil.ElectricHeating", "origin": "evaluated" }, "Buildings.Templates.ZoneEquipment.VAVBoxReheat-ctl.have_occSen": { "value": true, "origin": "user" }, "Buildings.Templates.ZoneEquipment.VAVBoxReheat-ctl.have_CO2Sen": { "value": false, "origin": "evaluated" } } @@ -190,13 +200,16 @@ The payload is the exchange format between the client and every consumer (sequen ### Rules - **Addresses.** A value is addressed by `(project.id, configuration.id, key)`, or `(project.id, key)` for project-level settings. This address is unambiguous across projects, configurations and templates. Keys are unique within a `values` object, so each key holds a single value. -- **Identifiers.** `id`s are the UUIDs of the client stores, stable across sessions. `name`s are labels: they are not required to be unique and are never used as identifiers. +- **Identifiers.** `id`s are the UUIDs of the client stores, stable across sessions. +- **Descriptions.** `description` is the label entered by the user (configuration name and project name in the UI). It is not required to be unique, is never used as an identifier, and becomes the description string of the exported class or package. +- **Reserved: `name`.** In Modelica, a name is an identifier. `name` is reserved for the simple name of the exported class (configuration) or package (project): an `IDENT`, unique within the enclosing package (`.` for a configuration). It will be added with the Modelica export, together with an input field. - **Value attributes.** Each key maps to an object, never to a bare value, so that attributes can be added without breaking consumers: - - `value` (required): see Values. There are no `null` values: an element without a value is absent. - - `origin` (required): `"user"` for a value entered by the user (`selections`), `"evaluated"` for a value derived by the interpreter (`evaluatedValues`). When both exist, the user value wins. Modelica export writes `"user"` values only. + - `value`, `redeclare`, `kindOfClass`: see Values. At least one of `value` and `redeclare` is present. There are no `null` values: an element without a value is absent. + - `origin` (required): `"user"` for a value entered by the user (`selections`), `"evaluated"` for a value derived by the interpreter (`evaluatedValues`). When both exist, the user value wins. The class modification of an exported configuration class is made of `"user"` values only (see Stores). - Further attributes (unit, bounds, description, etc.) may be added; consumers ignore attributes they do not know. - **Payload-wide attributes** go in `meta` (versions of ctrl-flow and of the libraries the templates were generated from), never on each value. - **Consumer options** go in `options`. They are not Modelica values. `deleteInfoBox` replaces `DEL_INFO_BOX`. +- **Not keys.** The system type and the template of a configuration are fields of the configuration, not keys. - **Versioning.** `schemaVersion` is an integer, incremented on any breaking change. Adding optional attributes or fields is not breaking. Consumers reject a payload whose `schemaVersion` they do not support. - **Order.** `projects` and `configurations` follow the order of the client stores. @@ -218,21 +231,30 @@ This reproduces the merge done by the client on `main`. Merging now happens in t ### Mappings -The `Modelica Parameter` column of the mappings file holds either an exempt key, or a **key pattern**: +The `Modelica Parameter` column of the mappings file holds a key, or a **key pattern** listing several classes: ``` -pattern = selector "-" elementPath -selector = name (a template class, or a package containing templates) +pattern = selector "-" elementPath +selector = className | [ className "." ] "{" alternatives "}" +alternatives = className { "|" className } ``` -A pattern matches every key whose `elementPath` is equal and whose `rootClass` is the selector or lies within the selector package. Both comparisons are made on identifier sequences produced by the scanner, never on string prefixes: `P.A` lies within `P`, but `P.AB` does not lie within `P.A`. The mogrifier evaluates a toggle against the union of the values of all matching keys. A condition on a subset of templates is expressed by the selector, or by a new mapping entry. +A pattern is shorthand for the set of keys obtained by expanding the alternatives, e.g. `P.{A|B}-c.p` stands for `P.A-c.p` and `P.B-c.p`. Each expanded `rootClass` is a class where `elementPath` is declared (a template, or `Buildings.Templates.Data.AllSystems`), never an enclosing package. `{`, `|` and `}` cannot occur in a class name (no quoted class identifiers), so the expansion is unambiguous. Keys are then matched by equality. A test checks that every expanded key resolves in its template. + +The mogrifier evaluates a toggle against the union of the values of all keys of the pattern. A condition on a subset of templates is expressed by the alternatives, or by a new mapping entry. + +The `Current G36 Decisions` mappings are rewritten so that the generated document is unchanged: -The `Current G36 Decisions` mappings are rewritten with the narrowest selector that covers the templates whose values are merged today, so that the generated document is unchanged, e.g.: +- Each pattern lists the templates whose values are merged today. +- Project-level settings use the keys of rule 1. +- The five entries that read the class of a replaceable component (`PREHEAT`, `REHEAT`, `COOL`, `OA`, `TERM_HT`) read its `typ` parameter instead, and their value rows (`Modelica Path` column) use the enumeration literals, which match the classes one to one (`Coils.WaterBasedCooling` → `Types.Coil.WaterBasedCooling`). The mogrifier then reads `value` only. | Short ID | `main` | New | |---|---|---| -| `OCC` | `Buildings.Templates.ZoneEquipment.Components.Interfaces.ControllerG36VAVBox.have_occSen-ctl.have_occSen` | `Buildings.Templates.ZoneEquipment-ctl.have_occSen` | -| `COOL` | `Buildings.Templates.AirHandlersFans.VAVMultiZone.coiCoo-coiCoo` | `Buildings.Templates.AirHandlersFans.VAVMultiZone-coiCoo` | +| `OCC` | `Buildings.Templates.ZoneEquipment.Components.Interfaces.ControllerG36VAVBox.have_occSen-ctl.have_occSen` | `Buildings.Templates.ZoneEquipment.{VAVBoxCoolingOnly\|VAVBoxReheat}-ctl.have_occSen` | +| `ENERGY` | `Buildings.Templates.Data.AllSystems.stdEne` | `Buildings.Templates.Data.AllSystems-stdEne` | +| `COOL` | `Buildings.Templates.AirHandlersFans.VAVMultiZone.coiCoo-coiCoo` | `Buildings.Templates.AirHandlersFans.VAVMultiZone-coiCoo.typ` | +| `OA` | `Buildings.Templates.AirHandlersFans.Components.OutdoorReliefReturnSection.MixedAirWithDamper.secOut-secOutRel.secOut` | `Buildings.Templates.AirHandlersFans.VAVMultiZone-secOutRel.typSecOut` | ### Golden tests @@ -251,6 +273,7 @@ Verified on 2026-10-08 with a probe package (quoted components and parameters co - **The server parser** preserves them in option paths (`…Template.'c-d'`), modifier keys (`…Template.'c-d'.'a.b'`), enumeration literals and values (`…Types.Valve.'13\'H'`), `enable` expressions (`'with-dash'`), `if` expressions and record bindings (`'rec-1'`). - **To be implemented:** - The parser accepts a quoted class without error (`…Classes.'Q'`). It must reject it. + - `templates.json` does not record the kind of a class definition: options of class definitions only have `definition: true`. The parser derives it from modelica-json's `class_prefixes` (`elementType` in `_constructElement`), but drops `expandable`. Class definition options get a `kindOfClass` field (class prefixes without `partial`), from which the client sets the `kindOfClass` attribute of values. - Six plain `split(".")` sites in the parser apply to element paths, so they would mis-split a component identifier containing `.`. They must use the scanner. The probe showed no symptom: `parser.ts:99` (inherited element lookup), `parser.ts:270`, `parser.ts:506` (`baseType`), `modification.ts:162`, `template.ts:173` (tree list), `schedule.ts:178`. The other seven sites only apply to class names, which are never quoted. Issues found by the probe, unrelated to quoted identifiers: From 8d053ed6c1d43f655f165c9596260abcffe105b0 Mon Sep 17 00:00:00 2001 From: AntoineGautier Date: Fri, 9 Oct 2026 14:04:52 +0200 Subject: [PATCH 2/3] Link configurations to projects - ConfigInterface.projectId, set by configStore.add() to the active project - Scope config store queries and removals to a project (active by default); add removeAllForProject, used when saving the project details - Save the default project once hydrated, so that its id does not change on reload - Once both stores are hydrated, attach configurations without a valid project to the project if there is only one, report them otherwise - Create the default project per store instance --- .../src/components/modal/EditDetailsModal.tsx | 14 +- client/src/data/config.ts | 91 ++++++--- client/src/data/index.ts | 7 + client/src/data/project.ts | 26 ++- client/tests/data/project-configs.test.ts | 192 ++++++++++++++++++ client/tests/sequence/golden-payload.test.ts | 1 + 6 files changed, 286 insertions(+), 45 deletions(-) create mode 100644 client/tests/data/project-configs.test.ts diff --git a/client/src/components/modal/EditDetailsModal.tsx b/client/src/components/modal/EditDetailsModal.tsx index 14b79a4a..ae7d6441 100644 --- a/client/src/components/modal/EditDetailsModal.tsx +++ b/client/src/components/modal/EditDetailsModal.tsx @@ -9,7 +9,6 @@ import { FlatConfigOption } from "../steps/Configs/SlideOut"; import OptionSelect from "../steps/Configs/OptionSelect"; import { useDebouncedCallback } from "use-debounce"; import { removeEmpty } from "../../utils/utils"; -import { SystemTypeInterface, TemplateInterface } from "../../data/template"; import { applyValueModifiers, applyVisibilityModifiers, @@ -243,18 +242,7 @@ const EditDetailsModal = observer( // This removes all configs when saving project details. // The reason for this is we don't want the user to use saved configs with // changed project details as it will cause issues with evaluated values. - templateStore.systemTypes.forEach((systemType: SystemTypeInterface) => { - const templates = templateStore.getTemplatesForSystem( - systemType.modelicaPath, - ); - - templates.forEach((option: TemplateInterface) => { - configStore.removeAllForSystemTemplate( - systemType.modelicaPath, - option.modelicaPath, - ); - }); - }); + configStore.removeAllForProject(projectStore.activeProjectId); if (afterSubmit) afterSubmit(); } diff --git a/client/src/data/config.ts b/client/src/data/config.ts index 09f81b5d..10b76a68 100644 --- a/client/src/data/config.ts +++ b/client/src/data/config.ts @@ -12,6 +12,7 @@ export interface SelectionInterface { export interface ConfigInterface { id: string; + projectId: string; name?: string; isLocked: boolean; selections?: ConfigValues; @@ -22,28 +23,34 @@ export interface ConfigInterface { [key: string]: string | number | undefined | boolean | ConfigValues; } -export type ConfigProps = Omit; +export type ConfigProps = Omit; export default class Config { configs: ConfigInterface[] = []; rootStore: RootStore; + /** Resolves once the store is loaded from local storage */ + hydrated: Promise = Promise.resolve(); constructor(rootStore: RootStore) { this.rootStore = rootStore; - makeAutoObservable(this); + makeAutoObservable(this, { hydrated: false }); if (process.env.NODE_ENV !== "test") { - makePersistable(this, { + this.hydrated = makePersistable(this, { name: this.rootStore.getStorageKey("config"), properties: ["configs"], }); } } + /** + * Adds a configuration to the active project + */ add(config: ConfigProps) { const merged = { id: uuid(), + projectId: this.activeProjectId, name: "Default", isLocked: false, selections: {}, @@ -75,13 +82,15 @@ export default class Config { if (config) config.isLocked = !config.isLocked; } - hasSystemTemplateConfigs(systemPath: string, templatePath: string): boolean { - return this.configs.find( - (config) => - config.systemPath == systemPath && config.templatePath === templatePath, - ) - ? true - : false; + hasSystemTemplateConfigs( + systemPath: string, + templatePath: string, + projectId = this.activeProjectId, + ): boolean { + return ( + this.getConfigsForSystemTemplate(systemPath, templatePath, projectId) + .length > 0 + ); } // Look in the config for the value of the first option that matches a given modelicaPath @@ -122,29 +131,65 @@ export default class Config { getConfigsForSystemTemplate( systemPath: string | null, templatePath: string | null, + projectId = this.activeProjectId, ): ConfigInterface[] { return this.configs.filter( (config) => + config.projectId === projectId && config.systemPath === systemPath && config.templatePath === templatePath, ); } - getConfigsForProject( - //TODO: need to connect project to configs - //projectId: string, - ): ConfigInterface[] { - return toJS(this.configs); - /*return this.configs.filter( - (config) => config.projectId === projectId - );*/ + getConfigsForProject(projectId = this.activeProjectId): ConfigInterface[] { + return toJS( + this.configs.filter((config) => config.projectId === projectId), + ); + } + + removeAllForSystemTemplate( + systemPath: string, + templatePath: string, + projectId = this.activeProjectId, + ) { + this.configs = this.configs.filter( + (config) => + !( + config.projectId === projectId && + config.systemPath === systemPath && + config.templatePath === templatePath + ), + ); + } + + removeAllForProject(projectId: string) { + this.configs = this.configs.filter( + (config) => config.projectId !== projectId, + ); } - removeAllForSystemTemplate(systemPath: string, templatePath: string) { - this.configs = this.configs.filter((config) => - config.systemPath === systemPath && config.templatePath === templatePath - ? false - : true, + /** + * Attaches the configurations that belong to no existing project (stored + * before configurations were linked to projects, or whose project was not + * saved) to the project, if there is exactly one. Otherwise, reports them: + * configurations are never dropped. + */ + attachOrphans(projectIds: string[]) { + const orphans = this.configs.filter( + (config) => !projectIds.includes(config.projectId), ); + if (orphans.length === 0) return; + if (projectIds.length === 1) { + orphans.forEach((config) => (config.projectId = projectIds[0])); + } else { + console.error( + `${orphans.length} configuration(s) belong to no project`, + orphans.map((config) => config.id), + ); + } + } + + private get activeProjectId(): string { + return this.rootStore.projectStore.activeProjectId; } } diff --git a/client/src/data/index.ts b/client/src/data/index.ts index 949aa715..4631d04d 100644 --- a/client/src/data/index.ts +++ b/client/src/data/index.ts @@ -37,6 +37,13 @@ class RootStore { this.templateStore = new TemplateStore(this); this.projectStore = new ProjectStore(this); this.configStore = new ConfigStore(this); + + Promise.all([this.projectStore.hydrated, this.configStore.hydrated]).then( + () => + this.configStore.attachOrphans( + this.projectStore.projects.map((project) => project.id), + ), + ); } getStorageKey(suffix = ""): string { diff --git a/client/src/data/project.ts b/client/src/data/project.ts index 1251e635..c8eb96ca 100644 --- a/client/src/data/project.ts +++ b/client/src/data/project.ts @@ -19,7 +19,7 @@ export interface ProjectInterface { projectDetails: ProjectDetailInterface; } -const DEFAULT_PROJECT = { +const createDefaultProject = (): ProjectInterface => ({ id: uuid(), projectDetails: { name: "", @@ -30,24 +30,32 @@ const DEFAULT_PROJECT = { selections: {}, evaluatedValues: {}, }, -}; +}); export default class Project { - projects = [DEFAULT_PROJECT]; - activeProjectId = DEFAULT_PROJECT.id; + projects = [createDefaultProject()]; + activeProjectId = this.projects[0].id; + /** Resolves once the store is loaded from local storage */ + hydrated: Promise = Promise.resolve(); rootStore; constructor(rootStore: RootStore) { this.rootStore = rootStore; - makeAutoObservable(this); + makeAutoObservable(this, { hydrated: false }); if (process.env.NODE_ENV !== "test") { - makePersistable(this, { - name: this.rootStore.getStorageKey("projects"), - properties: ["projects", "activeProjectId"], - }); + this.hydrated = makePersistable( + this, + { + name: this.rootStore.getStorageKey("projects"), + properties: ["projects", "activeProjectId"], + }, + // Save the default project once hydrated: its id, generated at page + // load, must not change on reload as configurations refer to it + { fireImmediately: true }, + ); } } diff --git a/client/tests/data/project-configs.test.ts b/client/tests/data/project-configs.test.ts new file mode 100644 index 00000000..6ae9d945 --- /dev/null +++ b/client/tests/data/project-configs.test.ts @@ -0,0 +1,192 @@ +/** + * Configurations belong to a project (docs/selection-keys.md, "Stores") + */ +import { stopPersisting } from "mobx-persist-store"; +import RootStore from "../../src/data"; +import { ConfigInterface } from "../../src/data/config"; +import { ProjectInterface } from "../../src/data/project"; + +const SYSTEM = "Buildings.Templates.ZoneEquipment"; +const RH = "Buildings.Templates.ZoneEquipment.VAVBoxReheat"; +const CO = "Buildings.Templates.ZoneEquipment.VAVBoxCoolingOnly"; + +const addProject = (store: RootStore, id: string) => { + const project = { + ...store.projectStore.projects[0], + id, + } as ProjectInterface; + store.projectStore.projects.push(project); + return project; +}; + +/** + * Store with configurations in two projects: "rh" and "co" in the default + * project, "other" in project "p2", which is left active + */ +function twoProjects() { + const store = new RootStore(); + const { configStore, projectStore } = store; + const defaultId = projectStore.activeProjectId; + configStore.add({ systemPath: SYSTEM, templatePath: RH, name: "rh" }); + configStore.add({ systemPath: SYSTEM, templatePath: CO, name: "co" }); + addProject(store, "p2"); + projectStore.setActiveProjectId("p2"); + configStore.add({ systemPath: SYSTEM, templatePath: RH, name: "other" }); + return { store, configStore, projectStore, defaultId }; +} + +const names = (configs: { name?: string }[]) => configs.map((c) => c.name); + +describe("Configurations linked to projects", () => { + it("adds configurations to the active project", () => { + const { configStore, defaultId } = twoProjects(); + expect(configStore.configs.map((c) => [c.name, c.projectId])).toEqual([ + ["rh", defaultId], + ["co", defaultId], + ["other", "p2"], + ]); + }); + + it("scopes queries to the active project by default", () => { + const { configStore, defaultId } = twoProjects(); + expect(names(configStore.getConfigsForProject())).toEqual(["other"]); + expect(names(configStore.getConfigsForProject(defaultId))).toEqual([ + "rh", + "co", + ]); + expect(names(configStore.getConfigsForSystemTemplate(SYSTEM, RH))).toEqual([ + "other", + ]); + expect( + names(configStore.getConfigsForSystemTemplate(SYSTEM, RH, defaultId)), + ).toEqual(["rh"]); + expect(configStore.hasSystemTemplateConfigs(SYSTEM, RH)).toBe(true); + expect(configStore.hasSystemTemplateConfigs(SYSTEM, CO)).toBe(false); + expect(configStore.hasSystemTemplateConfigs(SYSTEM, CO, defaultId)).toBe( + true, + ); + }); + + it("scopes removals to a project", () => { + const { configStore, defaultId } = twoProjects(); + configStore.removeAllForSystemTemplate(SYSTEM, RH); + expect(names(configStore.configs)).toEqual(["rh", "co"]); + configStore.removeAllForProject(defaultId); + expect(configStore.configs).toEqual([]); + }); + + it("does not share the default project between stores", () => { + const a = new RootStore(); + const b = new RootStore(); + expect(a.projectStore.activeProjectId).not.toEqual( + b.projectStore.activeProjectId, + ); + expect(a.projectStore.projects[0]).not.toBe(b.projectStore.projects[0]); + }); + + describe("attachOrphans", () => { + it("attaches orphans to the project if there is only one", () => { + const store = new RootStore(); + const { configStore, projectStore } = store; + configStore.add({ systemPath: SYSTEM, templatePath: RH, name: "a" }); + delete (configStore.configs[0] as Partial).projectId; // stored before #634 + configStore.add({ systemPath: SYSTEM, templatePath: RH, name: "b" }); + configStore.configs[1].projectId = "deleted"; // project not saved + configStore.attachOrphans([projectStore.activeProjectId]); + expect(configStore.configs.map((c) => c.projectId)).toEqual([ + projectStore.activeProjectId, + projectStore.activeProjectId, + ]); + }); + + it("reports orphans and keeps them if there are several projects", () => { + const { configStore, projectStore, defaultId } = twoProjects(); + configStore.configs[0].projectId = "deleted"; + const error = jest + .spyOn(console, "error") + .mockImplementation(() => undefined); + configStore.attachOrphans(projectStore.projects.map((p) => p.id)); + expect(error).toHaveBeenCalledTimes(1); + error.mockRestore(); + expect(configStore.configs.map((c) => c.projectId)).toEqual([ + "deleted", + defaultId, + "p2", + ]); + }); + }); +}); + +describe("Persistence of projects and configurations", () => { + const storage = new Map(); + const nodeEnv = process.env.NODE_ENV; + const SAVE_DELAY = 300; // persistence is debounced by 200 ms + + const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + + let loaded: RootStore | undefined; + + /** Store loaded from local storage, as on page load (closing the page of the previous one) */ + async function load() { + unload(); + const store = new RootStore(); + loaded = store; + await Promise.all([ + store.projectStore.hydrated, + store.configStore.hydrated, + ]); + return store; + } + + beforeAll(() => { + const localStorage = window.localStorage as jest.Mocked; + localStorage.getItem.mockImplementation((k) => storage.get(k) ?? null); + localStorage.setItem.mockImplementation((k, v) => { + storage.set(k, v); + }); + process.env.NODE_ENV = "development"; // persistence is off in tests + }); + + afterAll(() => { + process.env.NODE_ENV = nodeEnv; + const localStorage = window.localStorage as jest.Mocked; + localStorage.getItem.mockReset(); + localStorage.setItem.mockReset(); + }); + + function unload() { + if (!loaded) return; + stopPersisting(loaded.projectStore); + stopPersisting(loaded.configStore); + stopPersisting(loaded.uiStore); + loaded = undefined; + } + + beforeEach(() => storage.clear()); + afterEach(unload); + + it("keeps configurations in their project across reloads", async () => { + const first = await load(); + const projectId = first.projectStore.activeProjectId; + first.configStore.add({ systemPath: SYSTEM, templatePath: RH }); + await wait(SAVE_DELAY); + + // The default project was saved although it was never modified + const second = await load(); + expect(second.projectStore.activeProjectId).toEqual(projectId); + expect(second.configStore.getConfigsForProject()).toHaveLength(1); + }); + + it("attaches configurations stored without a project", async () => { + const first = await load(); + first.configStore.add({ systemPath: SYSTEM, templatePath: RH }); + delete (first.configStore.configs[0] as Partial).projectId; + await wait(SAVE_DELAY); + // Projects not stored, as before #634 + storage.forEach((_, k) => k.endsWith("-projects") && storage.delete(k)); + + const second = await load(); + await wait(0); // orphans are attached once both stores are hydrated + expect(second.configStore.getConfigsForProject()).toHaveLength(1); + }); +}); diff --git a/client/tests/sequence/golden-payload.test.ts b/client/tests/sequence/golden-payload.test.ts index 7093af43..30fc9c62 100644 --- a/client/tests/sequence/golden-payload.test.ts +++ b/client/tests/sequence/golden-payload.test.ts @@ -121,6 +121,7 @@ function configure(configCase: ConfigCase, project: Values): ConfigInterface { ); return { id: "", + projectId: store.projectStore.activeProjectId, isLocked: false, systemPath: template.systemTypes[0], templatePath: template.modelicaPath, From 373d3db6698f82fbddfa1711f5ce8be093cf9fe1 Mon Sep 17 00:00:00 2001 From: AntoineGautier Date: Fri, 9 Oct 2026 15:18:44 +0200 Subject: [PATCH 3/3] Support quoted identifiers in element paths - Add a scanner for Modelica names (server/src/parser/names.ts, also imported by the client): split, first/last identifier, enclosing name, validation, selection key split - Use it for every name split in the server parser and the client - Keep quoted identifiers as written: MLS 2.3.1 makes \? and ? the same, but Dymola and OCT do not, and keys must give modifications these tools accept - Reject quoted class identifiers when reading modelica-json files - Recognize names with quoted identifiers as values (quoted enumeration literals) - Add test packages with quoted identifiers, parser and interpreter tests, and scanner unit tests - Remove the obsolete root docker-compose.yml (EC2 deployment replaced by the CDK stack) - Update the specification --- client/src/interpreter/display-option.ts | 5 +- client/src/interpreter/interpreter.ts | 90 ++-- client/src/utils/expression-helpers.ts | 9 +- client/src/utils/modifier-helpers.ts | 17 +- client/src/utils/names.ts | 2 + client/src/utils/sequence-payload.ts | 3 +- client/tests/data/options-QuotedTemplate.json | 387 ++++++++++++++++++ .../interpreter/interpreter-quoted.test.ts | 108 +++++ client/tests/utils/names.test.ts | 87 ++++ docker-compose.yml | 62 --- docs/selection-keys.md | 9 +- server/src/parser/loader.ts | 51 ++- server/src/parser/modification.ts | 3 +- server/src/parser/names.ts | 118 ++++++ server/src/parser/parser.ts | 24 +- server/src/parser/schedule.ts | 3 +- server/src/parser/template.ts | 8 +- .../parser/quoted-identifiers.test.ts | 106 +++++ .../static-data/QuotedClassPackage/package.mo | 17 + .../QuotedPackage/Component/First.mo | 4 + .../QuotedPackage/Component/Partial.mo | 12 + .../QuotedPackage/Component/Second.mo | 7 + .../QuotedPackage/Component/package.mo | 4 + .../QuotedPackage/Component/package.order | 3 + .../QuotedPackage/Template/QuotedTemplate.mo | 51 +++ .../QuotedPackage/Template/package.mo | 4 + .../QuotedPackage/Template/package.order | 1 + .../tests/static-data/QuotedPackage/Types.mo | 14 + .../static-data/QuotedPackage/package.mo | 5 + .../static-data/QuotedPackage/package.order | 3 + 30 files changed, 1068 insertions(+), 149 deletions(-) create mode 100644 client/src/utils/names.ts create mode 100644 client/tests/data/options-QuotedTemplate.json create mode 100644 client/tests/interpreter/interpreter-quoted.test.ts create mode 100644 client/tests/utils/names.test.ts delete mode 100644 docker-compose.yml create mode 100644 server/src/parser/names.ts create mode 100644 server/tests/integration/parser/quoted-identifiers.test.ts create mode 100644 server/tests/static-data/QuotedClassPackage/package.mo create mode 100644 server/tests/static-data/QuotedPackage/Component/First.mo create mode 100644 server/tests/static-data/QuotedPackage/Component/Partial.mo create mode 100644 server/tests/static-data/QuotedPackage/Component/Second.mo create mode 100644 server/tests/static-data/QuotedPackage/Component/package.mo create mode 100644 server/tests/static-data/QuotedPackage/Component/package.order create mode 100644 server/tests/static-data/QuotedPackage/Template/QuotedTemplate.mo create mode 100644 server/tests/static-data/QuotedPackage/Template/package.mo create mode 100644 server/tests/static-data/QuotedPackage/Template/package.order create mode 100644 server/tests/static-data/QuotedPackage/Types.mo create mode 100644 server/tests/static-data/QuotedPackage/package.mo create mode 100644 server/tests/static-data/QuotedPackage/package.order diff --git a/client/src/interpreter/display-option.ts b/client/src/interpreter/display-option.ts index b562ac17..070f030c 100644 --- a/client/src/interpreter/display-option.ts +++ b/client/src/interpreter/display-option.ts @@ -7,6 +7,7 @@ import { OptionInstance, constructSelectionPath, } from "./interpreter"; +import { enclosingName, lastIdent } from "../utils/names"; export interface FlatConfigOptionGroup { groupName: string; @@ -116,7 +117,7 @@ export function _formatDisplayGroup( // class) to the first (the class itself). const treeList = option.treeList ?? []; const declaringClassRank = (path: string) => - treeList.indexOf(path.split(".").slice(0, -1).join(".")); + treeList.indexOf(enclosingName(path)); const orderedOptions = [...(option.options ?? [])].sort( (a, b) => declaringClassRank(b) - declaringClassRank(a), ); @@ -184,7 +185,7 @@ export function _formatDisplayGroup( ); childItems = childGroup ? [childGroup] : []; } else { - const paramName = o.split(".").pop(); + const paramName = lastIdent(o); const childInstancePath = [paramInstance.instancePath, paramName] .filter((p) => p !== "") .join("."); diff --git a/client/src/interpreter/interpreter.ts b/client/src/interpreter/interpreter.ts index 1ff90e36..ebdebd9f 100644 --- a/client/src/interpreter/interpreter.ts +++ b/client/src/interpreter/interpreter.ts @@ -2,6 +2,14 @@ import { ConfigInterface } from "../../src/data/config"; import { TemplateInterface, OptionInterface } from "../../src/data/template"; import { removeEmpty } from "../../src/utils/utils"; import { ConfigValues } from "../utils/modifier-helpers"; +import { + enclosingName, + firstIdent, + isName, + lastIdent, + splitName, + splitSelectionKey, +} from "../utils/names"; export type Literal = boolean | string | number; @@ -58,7 +66,7 @@ interface Modifier { * */ function createPossiblePaths(scope: string, path: string) { - const segments = scope.split("."); + const segments = splitName(scope); const paths = []; while (segments.length > 0) { paths.push([segments.join("."), path].filter((p) => p !== "").join(".")); @@ -87,7 +95,7 @@ export function applyPathModifiers( if (!pathModifiers) { return path; } - const splitScopePath = path.split("."); + const splitScopePath = splitName(path); let postFix: string | undefined = ""; let modifiedPath = path; @@ -137,7 +145,7 @@ const _instancePathToOption = ( outerOptionPath = outerPaths?.optionPath; } - const pathSegments = modifiedPath.split("."); + const pathSegments = splitName(modifiedPath); const curInstancePathList = [pathSegments.shift()]; // keep track of instance path for modifiers let curOptionPath: string | null | undefined = `${context.template.modelicaPath}.${curInstancePathList[0]}`; @@ -182,7 +190,7 @@ const _instancePathToOption = ( // this instance path if (context.config?.selections) { Object.entries(context.selections).map(([key, value]) => { - const [, instancePath] = key.split("-"); + const [, instancePath] = splitSelectionKey(key); // Only string values (Modelica paths) can be used for option lookup if ( instancePath === curInstancePathList.join(".") && @@ -272,7 +280,7 @@ const _instancePathToOption = ( // use the options child list to get the correct type - inherited types // are only correctly referenced through this list curOptionPath = option?.options?.find( - (o) => o.split(".").pop() === paramName, + (o) => lastIdent(o) === paramName, ) as string; if (debug) console.log( @@ -306,15 +314,12 @@ const _instancePathToOption = ( let scopePath: string; if (modBinding) { const depth = modBinding.modificationDepth || 1; - const sliceAmount = modBinding.fromClassDefinition - ? -depth - : -(depth + 1); - scopePath = curInstancePath - .split(".") - .slice(0, sliceAmount) - .join("."); + scopePath = enclosingName( + curInstancePath, + modBinding.fromClassDefinition ? depth : depth + 1, + ); } else { - scopePath = curInstancePath.split(".").slice(0, -1).join("."); + scopePath = enclosingName(curInstancePath); } const resolvedBindingPath = scopePath @@ -357,24 +362,18 @@ const _instancePathToOption = ( }; }; -const IDENT = /[a-zA-Z_][a-zA-Z0-9_]*/; -const MODELICA_NAME_RE = new RegExp( - `^\\.?(${IDENT.source})(\\.${IDENT.source})*$`, -); - /** Returns true if `name` is a syntactically valid Modelica name. - * Identifier: `IDENT = NON-DIGIT { DIGIT | NON-DIGIT }`, - * Name = [ `.` ] IDENT { `.` IDENT } - * - * Technical debt: Q-IDENT = "'" { Q-CHAR | S-ESCAPE } "'" is not supported yet. + * Name = [ `.` ] IDENT { `.` IDENT }, where IDENT may be a quoted identifier */ export function isValidModelicaName(name: string): boolean { - return MODELICA_NAME_RE.test(name); + return isName(name); } const LOADED_LIBRARIES = ["Modelica", "Buildings"]; const TEST_LIBRARIES = - process.env.NODE_ENV === "test" ? ["TestRecord", "TestPackage"] : []; + process.env.NODE_ENV === "test" + ? ["TestRecord", "TestPackage", "QuotedPackage"] + : []; /** * Returns true if `path` is a syntactically valid Modelica name whose root @@ -392,7 +391,7 @@ function isFullyQualifiedName( return false; } // Extract root package name (get rid of optional leading `.`) - const root = path.replace(/^\./, "").split(".")[0]; + const root = firstIdent(path.replace(/^\./, "")); return ( loadedLibraries.some((lib) => root === lib) || testLibraries.some((lib) => root === lib) @@ -492,7 +491,7 @@ export const resolveToValue = ( } const { instancePath, optionPath } = resolvePaths(operand, context, scope); - const instancePathScope = instancePath.split(".").slice(0, -1).join("."); + const instancePathScope = enclosingName(instancePath); // have the actual instance path, check for cached value let value = context._getCachedValue(instancePath); // if no value, check instance path now that scope should be properly applied @@ -541,11 +540,10 @@ export const evaluateModifier = ( if (mod?.redeclare && !mod?.expression) { return mod.redeclare; } - const sliceAmount = mod?.fromClassDefinition ? -1 : -2; - const expressionScope = instancePath - .split(".") - .slice(0, sliceAmount) - .join("."); + const expressionScope = enclosingName( + instancePath, + mod?.fromClassDefinition ? 1 : 2, + ); return evaluate(mod?.expression, context, expressionScope); }; @@ -687,7 +685,7 @@ const addToModObject = ( relativeInstancePath = k.slice(optionModelicaPath.length + 1); } else { // Fallback: use last segment - relativeInstancePath = k.split(".").pop() || ""; + relativeInstancePath = lastIdent(k); } // Build the modifier key by appending the relative instance path to the base instance path @@ -698,7 +696,7 @@ const addToModObject = ( // Calculate the modification depth (number of segments in the nested path) // e.g., "eff.per.pressure" has depth 3 const modificationDepth = relativeInstancePath - ? relativeInstancePath.split(".").length + ? splitName(relativeInstancePath).length : 0; // Do not add a key that is already present. The assumption is that @@ -749,9 +747,7 @@ const getReplaceableType = ( // so the short class element name is necessarily within the same variable namespace as the instance selectionPath = constructSelectionPath( typeOption.modelicaPath, - instancePath.split(".").slice(0, -1).join(".") + - "." + - typeOption.type.split(".").pop(), + enclosingName(instancePath) + "." + lastIdent(typeOption.type), ); } else if (option.replaceable) { // for replaceable components, the selection path is directly created from the instance path @@ -800,7 +796,7 @@ const buildModsHelper = ( } // fetch all modifiers from up the inheritance hierarchy - const name = option.modelicaPath.split(".").pop(); + const name = lastIdent(option.modelicaPath); const newBase = option.definition && !option.shortExclType // short class definitions are treated as instances ? baseInstancePath @@ -956,7 +952,7 @@ export const buildMods = ( const mods: { [key: string]: Modifier } = {}; const selectionModelicaPaths: { [key: string]: null } = {}; // Object.keys(selections) Object.keys(selections).map((s) => { - const [modelicaPath] = s.split("-"); + const [modelicaPath] = splitSelectionKey(s); selectionModelicaPaths[modelicaPath] = null; }); @@ -1103,7 +1099,7 @@ export class ConfigContext { } // return whatever value is present on the original option definition - const optionScope = instancePath.split(".").slice(0, -1).join("."); + const optionScope = enclosingName(instancePath); const option = this.options[optionPath]; // - For replaceable elements: value is "" if no binding, use type instead // - For non-replaceable elements: use value directly @@ -1124,7 +1120,7 @@ export class ConfigContext { const okDepth = depth !== null ? depth > 0 : true; if (typeOption && !isOuter && okDepth) { typeOption.options?.map((o) => { - const paramName = o.split(".").pop(); + const paramName = lastIdent(o); const childInstancePath = [instancePath, paramName] .filter((p) => p !== "") .join("."); @@ -1147,7 +1143,7 @@ export class ConfigContext { const rootOption = this.getRootOption(); rootOption.options?.map((o) => { - const paramName = o.split(".").pop(); + const paramName = lastIdent(o); if (paramName) { this._visitChildNodes(paramName, depth); } @@ -1166,7 +1162,7 @@ export class ConfigContext { } isValidSelection(selectionPath: string) { - const paths = selectionPath.split("-"); + const paths = splitSelectionKey(selectionPath); if (paths.length == 2) { const [, instancePath] = paths; const instance = this.getOptionInstance(instancePath); @@ -1195,14 +1191,14 @@ export class ConfigContext { if (option?.enable === undefined) { return false; } - const scope = instancePath.split(".").slice(0, -1).join("."); + const scope = enclosingName(instancePath); const enable = evaluate(option.enable, this, scope); // an unresolvable expression is NOT treated as disabled return !isExpression(enable) && !enable; } _isAncestorDisabled(instancePath: string): boolean { - const segments = instancePath.split("."); + const segments = splitName(instancePath); for (let i = segments.length - 1; i > 0; i--) { if (this._isInstanceDisabled(segments.slice(0, i).join("."))) { return true; @@ -1259,11 +1255,7 @@ export class ConfigContext { // scope only requiring one level to be popped off (slice(0, -1)) const enable = option && "enable" in option - ? evaluate( - option?.enable, - this, - instancePath.split(".").slice(0, -1).join("."), - ) + ? evaluate(option?.enable, this, enclosingName(instancePath)) : false; display = !isExpression(enable) ? !!enable : display; display = outerOption diff --git a/client/src/utils/expression-helpers.ts b/client/src/utils/expression-helpers.ts index ea304422..34374748 100644 --- a/client/src/utils/expression-helpers.ts +++ b/client/src/utils/expression-helpers.ts @@ -1,6 +1,7 @@ import { deepCopy } from "./utils"; import { applyPathModifiers } from "./modifier-helpers"; import { OptionInterface } from "../data/template"; +import { enclosingName, splitName } from "./names"; export type Literal = boolean | string | number; @@ -40,7 +41,7 @@ export function resolveValue( // We adjust scope to evaluate an expression if needed // Set scope relative to class definition by popping off what is assumed to be a param name - newScope = scope.split(".").slice(0, -1).join("."); + newScope = enclosingName(scope); } else if (typeof value === 'string') { // Since we don't have a selectionPath that means value is coming from inside an expression, // So if value is a string we need to do the following logic @@ -48,7 +49,7 @@ export function resolveValue( // This section checks if we have a selection to resolveValue // We need to determine if value is a modelicaPath or an instancePath, // Below tests if a selection exists based on if value is a modelicaPath - const splitScopePath = scope.split('.'); + const splitScopePath = splitName(scope); while(splitScopePath.length > 0) { const testPath = splitScopePath.join("."); @@ -85,11 +86,11 @@ export function resolveValue( if (modifiedPath) { scopeModifier = modifiers[modifiedPath]; // Set scope relative to class definition by popping off what is assumed to be a param name - newScope = modifiedPath.split(".").slice(0, -1).join("."); + newScope = enclosingName(modifiedPath); } else { scopeModifier = modifiers[value]; // Set scope relative to class definition by popping off what is assumed to be a param name - newScope = value.split(".").slice(0, -1).join("."); + newScope = enclosingName(value); } } else { // The value is a boolean or number so we just return it diff --git a/client/src/utils/modifier-helpers.ts b/client/src/utils/modifier-helpers.ts index 550df465..65d09d21 100644 --- a/client/src/utils/modifier-helpers.ts +++ b/client/src/utils/modifier-helpers.ts @@ -7,6 +7,12 @@ import { isExpression, resolveValue, } from "./expression-helpers"; +import { + enclosingName, + lastIdent, + splitName, + splitSelectionKey, +} from "./names"; export interface ConfigValues { [key: string]: boolean | string | number | null | undefined; @@ -28,7 +34,7 @@ function addToModObject( recursive = true, ) { Object.entries(newModifiers).forEach(([k, expression]) => { - const instanceName = k.split(".").pop(); + const instanceName = lastIdent(k); const modKey = [baseInstancePath, instanceName] .filter((segment) => segment !== "") .join("."); @@ -69,7 +75,7 @@ function updateModifiers( const optionModifiers = option.modifiers as Modifiers; const childOptions = option.options; - const name = option.modelicaPath.split(".").pop(); + const name = lastIdent(option.modelicaPath); const newBase = option.definition ? baseInstancePath : [baseInstancePath, name].filter((p) => p !== "").join("."); @@ -148,7 +154,7 @@ export function applyPathModifiers( scopePath: string, pathModifiers: Modifiers, ): string | undefined { - const splitScopePath = scopePath.split("."); + const splitScopePath = splitName(scopePath); let postFix: string | undefined = ""; let modifiedPath = undefined; @@ -289,9 +295,8 @@ export function getUpdatedModifiers( // Only string values (Modelica paths) can be used for option lookup if (typeof value === "string") { const selectedType = value; - const modelicaPath = key.split("-")[0]; - const instancePath = - key.split("-")[1]?.split(".").slice(0, -1).join(".") || ""; + const [modelicaPath, selectedInstancePath = ""] = splitSelectionKey(key); + const instancePath = enclosingName(selectedInstancePath); const option = allOptions[modelicaPath] as OptionInterface; let choiceModifiers: Modifiers = {}; diff --git a/client/src/utils/names.ts b/client/src/utils/names.ts new file mode 100644 index 00000000..c72a08ed --- /dev/null +++ b/client/src/utils/names.ts @@ -0,0 +1,2 @@ +// Scanner for Modelica names, shared with the server parser +export * from "../../../server/src/parser/names"; diff --git a/client/src/utils/sequence-payload.ts b/client/src/utils/sequence-payload.ts index 2441d48d..c6572985 100644 --- a/client/src/utils/sequence-payload.ts +++ b/client/src/utils/sequence-payload.ts @@ -1,4 +1,5 @@ import { ConfigInterface } from "../data/config"; +import { splitSelectionKey } from "./names"; /** * Merges the values of all configurations into a single object mapping @@ -21,7 +22,7 @@ export function getSequenceData(projectConfigs: ConfigInterface[]) { seqData[key].push(configData[key]); } } else { - const [modelicaPath] = key.split("-"); + const [modelicaPath] = splitSelectionKey(key); if (modelicaPath !== configData[key]) { seqData[key] = [configData[key]]; } diff --git a/client/tests/data/options-QuotedTemplate.json b/client/tests/data/options-QuotedTemplate.json new file mode 100644 index 00000000..47fef476 --- /dev/null +++ b/client/tests/data/options-QuotedTemplate.json @@ -0,0 +1,387 @@ +{ + "template": { + "modelicaPath": "QuotedPackage.Template.QuotedTemplate", + "scheduleOptionPaths": [], + "systemTypes": [ + "QuotedPackage.Template" + ], + "pathModifiers": {}, + "name": "Template with quoted identifiers" + }, + "options": { + "QuotedPackage.Template.QuotedTemplate.'rep.1'": { + "modelicaPath": "QuotedPackage.Template.QuotedTemplate.'rep.1'", + "type": "QuotedPackage.Component.First", + "name": "Replaceable component with a quoted name", + "group": "Configuration", + "tab": "", + "visible": true, + "modifiers": {}, + "enable": true, + "choiceModifiers": {}, + "replaceable": true, + "options": [ + "QuotedPackage.Component.First", + "QuotedPackage.Component.Second" + ], + "definition": false, + "shortExclType": false + }, + "QuotedPackage.Component.Partial.'a.b'": { + "modelicaPath": "QuotedPackage.Component.Partial.'a.b'", + "type": "Boolean", + "value": true, + "name": "Parameter with a dot", + "group": "Quoted", + "tab": "", + "visible": true, + "enable": true, + "modifiers": {}, + "replaceable": false, + "options": [], + "definition": false, + "shortExclType": false + }, + "QuotedPackage.Component.Partial.'val-1'": { + "modelicaPath": "QuotedPackage.Component.Partial.'val-1'", + "type": "QuotedPackage.Types.Valve", + "value": "QuotedPackage.Types.Valve.'two-way'", + "name": "Enumeration parameter with a dash", + "group": "Quoted", + "tab": "", + "visible": true, + "enable": true, + "modifiers": {}, + "replaceable": false, + "options": [ + "QuotedPackage.Types.Valve.'two-way'", + "QuotedPackage.Types.Valve.'three.way'", + "QuotedPackage.Types.Valve.'13\\'H'", + "QuotedPackage.Types.Valve.plain" + ], + "definition": false, + "shortExclType": false + }, + "QuotedPackage.Types.Valve": { + "modelicaPath": "QuotedPackage.Types.Valve", + "name": "Enumeration with quoted literals", + "type": "QuotedPackage.Types.Valve", + "value": "QuotedPackage.Types.Valve", + "visible": true, + "replaceable": false, + "options": [ + "QuotedPackage.Types.Valve.'two-way'", + "QuotedPackage.Types.Valve.'three.way'", + "QuotedPackage.Types.Valve.'13\\'H'", + "QuotedPackage.Types.Valve.plain" + ], + "definition": true, + "shortExclType": false, + "treeList": [ + "QuotedPackage.Types.Valve" + ] + }, + "QuotedPackage.Types.Valve.'two-way'": { + "modelicaPath": "QuotedPackage.Types.Valve.'two-way'", + "name": "Literal with a dash", + "type": "QuotedPackage.Types.Valve", + "value": "QuotedPackage.Types.Valve.'two-way'", + "visible": false, + "replaceable": false, + "definition": true, + "shortExclType": false, + "treeList": [ + "QuotedPackage.Types.Valve" + ] + }, + "QuotedPackage.Types.Valve.'three.way'": { + "modelicaPath": "QuotedPackage.Types.Valve.'three.way'", + "name": "Literal with a dot", + "type": "QuotedPackage.Types.Valve", + "value": "QuotedPackage.Types.Valve.'three.way'", + "visible": false, + "replaceable": false, + "definition": true, + "shortExclType": false, + "treeList": [ + "QuotedPackage.Types.Valve" + ] + }, + "QuotedPackage.Types.Valve.'13\\'H'": { + "modelicaPath": "QuotedPackage.Types.Valve.'13\\'H'", + "name": "Literal with an escaped quote", + "type": "QuotedPackage.Types.Valve", + "value": "QuotedPackage.Types.Valve.'13\\'H'", + "visible": false, + "replaceable": false, + "definition": true, + "shortExclType": false, + "treeList": [ + "QuotedPackage.Types.Valve" + ] + }, + "QuotedPackage.Types.Valve.plain": { + "modelicaPath": "QuotedPackage.Types.Valve.plain", + "name": "Unquoted literal", + "type": "QuotedPackage.Types.Valve", + "value": "QuotedPackage.Types.Valve.plain", + "visible": false, + "replaceable": false, + "definition": true, + "shortExclType": false, + "treeList": [ + "QuotedPackage.Types.Valve" + ] + }, + "QuotedPackage.Component.Partial.'k\\?'": { + "modelicaPath": "QuotedPackage.Component.Partial.'k\\?'", + "type": "Real", + "value": 1, + "name": "Parameter with a redundant escape, enabled by a quoted parameter", + "group": "Quoted", + "tab": "", + "visible": true, + "enable": "'a.b'", + "modifiers": {}, + "replaceable": false, + "options": [], + "definition": false, + "shortExclType": false + }, + "QuotedPackage.Component.Partial": { + "modelicaPath": "QuotedPackage.Component.Partial", + "type": "QuotedPackage.Component.Partial", + "name": "Interface with quoted parameters", + "visible": false, + "replaceable": false, + "options": [ + "QuotedPackage.Component.Partial.'a.b'", + "QuotedPackage.Component.Partial.'val-1'", + "QuotedPackage.Component.Partial.'k\\?'" + ], + "definition": true, + "shortExclType": false, + "treeList": [ + "QuotedPackage.Component.Partial" + ] + }, + "QuotedPackage.Component.First": { + "modelicaPath": "QuotedPackage.Component.First", + "type": "QuotedPackage.Component.First", + "name": "First component", + "visible": false, + "replaceable": false, + "options": [ + "QuotedPackage.Component.Partial.'a.b'", + "QuotedPackage.Component.Partial.'val-1'", + "QuotedPackage.Component.Partial.'k\\?'" + ], + "definition": true, + "shortExclType": false, + "treeList": [ + "QuotedPackage.Component.First", + "QuotedPackage.Component.Partial" + ] + }, + "QuotedPackage.Component.Second.'only-second'": { + "modelicaPath": "QuotedPackage.Component.Second.'only-second'", + "type": "Boolean", + "value": false, + "name": "Parameter of the second component only", + "group": "Quoted", + "tab": "", + "visible": true, + "enable": true, + "modifiers": {}, + "replaceable": false, + "options": [], + "definition": false, + "shortExclType": false + }, + "QuotedPackage.Component.Second": { + "modelicaPath": "QuotedPackage.Component.Second", + "type": "QuotedPackage.Component.Second", + "name": "Second component", + "visible": false, + "modifiers": { + "QuotedPackage.Component.Second.'val-1'": { + "expression": "QuotedPackage.Types.Valve.'13\\'H'", + "final": false + } + }, + "replaceable": false, + "options": [ + "QuotedPackage.Component.Second.'only-second'", + "QuotedPackage.Component.Partial.'a.b'", + "QuotedPackage.Component.Partial.'val-1'", + "QuotedPackage.Component.Partial.'k\\?'" + ], + "definition": true, + "shortExclType": false, + "treeList": [ + "QuotedPackage.Component.Second", + "QuotedPackage.Component.Partial" + ] + }, + "QuotedPackage.Template.QuotedTemplate.'c-d'": { + "modelicaPath": "QuotedPackage.Template.QuotedTemplate.'c-d'", + "type": "QuotedPackage.Component.First", + "name": "Component with a quoted name", + "group": "", + "tab": "", + "visible": false, + "enable": true, + "modifiers": { + "QuotedPackage.Template.QuotedTemplate.'c-d'.'a.b'": { + "expression": false, + "final": false + }, + "QuotedPackage.Template.QuotedTemplate.'c-d'.'val-1'": { + "expression": "QuotedPackage.Types.Valve.'three.way'", + "final": false + } + }, + "replaceable": false, + "options": [ + "QuotedPackage.Component.Partial.'a.b'", + "QuotedPackage.Component.Partial.'val-1'", + "QuotedPackage.Component.Partial.'k\\?'" + ], + "definition": false, + "shortExclType": false + }, + "QuotedPackage.Template.QuotedTemplate.'with-dash'": { + "modelicaPath": "QuotedPackage.Template.QuotedTemplate.'with-dash'", + "type": "Boolean", + "value": true, + "name": "Parameter with a dash", + "group": "Configuration", + "tab": "", + "visible": true, + "enable": true, + "modifiers": {}, + "replaceable": false, + "options": [], + "definition": false, + "shortExclType": false + }, + "QuotedPackage.Template.QuotedTemplate.'why\\?'": { + "modelicaPath": "QuotedPackage.Template.QuotedTemplate.'why\\?'", + "type": "Boolean", + "value": "'c-d'.'a.b'", + "name": "Parameter with a redundant escape, bound to a quoted path", + "group": "Configuration", + "tab": "", + "visible": true, + "enable": "'with-dash'", + "modifiers": {}, + "replaceable": false, + "options": [], + "definition": false, + "shortExclType": false + }, + "QuotedPackage.Template.QuotedTemplate.unquoted": { + "modelicaPath": "QuotedPackage.Template.QuotedTemplate.unquoted", + "type": "Boolean", + "value": "'why\\?'", + "name": "Unquoted parameter bound to a quoted parameter", + "group": "Configuration", + "tab": "", + "visible": true, + "enable": "'why\\?'", + "modifiers": {}, + "replaceable": false, + "options": [], + "definition": false, + "shortExclType": false + }, + "QuotedPackage.Template.QuotedTemplate.valve": { + "modelicaPath": "QuotedPackage.Template.QuotedTemplate.valve", + "type": "QuotedPackage.Types.Valve", + "value": "QuotedPackage.Types.Valve.'13\\'H'", + "name": "Enumeration parameter with a quoted literal", + "group": "Configuration", + "tab": "", + "visible": true, + "enable": true, + "modifiers": {}, + "replaceable": false, + "options": [ + "QuotedPackage.Types.Valve.'two-way'", + "QuotedPackage.Types.Valve.'three.way'", + "QuotedPackage.Types.Valve.'13\\'H'", + "QuotedPackage.Types.Valve.plain" + ], + "definition": false, + "shortExclType": false + }, + "QuotedPackage.Template.QuotedTemplate": { + "modelicaPath": "QuotedPackage.Template.QuotedTemplate", + "type": "QuotedPackage.Template.QuotedTemplate", + "name": "Template with quoted identifiers", + "visible": false, + "replaceable": false, + "options": [ + "QuotedPackage.Template.QuotedTemplate.'rep.1'", + "QuotedPackage.Template.QuotedTemplate.'c-d'", + "QuotedPackage.Template.QuotedTemplate.'with-dash'", + "QuotedPackage.Template.QuotedTemplate.'why\\?'", + "QuotedPackage.Template.QuotedTemplate.unquoted", + "QuotedPackage.Template.QuotedTemplate.valve" + ], + "definition": true, + "shortExclType": false, + "treeList": [ + "QuotedPackage.Template.QuotedTemplate" + ] + }, + "datAll": { + "modelicaPath": "datAll", + "name": "datAll", + "type": "Buildings.Templates.Data.AllSystems", + "visible": false, + "modifiers": { + "Buildings.Templates.Data.AllSystems.projectSetting": { + "expression": false, + "final": false + } + }, + "replaceable": false, + "options": [ + "Buildings.Templates.Data.AllSystems" + ], + "definition": false, + "shortExclType": false + }, + "Buildings.Templates.Data.AllSystems.projectSetting": { + "modelicaPath": "Buildings.Templates.Data.AllSystems.projectSetting", + "type": "Boolean", + "value": false, + "name": "Example Project setting", + "group": "", + "tab": "", + "visible": true, + "enable": true, + "modifiers": {}, + "replaceable": false, + "options": [], + "definition": false, + "shortExclType": false + }, + "Buildings.Templates.Data.AllSystems": { + "modelicaPath": "Buildings.Templates.Data.AllSystems", + "type": "Buildings.Templates.Data.AllSystems", + "name": "Top-level (whole building) system parameters", + "visible": false, + "replaceable": false, + "options": [ + "Buildings.Templates.Data.AllSystems.projectSetting" + ], + "definition": true, + "shortExclType": false, + "treeList": [ + "Buildings.Templates.Data.AllSystems" + ] + } + } +} diff --git a/client/tests/interpreter/interpreter-quoted.test.ts b/client/tests/interpreter/interpreter-quoted.test.ts new file mode 100644 index 00000000..980ba19c --- /dev/null +++ b/client/tests/interpreter/interpreter-quoted.test.ts @@ -0,0 +1,108 @@ +/** + * Quoted identifiers (MLS 3.7 §2.3.1, docs/selection-keys.md) + */ +import { TemplateInterface, OptionInterface } from "../../src/data/template"; +import { + ConfigContext, + constructSelectionPath, +} from "../../src/interpreter/interpreter"; +import { + mapToDisplayOptions, + FlatConfigOption, +} from "../../src/interpreter/display-option"; +import { ConfigValues } from "../../src/utils/modifier-helpers"; + +/** + * Options generated from server/tests/static-data/QuotedPackage via server parser. + * This file is generated by running: + * cd server && npx jest tests/integration/parser/quoted-identifiers.test.ts + */ +const { + template, + options, +}: { + template: TemplateInterface; + options: { [key: string]: OptionInterface }; +} = require("../data/options-QuotedTemplate.json"); + +const T = template.modelicaPath; +const C = "QuotedPackage.Component"; +const VALVE = "QuotedPackage.Types.Valve"; + +const createContext = (selections: ConfigValues = {}) => + new ConfigContext(template, { selections } as any, options, selections); + +const flatten = (items: any[], out: FlatConfigOption[] = []) => { + items.forEach((i) => + "groupName" in i ? flatten(i.items, out) : out.push(i), + ); + return out; +}; + +describe("Quoted identifiers", () => { + it("resolves values through quoted names", () => { + const context = createContext(); + expect(context.getValue("'c-d'.'a.b'")).toBe(false); + expect(context.getValue("'c-d'.'val-1'")).toEqual(`${VALVE}.'three.way'`); + expect(context.getValue("'rep.1'.'val-1'")).toEqual(`${VALVE}.'two-way'`); + expect(context.getValue("'with-dash'")).toBe(true); + // 'why\?' is bound to 'c-d'.'a.b', and unquoted to 'why\?' + expect(context.getValue("'why\\?'")).toBe(false); + expect(context.getValue("unquoted")).toBe(false); + expect(context.getValue("valve")).toEqual(`${VALVE}.'13\\'H'`); + }); + + it("evaluates enable expressions with quoted names", () => { + const context = createContext(); + expect(context.getOptionInstance("'rep.1'.'k\\?'")?.display).toBe(true); + expect(context.getOptionInstance("'c-d'.'k\\?'")?.display).toBe(false); + expect(context.getOptionInstance("'why\\?'")?.display).toBe(true); + expect(context.getOptionInstance("unquoted")?.display).toBe(false); + }); + + it("applies selections keyed with quoted names", () => { + const rep = `${T}.'rep.1'`; + const context = createContext({ + [constructSelectionPath(rep, "'rep.1'")]: `${C}.Second`, + [constructSelectionPath(`${C}.Partial.'a.b'`, "'rep.1'.'a.b'")]: false, + }); + expect(context.getValue("'rep.1'")).toEqual(`${C}.Second`); + // Modifier of the extends clause of Second + expect(context.getValue("'rep.1'.'val-1'")).toEqual(`${VALVE}.'13\\'H'`); + expect(context.getValue("'rep.1'.'only-second'")).toBe(false); + expect(context.getOptionInstance("'rep.1'.'k\\?'")?.display).toBe(false); + }); + + it("displays the same options as with unquoted names", () => { + // Quoted identifiers and their unquoted replacement, as in the JSON text + const unquoted: [string, string][] = [ + ["'rep.1'", "rep1"], + ["'c-d'", "cd"], + ["'a.b'", "ab"], + ["'val-1'", "val1"], + ["'k\\\\?'", "k"], + ["'with-dash'", "withDash"], + ["'why\\\\?'", "why"], + ["'only-second'", "onlySecond"], + ["'two-way'", "twoWay"], + ["'three.way'", "threeWay"], + ["'13\\\\'H'", "H13"], + ]; + const rename = (text: string) => + unquoted.reduce((t, [q, u]) => t.split(q).join(u), text); + const renamed = JSON.parse(rename(JSON.stringify(options))); + const displayed = (opts: { [key: string]: OptionInterface }) => + flatten( + mapToDisplayOptions( + new ConfigContext(template, { selections: {} } as any, opts, {}), + ), + ).map((o) => [o.scope, o.value]); + + const quotedDisplay = displayed(options); + expect(quotedDisplay.map(([scope]) => scope)).toContain("'rep.1'.'a.b'"); + expect(JSON.parse(rename(JSON.stringify(quotedDisplay)))).toEqual( + displayed(renamed), + ); + expect(JSON.stringify(renamed)).not.toContain("'"); + }); +}); diff --git a/client/tests/utils/names.test.ts b/client/tests/utils/names.test.ts new file mode 100644 index 00000000..868de7f7 --- /dev/null +++ b/client/tests/utils/names.test.ts @@ -0,0 +1,87 @@ +/** + * Scanner for Modelica names (docs/selection-keys.md, "Parsing") + */ +import { + enclosingName, + firstIdent, + hasQuotedIdent, + isName, + lastIdent, + splitName, + splitSelectionKey, +} from "../../src/utils/names"; + +describe("Modelica names", () => { + it("splits names as a plain split when nothing is quoted", () => { + ["", "a", "a.b.c", ".A.b", "a[1].b"].forEach((name) => + expect(splitName(name)).toEqual(name.split(".")), + ); + }); + + it("does not split quoted identifiers", () => { + expect(splitName("'a.b'")).toEqual(["'a.b'"]); + expect(splitName("P.'c-d'.'a.b'.e")).toEqual(["P", "'c-d'", "'a.b'", "e"]); + // Escaped quote and escaped backslash before the closing quote + expect(splitName("'13\\'H.x'.y")).toEqual(["'13\\'H.x'", "y"]); + expect(splitName("'a\\\\'.b")).toEqual(["'a\\\\'", "b"]); + expect(splitName("'\"'.b")).toEqual(["'\"'", "b"]); + }); + + it("throws on unclosed quoted identifiers", () => { + expect(() => splitName("a.'b")).toThrow("Unclosed quoted identifier"); + expect(() => splitName("a.'b\\'")).toThrow("Unclosed quoted identifier"); + }); + + it("gives the first, last and enclosing names", () => { + const name = "'c-d'.e.'a.b'"; + expect(firstIdent(name)).toEqual("'c-d'"); + expect(lastIdent(name)).toEqual("'a.b'"); + expect(enclosingName(name)).toEqual("'c-d'.e"); + expect(enclosingName(name, 2)).toEqual("'c-d'"); + expect(enclosingName("'a.b'")).toEqual(""); + }); + + it("detects quoted identifiers", () => { + expect(hasQuotedIdent("P.A")).toBe(false); + expect(hasQuotedIdent("P.'A'")).toBe(true); + expect(hasQuotedIdent("P.'A.B'.C")).toBe(true); + }); + + it("validates names", () => { + [ + "a", + ".Modelica.Units", + "P.'with-dash'", + "'c-d'.'a.b'", + "'13\\'H'", + "'a \"b\" ?'", + ].forEach((name) => expect(isName(name)).toBe(true)); + [ + "", + "1a", + "a.", + "a..b", + "a-b", + "''", // empty quoted identifier + "'a'b'", + "'a\\x'", // invalid escape + "a[1]", + ].forEach((name) => expect(isName(name)).toBe(false)); + }); + + it("splits selection keys at the first dash outside quoted identifiers", () => { + expect(splitSelectionKey("P.T.p-a.p")).toEqual(["P.T.p", "a.p"]); + expect(splitSelectionKey("P.'p-1'-'c-d'.'p-1'")).toEqual([ + "P.'p-1'", + "'c-d'.'p-1'", + ]); + expect(splitSelectionKey("P.AllSystems.stdEne")).toEqual([ + "P.AllSystems.stdEne", + ]); + }); + + it("keeps escapes as written", () => { + expect(splitName("P.'a\\\"b'.'c\\?'")).toEqual(["P", "'a\\\"b'", "'c\\?'"]); + expect(lastIdent("P.'a\\n'")).toEqual("'a\\n'"); + }); +}); diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 6d342111..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,62 +0,0 @@ -version: "2" - -services: - nginx-proxy: - image: nginxproxy/nginx-proxy - ports: - - "80:80" - - "443:443" - volumes: - - conf:/etc/nginx/conf.d - - vhost:/etc/nginx/vhost.d - - html:/usr/share/nginx/html - - certs:/etc/nginx/certs:ro - - /var/run/docker.sock:/tmp/docker.sock:ro - - acme-companion: - image: nginxproxy/acme-companion - volumes_from: - - nginx-proxy - volumes: - - certs:/etc/nginx/certs:rw - - acme:/etc/acme.sh - - /var/run/docker.sock:/var/run/docker.sock:ro - - client: - image: node - expose: - - 3000 - command: "npm run start" - working_dir: "/home/node/app" - volumes: - - "./client:/home/node/app" - environment: - - VIRTUAL_HOST=ec2-52-35-179-192.us-west-2.compute.amazonaws.com - - LETSENCRYPT_HOST=ec2-52-35-179-192.us-west-2.compute.amazonaws.com - - VIRTUAL_PATH=/ - env_file: - - ./client/.env - - server: - build: - context: ./server - dockerfile: ./Dockerfile - expose: - - 3000 - environment: - - VIRTUAL_HOST=ec2-52-35-179-192.us-west-2.compute.amazonaws.com - - LETSENCRYPT_HOST=ec2-52-35-179-192.us-west-2.compute.amazonaws.com - - VIRTUAL_PATH=/api - - MODELICAPATH=/dependencies/ModelicaStandardLibrary:/dependencies/modelica-buildings - command: npm run start - env_file: - - ./server/.env - volumes: - - ./server:/app - -volumes: - conf: - vhost: - html: - certs: - acme: diff --git a/docs/selection-keys.md b/docs/selection-keys.md index 9c753e57..97791fbc 100644 --- a/docs/selection-keys.md +++ b/docs/selection-keys.md @@ -50,7 +50,7 @@ Examples: Keys sharing a prefix are written as one nested modifier, and the modifications of the elements of a redeclared component go into its redeclaration, as in the last row. This is the merged form defined by MLS §7.2.4. -## Parsing and normalization +## Parsing `rootClass` contains no `Q-IDENT`, hence no `-`: the first `-` of a key always separates `rootClass` from `elementPath`. @@ -60,7 +60,7 @@ A component `Q-IDENT` may contain `.`, `-` and escaped quotes (`'13\'H'`). Names 2. Inside a `Q-IDENT`, `\` escapes the next character (so `\'` does not close the identifier) and `'` closes it. 3. A key is invalid if it has no `-`, if `rootClass` does not match `className`, if a `Q-IDENT` is not closed, or if `elementPath` does not match `elementName`. -MLS §2.3.1 states that the redundant escapes `\?` and `\"` are the same as `?` and `"`. Different spellings of the same identifier would yield different keys, so keys are built from the **normalized** spelling, where `\?` and `\"` are replaced with `?` and `"`. All other escapes (`\'`, `\\`, `\a`, `\b`, `\f`, `\n`, `\r`, `\t`, `\v`) are kept as written. +**Escapes are kept as written.** MLS §2.3.1 states that the redundant escapes `\?` and `\"` are the same as `?` and `"`, so `'a\?'` and `'a?'` would be the same identifier. Dymola and OCT treat them as distinct identifiers: they reject a reference `'a?'` to a declaration `'a\?'`. Keys use the spelling of the declaration, so that the modifications written from them are accepted by these tools, and remain valid under the MLS. A source that spells the same identifier in different ways is not supported (see Known limitations). The same scanner provides the name helpers (split into identifiers, last identifier, enclosing name). They replace every plain `split(".")` and `split("-")` applied to Modelica names in the client (interpreter, display mapping, modifier and expression helpers), the server parser, and the sequence document pipeline (Python port of the scanner). @@ -88,7 +88,7 @@ enumLiteral = className "." E-IDENT (enumeration type, then literal: quoted - **Consumers that only need values.** The MBL templates bind a `typ` parameter in every class that can be redeclared (type introspection: Modelica has no `isOfType()`, its type system being structural). Consumers that do not write Modelica, such as the sequence document, read `value` only, through these `typ` parameters (see Mappings). - **Enumeration literals** may be quoted (`type Valve = enumeration('two-way', threeWay)`), but the enumeration type must not be (see the quoted class identifier rule). The literal is the last identifier of the value, extracted with the scanner. `P.Types.Valve.'two-way'` and `P.Types.Valve.two_way` are distinct values. -- **Normalization.** Quoted literals are normalized like quoted component identifiers, so that values compare equal (client `==`, mogrifier `EQUALS`/`ANY`/`NOT_EQUALS`) whatever the escape spelling in the source. +- **Spelling.** Quoted literals are kept as written, like quoted component identifiers (see Parsing): values compare equal (client `==`, mogrifier `EQUALS`/`ANY`/`NOT_EQUALS`) if the source spells the literal consistently. - **Recognizing a value as a name.** `isValidModelicaName` and `isFullyQualifiedName` accept a trailing quoted identifier, so a quoted literal is resolved as an enumeration value and not parsed as a string literal. ## Resolution rules for `elementPath` @@ -115,7 +115,7 @@ enumLiteral = className "." E-IDENT (enumeration type, then literal: quoted | Function | Responsibility | |---|---| -| `names.ts` | Quote-aware name helpers (see Parsing and normalization). | +| `names.ts` | Quote-aware name helpers (see Parsing). | | `selectionKey.ts` | `selectionKey(rootClass, elementPath)`, `parseSelectionKey(key)`. | | `applyPathModifiers(path, pathModifiers)` | Inner/outer rewriting. | | `memberOption(classPath, name, ctx)` | Declared or inherited element `name` of class `classPath`, or `null`. The template is handled like any other class. | @@ -293,3 +293,4 @@ Issues found by the probe, unrelated to quoted identifiers: ## Known limitations - **Replaceable short classes.** On `main`, short-class choices are written under one key (`…ShortClass-ShortClass`), read under another (`…ShortClass-.FirstComponent`), and the parameters of the selected class are displayed and keyed under the class name (`ShortClass.container`) rather than under the instance. Selecting another class does not change how the instance resolves. No template in `templates.json` uses a replaceable short class in the configuration panel. This refactoring fixes the key (rule 4); the display and resolution of short class instances are tracked separately. +- **Spellings of quoted identifiers.** A source that spells a quoted identifier with and without a redundant escape (`'a\?'` and `'a?'`, `'a\"'` and `'a"'`) is not supported: ctrl-flow keeps identifiers as written, like Dymola and OCT, which reject such a source, although MLS §2.3.1 makes both spellings the same identifier. diff --git a/server/src/parser/loader.ts b/server/src/parser/loader.ts index aba93f3d..e5895399 100644 --- a/server/src/parser/loader.ts +++ b/server/src/parser/loader.ts @@ -3,6 +3,7 @@ import fs from "fs"; import { execSync } from "child_process"; import { typeStore } from "./parser"; +import { hasQuotedIdent } from "./names"; import config from "../../src/config"; // The following arrays are populated with the ***full class names*** @@ -233,6 +234,54 @@ function getPathFromClassName( return fs.existsSync(jsonFile) ? jsonFile : null; } +// Keys of class names +const CLASS_NAME_KEYS = new Set(["type_specifier", "within"]); +// Keys of objects holding class names +const CLASS_NAMES_IN: { [key: string]: (value: any) => string[] } = { + long_class_specifier: (v) => [v.identifier], + short_class_specifier: (v) => [v.identifier, v.value?.name], + der_class_specifier: (v) => [ + v.identifier, + v.der_class_specifier_value?.type_specifier, + ], + extends_clause: (v) => [v.name], + constraining_clause: (v) => [v.name], + import_clause: (v) => [v.identifier, v.name], +}; + +// Parsed files by path: an object is parsed once, as the parser keys +// constructed files by object +const jsonCache = new Map(); + +/** + * Reads a modelica-json file, rejecting quoted class identifiers: a quoted + * class cannot be stored with the directory hierarchy mapping (MLS §13.4.1). + * See docs/selection-keys.md, "Grammar". + */ +function readJson(jsonFile: string): Object { + const cached = jsonCache.get(jsonFile); + if (cached) { + return cached; + } + const json = JSON.parse(fs.readFileSync(jsonFile, "utf8"), (key, value) => { + const classNames = + typeof value === "string" + ? CLASS_NAME_KEYS.has(key) + ? [value] + : [] + : (CLASS_NAMES_IN[key]?.(value) ?? []); + const quoted = classNames.find((name) => name && hasQuotedIdent(name)); + if (quoted) { + throw new Error( + `Quoted class identifiers are not supported: ${quoted} in ${jsonFile}`, + ); + } + return value; + }); + jsonCache.set(jsonFile, json); + return json; +} + // Resolving a class name to a file on disk walks the directory tree with // synchronous fs.existsSync calls. The same class name is looked up // repeatedly (often thousands of times) while parsing, so cache the result @@ -259,7 +308,7 @@ export function loader(className: string): Object | undefined { for (const dir of MODELICA_JSON_PATH) { const jsonFile = getPathFromClassName(className, dir); if (jsonFile && fs.existsSync(jsonFile)) { - result = require(jsonFile); + result = readJson(jsonFile); break; } } diff --git a/server/src/parser/modification.ts b/server/src/parser/modification.ts index c84e311b..70376860 100644 --- a/server/src/parser/modification.ts +++ b/server/src/parser/modification.ts @@ -1,6 +1,7 @@ import { typeStore, isInputGroup, LongClass, Element } from "./parser"; import { Literal } from "./expression"; import * as mj from "./mj-types"; +import { lastIdent } from "./names"; /** * Modifications are places where there is an assignment, e.g. @@ -159,7 +160,7 @@ function finalizeAllParams( : []; allParams.forEach((el) => { - const paramName = el.modelicaPath.split(".").pop() as string; + const paramName = lastIdent(el.modelicaPath); if (!boundNames.has(paramName)) { childMods.push( new Modification(childBasePath, paramName, undefined, [], true), diff --git a/server/src/parser/names.ts b/server/src/parser/names.ts new file mode 100644 index 00000000..921de4ee --- /dev/null +++ b/server/src/parser/names.ts @@ -0,0 +1,118 @@ +/** + * Modelica names, which may contain quoted identifiers (MLS 3.7 §2.3.1) + * + * A quoted identifier may contain ".", "-" and escaped quotes, as in 'a.b', + * 'with-dash' or '13\'H'. Names are therefore never split with a plain string + * split, but with the scanner below. + * Specification: docs/selection-keys.md, "Parsing". + * + * This file is also imported by the client (client/src/utils/names.ts): it + * must not import anything. + */ + +const UNQUOTED_IDENT = "[a-zA-Z_][a-zA-Z0-9_]*"; +const Q_CHAR = `[a-zA-Z0-9_!#$%&()*+,\\-./:;<=>?@[\\]^{}|~ "]`; +const S_ESCAPE = `\\\\['"?\\\\abfnrtv]`; +const Q_IDENT = `'(?:${Q_CHAR}|${S_ESCAPE})+'`; +const IDENT = `(?:${UNQUOTED_IDENT}|${Q_IDENT})`; +const NAME_RE = new RegExp(`^\\.?${IDENT}(?:\\.${IDENT})*$`); + +/** + * Calls `visit` for each character outside quoted identifiers and string + * literals, and for each quoted identifier or string literal as a whole, + * quotes included. + * Throws if a quoted identifier or a string literal is not closed. + */ +function scan( + text: string, + visit: (token: string, index: number, quoted: boolean) => void, +) { + let i = 0; + while (i < text.length) { + const quote = text[i]; + if (quote !== "'" && quote !== '"') { + visit(quote, i, false); + i++; + continue; + } + let j = i + 1; + while (j < text.length && text[j] !== quote) { + j += text[j] === "\\" ? 2 : 1; // skip the escaped character + } + if (j >= text.length) { + const kind = quote === "'" ? "quoted identifier" : "string"; + throw new Error(`Unclosed ${kind} in: ${text}`); + } + visit(text.slice(i, j + 1), i, true); + i = j + 1; + } +} + +/** + * Returns the indices of `char` outside quoted identifiers and string literals + */ +function indicesOf(text: string, char: string): number[] { + const indices: number[] = []; + scan(text, (token, index, quoted) => { + if (!quoted && token === char) indices.push(index); + }); + return indices; +} + +/** True if `name` is a Modelica name: [ "." ] IDENT { "." IDENT } */ +export function isName(name: string): boolean { + return NAME_RE.test(name); +} + +/** True if `ident` is a quoted identifier */ +export function isQuotedIdent(ident: string): boolean { + return ident.startsWith("'"); +} + +/** True if any identifier of `name` is quoted */ +export function hasQuotedIdent(name: string): boolean { + return splitName(name).some(isQuotedIdent); +} + +/** + * Splits a name into its identifiers, as `name.split(".")` does for a name + * without quoted identifiers: "a.'b.c'" gives ["a", "'b.c'"]. + */ +export function splitName(name: string): string[] { + const parts: string[] = []; + let start = 0; + indicesOf(name, ".").forEach((i) => { + parts.push(name.slice(start, i)); + start = i + 1; + }); + parts.push(name.slice(start)); + return parts; +} + +/** First identifier of a name */ +export function firstIdent(name: string): string { + return splitName(name)[0]; +} + +/** Last identifier of a name */ +export function lastIdent(name: string): string { + const idents = splitName(name); + return idents[idents.length - 1]; +} + +/** + * Name without its last `count` identifiers: "a.b.c" gives "a.b", and "" if + * no identifier is left + */ +export function enclosingName(name: string, count = 1): string { + return splitName(name).slice(0, -count).join("."); +} + +/** + * Splits a selection key at its first "-" outside quoted identifiers: + * returns [modelicaPath, instancePath], or [key] for a key without "-" + */ +export function splitSelectionKey(key: string): string[] { + const [i] = indicesOf(key, "-"); + return i === undefined ? [key] : [key.slice(0, i), key.slice(i + 1)]; +} diff --git a/server/src/parser/parser.ts b/server/src/parser/parser.ts index f7a25fee..bafaf9d6 100644 --- a/server/src/parser/parser.ts +++ b/server/src/parser/parser.ts @@ -27,6 +27,7 @@ import { getExpression, } from "./expression"; import * as mj from "./mj-types"; +import { enclosingName, firstIdent, lastIdent, splitName } from "./names"; export const EXTEND_NAME = "__extend"; const PROJECT_PATH = "Buildings.Templates.Data.AllSystems"; @@ -96,9 +97,8 @@ class Store { return this._store.get(path); } else { // walk inheritance chain to attempt to find correct element - const pathList = path.split("."); - const name = pathList.pop(); - const basePath = pathList.join("."); + const name = lastIdent(path); + const basePath = enclosingName(path); // avoid infinite recursion if (basePath !== path) { @@ -168,12 +168,12 @@ class Store { // package (e.g. a local class "Buildings") would shadow it; such a reference // is misresolved here — but it already failed before this shortcut existed, // and the pattern is not used in MBL. - const firstSegment = path?.split(".")[0]; + const firstSegment = path && firstIdent(path); if (firstSegment && getTopLevelPackageNames().has(firstSegment)) { return [path]; } - const splitBasePath = basePath ? basePath.split(".") : []; + const splitBasePath = basePath ? splitName(basePath) : []; const pathList: string[] = []; while (splitBasePath.length > 0) { @@ -195,8 +195,8 @@ class Store { file.elementList.map((e) => { // find the element that matches as much as the path as possible let pathCount = 0; - const pList = e.modelicaPath.split("."); - e.modelicaPath.split(".").forEach((segment, i) => { + const pList = splitName(e.modelicaPath); + pList.forEach((segment, i) => { if (segment !== undefined && pList[i] === undefined) { pathCount = pList[i] === segment ? pathCount + 1 : pathCount; } @@ -267,7 +267,7 @@ export function createProjectInputs(): { [key: string]: TemplateInput } { [key: string]: TemplateInput; }; const spoofedModList = Object.values(projectInputs).map((i) => { - const modName = i.modelicaPath.split(".").pop(); + const modName = lastIdent(i.modelicaPath); return new Modification(PROJECT_PATH, modName, i.value); }); @@ -503,9 +503,7 @@ export abstract class Element { } get baseType(): string { - const pathList = this.modelicaPath.split("."); - pathList.pop(); - return pathList.join("."); + return enclosingName(this.modelicaPath); } /** @@ -1427,7 +1425,7 @@ export class File { public elementList: Element[] = []; constructor(obj: any, className: string) { this.package = obj.within; - const splitFilePath = className.split("."); + const splitFilePath = splitName(className); if (this.package) { // FIXME: The assumption below is not consistent with MLS. // A one-to-one relationship between class names and file paths cannot be generically assumed. @@ -1437,7 +1435,7 @@ export class File { // a mismatch means an incorrectly typed or structured package // Folder and file should always line up, e.g. // TestPackage/Interface/stuff.mo should have a within path of 'TestPackage.Interface' - this.package.split(".").forEach((value, index) => { + splitName(this.package).forEach((value, index) => { if (index < splitFilePath.length && value !== splitFilePath[index]) { throw new Error( "Malformed Modelica Package or Incorrect type assigned", diff --git a/server/src/parser/schedule.ts b/server/src/parser/schedule.ts index e974101e..d8517a69 100644 --- a/server/src/parser/schedule.ts +++ b/server/src/parser/schedule.ts @@ -62,6 +62,7 @@ import { } from "./parser"; import { Modification } from "./modification"; import { Expression, Literal } from "./expression"; +import { firstIdent } from "./names"; // --- Header definitions --- @@ -175,7 +176,7 @@ function applyModifiers( // Plain literal — if it's a string reference in the mod map, substitute if (typeof value === "string") { // Check top-level name (e.g. "typ") and dotted first segment (e.g. "cfg" in "cfg.typCoiCoo") - const topName = value.split(".")[0]; + const topName = firstIdent(value); if (modMap.has(value)) { return modMap.get(value)!; } diff --git a/server/src/parser/template.ts b/server/src/parser/template.ts index a4e33e90..914b1d10 100644 --- a/server/src/parser/template.ts +++ b/server/src/parser/template.ts @@ -12,6 +12,7 @@ import * as parser from "./parser"; import { Expression, Literal } from "./expression"; import { Modification } from "./modification"; import { buildParameterTable, Table } from "./schedule"; +import { enclosingName } from "./names"; const templateStore = new Map(); const systemTypeStore = new Map(); @@ -170,7 +171,7 @@ export function _getTreeList(option: Option) { treeElement = o; } else { // remove the trailing '*.' to retrieve the class name from the instance path - treeElement = o.split(".").slice(0, -1).join("."); + treeElement = enclosingName(o); } if (!treeList.includes(treeElement)) { treeList.push(treeElement); @@ -266,13 +267,10 @@ export class Template { } _extractSystemTypes(element: parser.Element) { - const path = element.modelicaPath.split("."); - path.pop(); - // Fix for https://github.com/lbl-srg/ctrl-flow-dev/issues/422 // Currently the UI does not support nested system types. // Therefore, we only add the Modelica class name of the containing package. - const type = parser.findElement(path.join(".")); + const type = parser.findElement(enclosingName(element.modelicaPath)); if (type && type.entryPoint) { if (!type.description) { console.error( diff --git a/server/tests/integration/parser/quoted-identifiers.test.ts b/server/tests/integration/parser/quoted-identifiers.test.ts new file mode 100644 index 00000000..ffc16481 --- /dev/null +++ b/server/tests/integration/parser/quoted-identifiers.test.ts @@ -0,0 +1,106 @@ +/** + * Quoted identifiers (MLS 3.7 §2.3.1, docs/selection-keys.md) + */ +import * as fs from "fs"; +import * as path from "path"; +import { createModelicaJson } from "../../../scripts/generate-modelica-json"; +import { prependToModelicaJsonPath } from "../../../src/parser/loader"; +import { loadPackage, getOptions, getTemplates } from "../../../src/parser"; +import { Option } from "../../../src/parser/template"; +import { fullTempDirPath } from "./utils"; + +const tempDirPath = "/tmp/test-linkage-widget/"; +const T = "QuotedPackage.Template.QuotedTemplate"; +const C = "QuotedPackage.Component"; +const VALVE = "QuotedPackage.Types.Valve"; + +let options: { [key: string]: Option }; + +describe("Quoted identifiers", () => { + beforeAll(() => { + createModelicaJson("tests/static-data/QuotedPackage", tempDirPath); + createModelicaJson("tests/static-data/QuotedClassPackage", tempDirPath); + prependToModelicaJsonPath([fullTempDirPath]); + loadPackage("QuotedPackage"); + options = Object.fromEntries( + getOptions().options.map((o) => [o.modelicaPath, o]), + ); + }); + + it("keeps quoted identifiers whole in option paths", () => { + expect(options[T].options).toEqual([ + `${T}.'rep.1'`, + `${T}.'c-d'`, + `${T}.'with-dash'`, + `${T}.'why\\?'`, + `${T}.unquoted`, + `${T}.valve`, + ]); + expect(options[`${T}.'rep.1'`].options).toEqual([ + `${C}.First`, + `${C}.Second`, + ]); + }); + + it("builds tree lists without splitting quoted identifiers", () => { + expect(options[T].treeList).toEqual([T]); + expect(options[`${C}.First`].treeList).toEqual([ + `${C}.First`, + `${C}.Partial`, + ]); + expect(options[VALVE].treeList).toEqual([VALVE]); + }); + + it("supports quoted enumeration literals", () => { + const literals = [ + `${VALVE}.'two-way'`, + `${VALVE}.'three.way'`, + `${VALVE}.'13\\'H'`, // escaped quote kept as written + `${VALVE}.plain`, + ]; + expect(options[VALVE].options).toEqual(literals); + expect(options[`${T}.valve`].value).toEqual(`${VALVE}.'13\\'H'`); + expect(options[`${C}.Partial.'val-1'`].value).toEqual(`${VALVE}.'two-way'`); + }); + + it("keeps redundant escapes as written", () => { + expect(options[`${T}.'why\\?'`]).toBeDefined(); + expect(options[`${T}.'why?'`]).toBeUndefined(); + expect(options[`${T}.unquoted`].value).toEqual("'why\\?'"); + expect(options[`${T}.unquoted`].enable).toEqual("'why\\?'"); + expect(options[`${C}.Partial.'k\\?'`]).toBeDefined(); + }); + + it("parses modifiers and expressions with quoted names", () => { + expect(options[`${T}.'c-d'`].modifiers).toEqual({ + [`${T}.'c-d'.'a.b'`]: { expression: false, final: false }, + [`${T}.'c-d'.'val-1'`]: { + expression: `${VALVE}.'three.way'`, + final: false, + }, + }); + expect(options[`${T}.'why\\?'`].value).toEqual("'c-d'.'a.b'"); + expect(options[`${T}.'why\\?'`].enable).toEqual("'with-dash'"); + expect(options[`${C}.Partial.'k\\?'`].enable).toEqual("'a.b'"); + }); + + it("rejects quoted class identifiers", () => { + expect(() => loadPackage("QuotedClassPackage")).toThrow( + /Quoted class identifiers are not supported: 'Q' in .*QuotedClassPackage/, + ); + }); + + afterAll(() => { + // Generate options-QuotedTemplate.json for client tests + const template = getTemplates().find((t) => t.modelicaPath === T); + const outputPath = path.resolve( + __dirname, + "../../../../client/tests/data/options-QuotedTemplate.json", + ); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync( + outputPath, + JSON.stringify({ template, options }, null, 2) + "\n", + ); + }); +}); diff --git a/server/tests/static-data/QuotedClassPackage/package.mo b/server/tests/static-data/QuotedClassPackage/package.mo new file mode 100644 index 00000000..428d65c7 --- /dev/null +++ b/server/tests/static-data/QuotedClassPackage/package.mo @@ -0,0 +1,17 @@ +within ; +package QuotedClassPackage "Test package with a quoted class identifier, not supported" + extends Modelica.Icons.Package; + + model 'Q' "Class with a quoted identifier" + parameter Boolean b=true + "Parameter"; + end 'Q'; + + model Template "Template instantiating a class with a quoted identifier" + QuotedClassPackage.'Q' q + "Component of a class with a quoted identifier"; + annotation(__ctrlFlow(routing="template")); + end Template; + + annotation(__ctrlFlow(routing="root")); +end QuotedClassPackage; diff --git a/server/tests/static-data/QuotedPackage/Component/First.mo b/server/tests/static-data/QuotedPackage/Component/First.mo new file mode 100644 index 00000000..fa7eb722 --- /dev/null +++ b/server/tests/static-data/QuotedPackage/Component/First.mo @@ -0,0 +1,4 @@ +within QuotedPackage.Component; +model First "First component" + extends QuotedPackage.Component.Partial; +end First; diff --git a/server/tests/static-data/QuotedPackage/Component/Partial.mo b/server/tests/static-data/QuotedPackage/Component/Partial.mo new file mode 100644 index 00000000..1c19f0c6 --- /dev/null +++ b/server/tests/static-data/QuotedPackage/Component/Partial.mo @@ -0,0 +1,12 @@ +within QuotedPackage.Component; +partial model Partial "Interface with quoted parameters" + parameter Boolean 'a.b'=true + "Parameter with a dot" + annotation (Evaluate=true, Dialog(group="Quoted")); + parameter QuotedPackage.Types.Valve 'val-1'=QuotedPackage.Types.Valve.'two-way' + "Enumeration parameter with a dash" + annotation (Evaluate=true, Dialog(group="Quoted")); + parameter Real 'k\?'=1 + "Parameter with a redundant escape, enabled by a quoted parameter" + annotation (Dialog(group="Quoted", enable='a.b')); +end Partial; diff --git a/server/tests/static-data/QuotedPackage/Component/Second.mo b/server/tests/static-data/QuotedPackage/Component/Second.mo new file mode 100644 index 00000000..f03c2269 --- /dev/null +++ b/server/tests/static-data/QuotedPackage/Component/Second.mo @@ -0,0 +1,7 @@ +within QuotedPackage.Component; +model Second "Second component" + extends QuotedPackage.Component.Partial('val-1'=QuotedPackage.Types.Valve.'13\'H'); + parameter Boolean 'only-second'=false + "Parameter of the second component only" + annotation (Evaluate=true, Dialog(group="Quoted")); +end Second; diff --git a/server/tests/static-data/QuotedPackage/Component/package.mo b/server/tests/static-data/QuotedPackage/Component/package.mo new file mode 100644 index 00000000..b12463ed --- /dev/null +++ b/server/tests/static-data/QuotedPackage/Component/package.mo @@ -0,0 +1,4 @@ +within QuotedPackage; +package Component "Components with quoted identifiers" + extends Modelica.Icons.Package; +end Component; diff --git a/server/tests/static-data/QuotedPackage/Component/package.order b/server/tests/static-data/QuotedPackage/Component/package.order new file mode 100644 index 00000000..2e4e4544 --- /dev/null +++ b/server/tests/static-data/QuotedPackage/Component/package.order @@ -0,0 +1,3 @@ +Partial +First +Second diff --git a/server/tests/static-data/QuotedPackage/Template/QuotedTemplate.mo b/server/tests/static-data/QuotedPackage/Template/QuotedTemplate.mo new file mode 100644 index 00000000..9db48e6b --- /dev/null +++ b/server/tests/static-data/QuotedPackage/Template/QuotedTemplate.mo @@ -0,0 +1,51 @@ +within QuotedPackage.Template; +model QuotedTemplate "Template with quoted identifiers" + /* + Replaceable component with a quoted name containing a dot + */ + replaceable QuotedPackage.Component.First 'rep.1' + constrainedby QuotedPackage.Component.Partial + "Replaceable component with a quoted name" + annotation ( + choices( + choice( + redeclare replaceable QuotedPackage.Component.First 'rep.1' + "First component"), + choice( + redeclare replaceable QuotedPackage.Component.Second 'rep.1' + "Second component")), + Dialog(group="Configuration")); + + /* + Component with a quoted name containing a dash, modified through + quoted names + */ + QuotedPackage.Component.First 'c-d'( + 'a.b'=false, + 'val-1'=QuotedPackage.Types.Valve.'three.way') + "Component with a quoted name" + annotation (Dialog(enable=true)); + + parameter Boolean 'with-dash'=true + "Parameter with a dash" + annotation (Evaluate=true, Dialog(group="Configuration")); + + /* + Redundant escape, kept as written. MLS 2.3.1 makes 'why\?' and 'why?' + the same identifier, but Dymola and OCT reject a reference 'why?' to + this declaration: the references use the same spelling. + */ + parameter Boolean 'why\?'='c-d'.'a.b' + "Parameter with a redundant escape, bound to a quoted path" + annotation (Evaluate=true, Dialog(group="Configuration", enable='with-dash')); + + parameter Boolean unquoted='why\?' + "Unquoted parameter bound to a quoted parameter" + annotation (Evaluate=true, Dialog(group="Configuration", enable='why\?')); + + parameter QuotedPackage.Types.Valve valve=QuotedPackage.Types.Valve.'13\'H' + "Enumeration parameter with a quoted literal" + annotation (Evaluate=true, Dialog(group="Configuration")); + + annotation(__ctrlFlow(routing="template")); +end QuotedTemplate; diff --git a/server/tests/static-data/QuotedPackage/Template/package.mo b/server/tests/static-data/QuotedPackage/Template/package.mo new file mode 100644 index 00000000..b45c75d0 --- /dev/null +++ b/server/tests/static-data/QuotedPackage/Template/package.mo @@ -0,0 +1,4 @@ +within QuotedPackage; +package Template "Templates with quoted identifiers" + extends Modelica.Icons.Package; +end Template; diff --git a/server/tests/static-data/QuotedPackage/Template/package.order b/server/tests/static-data/QuotedPackage/Template/package.order new file mode 100644 index 00000000..943e8fe4 --- /dev/null +++ b/server/tests/static-data/QuotedPackage/Template/package.order @@ -0,0 +1 @@ +QuotedTemplate diff --git a/server/tests/static-data/QuotedPackage/Types.mo b/server/tests/static-data/QuotedPackage/Types.mo new file mode 100644 index 00000000..23edbf5a --- /dev/null +++ b/server/tests/static-data/QuotedPackage/Types.mo @@ -0,0 +1,14 @@ +within QuotedPackage; +package Types "Package with type definitions" + extends Modelica.Icons.TypesPackage; + type Valve = enumeration( + 'two-way' + "Literal with a dash", + 'three.way' + "Literal with a dot", + '13\'H' + "Literal with an escaped quote", + plain + "Unquoted literal") + "Enumeration with quoted literals"; +end Types; diff --git a/server/tests/static-data/QuotedPackage/package.mo b/server/tests/static-data/QuotedPackage/package.mo new file mode 100644 index 00000000..7d8d97d3 --- /dev/null +++ b/server/tests/static-data/QuotedPackage/package.mo @@ -0,0 +1,5 @@ +within ; +package QuotedPackage "Test package with quoted identifiers" + extends Modelica.Icons.Package; + annotation(__ctrlFlow(routing="root")); +end QuotedPackage; diff --git a/server/tests/static-data/QuotedPackage/package.order b/server/tests/static-data/QuotedPackage/package.order new file mode 100644 index 00000000..eee32975 --- /dev/null +++ b/server/tests/static-data/QuotedPackage/package.order @@ -0,0 +1,3 @@ +Component +Template +Types