diff --git a/.github/actions/build/action.yml b/.github/actions/build/action.yml index 0f6dd5f4..47ee24f3 100644 --- a/.github/actions/build/action.yml +++ b/.github/actions/build/action.yml @@ -32,24 +32,27 @@ runs: # windows-2019 # is needed because the Setup Python task does not work wit - name: Set up yarn to use version 1.22.19 uses: Amadevus/pwsh-script@v2.0.3 with: - # PowerShell script to execute in Actions-hydrated context script: | yarn policies set-version 1.22.19 - name: Yarn version uses: Amadevus/pwsh-script@v2.0.3 with: - # PowerShell script to execute in Actions-hydrated context script: | yarn --version | Write-Host - name: Yarn build uses: Amadevus/pwsh-script@v2.0.3 with: - # PowerShell script to execute in Actions-hydrated context script: | yarn + - name: Yarn test + uses: Amadevus/pwsh-script@v2.0.3 + with: + script: | + yarn test + #- name: Yarn start browser # run: | # yarn start:browser diff --git a/.github/actions/test/action.yml b/.github/actions/test/action.yml new file mode 100644 index 00000000..9809a87f --- /dev/null +++ b/.github/actions/test/action.yml @@ -0,0 +1,11 @@ +name: test +description: 'Test CrossModel' + +runs: # windows-2019 # is needed because the Setup Python task does not work with ubuntu + using: 'composite' + steps: + - name: Yarn test + uses: Amadevus/pwsh-script@v2.0.3 + with: + script: | + yarn test diff --git a/.github/workflows/feature.yml b/.github/workflows/feature.yml index efca7754..d91fd193 100644 --- a/.github/workflows/feature.yml +++ b/.github/workflows/feature.yml @@ -1,16 +1,18 @@ name: feature on: - push: - branches: - - feature/* + push: + branches: + - feature/* jobs: build: runs-on: windows-2019 # is needed because the Setup Python task does not work with ubuntu steps: - # Checkout the code. - - name: check out # is needed to read the files on the root folder of the repo - uses: actions/checkout@v3 - # Use the build-addin action. - - uses: ./.github/actions/build + # Checkout the code. + - name: check out # is needed to read the files on the root folder of the repo + uses: actions/checkout@v3 + # Use the build action. + - uses: ./.github/actions/build + # Use the test action. + # - uses: ./.github/actions/test diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 00000000..069b9077 --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,17 @@ +# This configuration file was automatically generated by Gitpod. +# Please adjust to your needs (see https://www.gitpod.io/docs/introduction/learn-gitpod/gitpod-yaml) +# and commit this file to your remote git repository to share the goodness with others. + +# Learn more from ready-to-use templates: https://www.gitpod.io/docs/introduction/getting-started/quickstart + +# To have the dependencies for Theia installed execute the following commands in the GitPod workspace once: +# sudo apt-get install libsecret-1-dev libxkbfile-dev +# nvm install 16.20.0 +# pyenv install -s 3.11.4 +# pyenv global 3.11.4 + +tasks: + - init: yarn install && yarn run build + command: yarn run watch + + diff --git a/.vscode/launch.json b/.vscode/launch.json index f0ec70c0..4b660e1f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -22,7 +22,7 @@ "--loglevel=debug", "--hostname=localhost", "--no-cluster", - "--root-dir=${workspaceRoot}/examples/workspace", + "--root-dir=${workspaceRoot}/examples/yaml-example", "--app-project-path=${workspaceFolder}/applications/electron-app", "--remote-debugging-port=9222", "--no-app-auto-install", @@ -114,6 +114,20 @@ "${workspaceFolder}/node_modules/vscode-languageclient/**/*.js", "${workspaceFolder}/node_modules/vscode-jsonrpc/**/*.js" ] + }, + { + "name": "Debug Jest Tests", + "type": "node", + "request": "launch", + "runtimeArgs": [ + "--inspect-brk", + "${workspaceRoot}/node_modules/jest/bin/jest.js", + "--runInBand", + "--config=extensions/crossmodel-lang/jest.config.js", + "--testPathPattern=extensions/crossmodel-lang/test/language-server/util/name-util.test.ts" + ], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen" } ] } diff --git a/README.md b/README.md index afd34909..6f898139 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,8 @@ The repository itself contains the following components structured as follows: ## Used libraries and license -- mui-x : MUI X is open core—base components are MIT-licensed, while more advanced features require a Pro or Premium commercial license. We are - currently only using core-base +- mui-x : MUI X is open core—base components are MIT-licensed, while more advanced features require a Pro or Premium commercial license. We are currently only using core-base - react-tabs: MIT +- chevotain: This library is a dependency of langium. To get the Yaml language working in crossmodel an example implementation of the python language of chevrotrain has been used. This example has been modified to make it work for the yaml language. + - https://github.com/Chevrotain/chevrotain + - example that has been used: https://github.com/Chevrotain/chevrotain/tree/master/examples/lexer/python_indentation diff --git a/configs/jest.config.js b/configs/jest.config.js new file mode 100644 index 00000000..257ddde2 --- /dev/null +++ b/configs/jest.config.js @@ -0,0 +1,5 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node' +}; diff --git a/examples/cross-model-project-example/.theia/launch.json b/examples/cross-model-project-example/.theia/launch.json new file mode 100644 index 00000000..7e4253b3 --- /dev/null +++ b/examples/cross-model-project-example/.theia/launch.json @@ -0,0 +1,8 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + "version": "0.2.0", + "configurations": [ + + ] +} diff --git a/examples/cross-model-project-example/systemA/entities/Aantal.cm b/examples/cross-model-project-example/systemA/entities/Aantal.cm new file mode 100644 index 00000000..1561bfaf --- /dev/null +++ b/examples/cross-model-project-example/systemA/entities/Aantal.cm @@ -0,0 +1,9 @@ +entity Aantal { + description := "Test description"; + attributes { + nope := 'Float'; + test123456 := 'Bool'; + test3 := 'Float'; + test4 := 'Char'; + } +} \ No newline at end of file diff --git a/examples/cross-model-project-example/systemA/entities/Klant.cm b/examples/cross-model-project-example/systemA/entities/Klant.cm new file mode 100644 index 00000000..d14bfb1e --- /dev/null +++ b/examples/cross-model-project-example/systemA/entities/Klant.cm @@ -0,0 +1,10 @@ +entity Klant { + description := "test123"; + attributes { + werk := 'Float'; + test49999912somsdit := 'Char'; + wat111234 := 'Varchar'; + hall1123 := 'Integer'; + ditwerktdusnietaltijd := 'test1234'; + } +} \ No newline at end of file diff --git a/examples/cross-model-project-example/systemA/entities/Order.cm b/examples/cross-model-project-example/systemA/entities/Order.cm new file mode 100644 index 00000000..7f07dce4 --- /dev/null +++ b/examples/cross-model-project-example/systemA/entities/Order.cm @@ -0,0 +1,17 @@ +entity Order { + description := "Orders geplaatst door de klant"; + attributes { + test49999912somsdit := 'Varchar'; + werk := 'Integer'; + wat111234 := 'Varchar'; + hall1123 := 'Integer'; + ditwerktdusnietaltijd := 'test1234'; + empty_attribute5 := 'Float'; + empty_attribute6 := 'Float'; + empty_attribute7 := 'Float'; + empty_attribute8 := 'Float'; + empty_attribute9 := 'Float'; + empty_attribute10 := 'Float'; + empty_attribute11 := 'Float'; + } +} \ No newline at end of file diff --git a/examples/cross-model-project-example/systemA/entities/Product.cm b/examples/cross-model-project-example/systemA/entities/Product.cm new file mode 100644 index 00000000..a402eb76 --- /dev/null +++ b/examples/cross-model-project-example/systemA/entities/Product.cm @@ -0,0 +1,10 @@ +entity Product { + description := "Producten die verkocht worden"; + attributes { + test1 := 'test2'; + test2 := 'test3'; + test3 := 'test'; + test4 := 'test'; + test5 := 'test'; + } +} \ No newline at end of file diff --git a/examples/cross-model-project-example/systemA/relationships/AantalToProduct.relationship.cm b/examples/cross-model-project-example/systemA/relationships/AantalToProduct.relationship.cm new file mode 100644 index 00000000..eaa57c1d --- /dev/null +++ b/examples/cross-model-project-example/systemA/relationships/AantalToProduct.relationship.cm @@ -0,0 +1,8 @@ +relationship AantalToProduct { + source := Aantal; + target := Product ; + type := 1:1; + properties { + + } + } \ No newline at end of file diff --git a/examples/cross-model-project-example/systemA/relationships/AantalToProduct0.relationship.cm b/examples/cross-model-project-example/systemA/relationships/AantalToProduct0.relationship.cm new file mode 100644 index 00000000..97b6d9fc --- /dev/null +++ b/examples/cross-model-project-example/systemA/relationships/AantalToProduct0.relationship.cm @@ -0,0 +1,8 @@ +relationship AantalToProduct0 { + source := Aantal; + target := Product; + type := 1:1; + properties { + + } + } \ No newline at end of file diff --git a/examples/cross-model-project-example/systemA/relationships/KlantToOrder.relationship.cm b/examples/cross-model-project-example/systemA/relationships/KlantToOrder.relationship.cm new file mode 100644 index 00000000..307bc6af --- /dev/null +++ b/examples/cross-model-project-example/systemA/relationships/KlantToOrder.relationship.cm @@ -0,0 +1,8 @@ +relationship KlantToOrder { + source := Klant; + target := Order; + type := 1:1; + properties { + + } + } \ No newline at end of file diff --git a/examples/cross-model-project-example/systemA/relationships/KlantToOrder0.relationship.cm b/examples/cross-model-project-example/systemA/relationships/KlantToOrder0.relationship.cm new file mode 100644 index 00000000..589c7d2a --- /dev/null +++ b/examples/cross-model-project-example/systemA/relationships/KlantToOrder0.relationship.cm @@ -0,0 +1,8 @@ +relationship KlantToOrder0 { + source := Klant; + target := Order; + type := 1:1; + properties { + + } + } \ No newline at end of file diff --git a/examples/cross-model-project-example/systemA/relationships/OrderToAantal.relationship.cm b/examples/cross-model-project-example/systemA/relationships/OrderToAantal.relationship.cm new file mode 100644 index 00000000..2ef7e052 --- /dev/null +++ b/examples/cross-model-project-example/systemA/relationships/OrderToAantal.relationship.cm @@ -0,0 +1,8 @@ +relationship OrderToAantal { + source := Order; + target := Aantal; + type := 1:1; + properties { + + } + } \ No newline at end of file diff --git a/examples/cross-model-project-example/systemA/relationships/OrderToProduct.relationship.cm b/examples/cross-model-project-example/systemA/relationships/OrderToProduct.relationship.cm new file mode 100644 index 00000000..fe7145a8 --- /dev/null +++ b/examples/cross-model-project-example/systemA/relationships/OrderToProduct.relationship.cm @@ -0,0 +1,8 @@ +relationship OrderToProduct { + source := Order; + target := Product; + type := 1:1; + properties { + + } + } \ No newline at end of file diff --git a/examples/cross-model-project-example/systemA/source/system.diagram.cm b/examples/cross-model-project-example/systemA/source/system.diagram.cm new file mode 100644 index 00000000..cab742d8 --- /dev/null +++ b/examples/cross-model-project-example/systemA/source/system.diagram.cm @@ -0,0 +1,272 @@ +diagram { + node KlantN for Klant { + x := 227.55764999671163; + y := 23.42315729156587; + width := 171.0640411376953; + height := 132; + }; +node ProductN for Product { + x := 751.1084098192446; + y := 400.43789846410783; + width := 113.9998640556468; + height := 141.06518762453462; + }; +node AantalN for Aantal { + x := 720.0619968875156; + y := -38.00518218292889; + width := 133.3416748046875; + height := 170; + }; +node OrderN for Order { + x := 461.019229870532; + y := 22.455090022486672; + width := 171.0640411376953; + height := 132; + }; +node AantalNode for unknown/Aantal { + x := 7.17229932708031; + y := 159.86311597766826; + width := 132.084401845932; + height := 170; + }; +node KlantNode for unknown/Klant { + x := -88.19829508665896; + y := -75.9252393169105; + width := 170.21827483177185; + height := 132; + }; +node CNode for C { + x := -303.54256511586857; + y := 83.24109552114436; + width := 178.2309737524657; + height := 97.48529625107477; + }; +node ProductNode for Product { + x := 4.6235348891893295; + y := 473.5848221942176; + width := 10; + height := 10; + }; +node ProductNode1 for Product { + x := 298.6653553106821; + y := 549.3423217787945; + width := 83.36386108398438; + height := 132; + }; +node ProductNode12 for Product { + x := 175.3989153086589; + y := 401.6793988597042; + width := 10; + height := 10; + }; +node ProductNode123 for Product { + x := 84.23311072382927; + y := 664.9046092806911; + width := 10; + height := 10; + }; +node ProductNode1234 for Product { + x := 285.8251011438048; + y := 772.7627442824614; + width := 83.36386108398438; + height := 132; + }; +node ProductNode12345 for Product { + x := 533.6420065645389; + y := 777.8988459492124; + width := 83.36386108398438; + height := 132; + }; +node AantalNode1 for unknown/Aantal { + x := 1330.749586994124; + y := 559.3423217787945; + width := 10; + height := 10; + }; +node AantalNode12 for unknown/Aantal { + x := 1715.9572120004464; + y := 633.8157959466835; + width := 10; + height := 10; + }; +node AantalNode123 for unknown/Aantal { + x := 1332.0336124108117; + y := 222.9276626066063; + width := 133.3416748046875; + height := 170; + }; +node AantalNode1234 for unknown/Aantal { + x := -305.09879386605843; + y := 247.32414552367325; + width := 133.3416748046875; + height := 170; + }; +node AantalNode12345 for unknown/Aantal { + x := -317.93904803293583; + y := 491.28897469434423; + width := 133.3416748046875; + height := 170; + }; +node ENode for unknown/E { + x := -427.9793782188551; + y := 31.841103463085908; + width := 10; + height := 10; + }; +node ENode1 for unknown/E { + x := -440.8196323857325; + y := 528.7589397212417; + width := 10; + height := 10; + }; +node ENode2 for unknown/E { + x := -499.8848015533686; + y := 550.5873718049334; + width := 10; + height := 10; + }; +node DNode for unknown/D { + x := -524.2812844704357; + y := 503.0784313874869; + width := 10; + height := 10; + }; +node ENode3 for unknown/E { + x := -452.3758611359222; + y := 610.9365663892571; + width := 10; + height := 10; + }; +node ENode4 for unknown/E { + x := -430.5474290522306; + y := 691.8301676405848; + width := 10; + height := 10; + }; +node ENode5 for unknown/E { + x := -352.22187863427837; + y := 702.1023709740867; + width := 10; + height := 10; + }; +node ENode6 for unknown/E { + x := -393.3106919682861; + y := 802.2563534757305; + width := 10; + height := 10; + }; +node ENode7 for unknown/E { + x := -288.0206077998913; + y := 804.8244043091061; + width := 10; + height := 10; + }; +node KlantNode1 for unknown/Klant { + x := 407.3538168651514; + y := 1188.0036571285125; + width := 10; + height := 10; + }; +node KlantNode2 for unknown/Klant { + x := 785.6515846658832; + y := 1170.5437601530941; + width := 10; + height := 10; + }; +node AantalNode2 for Aantal { + x := 573.4077780339535; + y := 1006.314670203565; + width := 10; + height := 10; + }; +node ANode for A { + x := 273.67954662260445; + y := 1058.6943611298202; + width := 10; + height := 10; + }; +node CNode1 for C { + x := -91.52329844656362; + y := 1112.5290434706935; + width := 10; + height := 10; + }; +node ANode1 for unknown/A { + x := 127.9954652584571; + y := 1398.9774122481513; + width := 29; + height := 32; + }; +node ENode8 for unknown/E { + x := 130.7205081853661; + y := 1273.6632106886589; + width := 28; + height := 32; + }; +node DNode1 for unknown/D { + x := 191.6452076970031; + y := 1305.4880819079317; + width := 28; + height := 32; + }; +node CNode2 for unknown/C { + x := 307.8595809641316; + y := 1350.4078758587686; + width := 28; + height := 32; + }; +node AantalNode3 for unknown/Aantal { + x := 12.829215051195275; + y := -319.56846840292644; + width := 10; + height := 10; + }; +node KlantNode3 for unknown/Klant { + x := 708.9582229066704; + y := -313.40803470509036; + width := 10; + height := 10; + }; +node AantalNode4 for unknown/Aantal { + x := -193.54531382631282; + y := -190.19936074836912; + width := 10; + height := 10; + }; +node AantalNode5 for unknown/Aantal { + x := -212.026614919821; + y := -224.08174608646746; + width := 10; + height := 10; + }; +node KlantNode4 for unknown/Klant { + x := -292.1122529916898; + y := -177.878493352697; + width := 10; + height := 10; + }; +node AantalNode6 for unknown/Aantal { + x := 2501.923662167497; + y := -252.2931382342814; + width := 133.3416748046875; + height := 170; + }; +node KlantNode5 for unknown/Klant { + x := 2864.363377909348; + y := -226.13769483023026; + width := 10; + height := 10; + }; + edge KlantToOrder for KlantToOrder { + source := KlantN; + target := OrderN; + }; +edge OrderToAantal for OrderToAantal { + source := OrderN; + target := AantalN; + }; +edge AantalToProduct0 for unknown/AantalToProduct0 { + source := AantalN; + target := ProductN; + }; + } \ No newline at end of file diff --git a/examples/libraries/entities/entities/entityA.cm b/examples/cross-model-project-example/systemB/entities/entityA.cm similarity index 100% rename from examples/libraries/entities/entities/entityA.cm rename to examples/cross-model-project-example/systemB/entities/entityA.cm diff --git a/examples/cross-model-project-example/systemB/entities/entityC.cm b/examples/cross-model-project-example/systemB/entities/entityC.cm new file mode 100644 index 00000000..a6c63580 --- /dev/null +++ b/examples/cross-model-project-example/systemB/entities/entityC.cm @@ -0,0 +1,3 @@ +entity C { + description := "hello"; +} diff --git a/examples/libraries/entities/entities/entityD.cm b/examples/cross-model-project-example/systemB/entities/entityD.cm similarity index 100% rename from examples/libraries/entities/entities/entityD.cm rename to examples/cross-model-project-example/systemB/entities/entityD.cm diff --git a/examples/libraries/system/entities/entityE.cm b/examples/cross-model-project-example/systemB/entities/entityE.cm similarity index 100% rename from examples/libraries/system/entities/entityE.cm rename to examples/cross-model-project-example/systemB/entities/entityE.cm diff --git a/examples/cross-model-project-example/systemB/relationships/relationship.cm b/examples/cross-model-project-example/systemB/relationships/relationship.cm new file mode 100644 index 00000000..1a82d0fa --- /dev/null +++ b/examples/cross-model-project-example/systemB/relationships/relationship.cm @@ -0,0 +1,5 @@ +relationship aToB { + source := A; + target := C; + type := n:m; +} \ No newline at end of file diff --git a/examples/cross-model-project-example/systemB/views/entity.diagram.cm b/examples/cross-model-project-example/systemB/views/entity.diagram.cm new file mode 100644 index 00000000..cbf585d2 --- /dev/null +++ b/examples/cross-model-project-example/systemB/views/entity.diagram.cm @@ -0,0 +1,8 @@ +diagram { + node mynode for A { + x := 0; + y := 0; + width := 100; + height := 100; + }; +} \ No newline at end of file diff --git a/examples/libraries/entities/.npmrc b/examples/verdaccio-example/libraries/entities/.npmrc similarity index 100% rename from examples/libraries/entities/.npmrc rename to examples/verdaccio-example/libraries/entities/.npmrc diff --git a/examples/libraries/example-library/entities/entityA.cm b/examples/verdaccio-example/libraries/entities/entities/entityA.cm similarity index 100% rename from examples/libraries/example-library/entities/entityA.cm rename to examples/verdaccio-example/libraries/entities/entities/entityA.cm diff --git a/examples/libraries/entities/entities/entityB.cm b/examples/verdaccio-example/libraries/entities/entities/entityB.cm similarity index 100% rename from examples/libraries/entities/entities/entityB.cm rename to examples/verdaccio-example/libraries/entities/entities/entityB.cm diff --git a/examples/libraries/entities/entities/entityC.cm b/examples/verdaccio-example/libraries/entities/entities/entityC.cm similarity index 100% rename from examples/libraries/entities/entities/entityC.cm rename to examples/verdaccio-example/libraries/entities/entities/entityC.cm diff --git a/examples/libraries/example-library/entities/entityD.cm b/examples/verdaccio-example/libraries/entities/entities/entityD.cm similarity index 100% rename from examples/libraries/example-library/entities/entityD.cm rename to examples/verdaccio-example/libraries/entities/entities/entityD.cm diff --git a/examples/libraries/entities/package.json b/examples/verdaccio-example/libraries/entities/package.json similarity index 100% rename from examples/libraries/entities/package.json rename to examples/verdaccio-example/libraries/entities/package.json diff --git a/examples/libraries/example-library/.npmrc b/examples/verdaccio-example/libraries/example-library/.npmrc similarity index 100% rename from examples/libraries/example-library/.npmrc rename to examples/verdaccio-example/libraries/example-library/.npmrc diff --git a/examples/verdaccio-example/libraries/example-library/entities/entityA.cm b/examples/verdaccio-example/libraries/example-library/entities/entityA.cm new file mode 100644 index 00000000..03b895bd --- /dev/null +++ b/examples/verdaccio-example/libraries/example-library/entities/entityA.cm @@ -0,0 +1,3 @@ +entity A { + description := "hello"; +} diff --git a/examples/libraries/example-library/entities/entityB.cm b/examples/verdaccio-example/libraries/example-library/entities/entityB.cm similarity index 100% rename from examples/libraries/example-library/entities/entityB.cm rename to examples/verdaccio-example/libraries/example-library/entities/entityB.cm diff --git a/examples/libraries/example-library/entities/entityC.cm b/examples/verdaccio-example/libraries/example-library/entities/entityC.cm similarity index 100% rename from examples/libraries/example-library/entities/entityC.cm rename to examples/verdaccio-example/libraries/example-library/entities/entityC.cm diff --git a/examples/verdaccio-example/libraries/example-library/entities/entityD.cm b/examples/verdaccio-example/libraries/example-library/entities/entityD.cm new file mode 100644 index 00000000..4604c447 --- /dev/null +++ b/examples/verdaccio-example/libraries/example-library/entities/entityD.cm @@ -0,0 +1,3 @@ +entity D { + description := "hello"; +} diff --git a/examples/libraries/example-library/package.json b/examples/verdaccio-example/libraries/example-library/package.json similarity index 100% rename from examples/libraries/example-library/package.json rename to examples/verdaccio-example/libraries/example-library/package.json diff --git a/examples/libraries/example-library/relationships/aToB.relationship.cm b/examples/verdaccio-example/libraries/example-library/relationships/aToB.relationship.cm similarity index 100% rename from examples/libraries/example-library/relationships/aToB.relationship.cm rename to examples/verdaccio-example/libraries/example-library/relationships/aToB.relationship.cm diff --git a/examples/libraries/example-library/relationships/cToD.relationship.cm b/examples/verdaccio-example/libraries/example-library/relationships/cToD.relationship.cm similarity index 100% rename from examples/libraries/example-library/relationships/cToD.relationship.cm rename to examples/verdaccio-example/libraries/example-library/relationships/cToD.relationship.cm diff --git a/examples/libraries/example-library/views/system.diagram.cm b/examples/verdaccio-example/libraries/example-library/views/system.diagram.cm similarity index 100% rename from examples/libraries/example-library/views/system.diagram.cm rename to examples/verdaccio-example/libraries/example-library/views/system.diagram.cm diff --git a/examples/libraries/relationships/.npmrc b/examples/verdaccio-example/libraries/relationships/.npmrc similarity index 100% rename from examples/libraries/relationships/.npmrc rename to examples/verdaccio-example/libraries/relationships/.npmrc diff --git a/examples/libraries/relationships/package.json b/examples/verdaccio-example/libraries/relationships/package.json similarity index 100% rename from examples/libraries/relationships/package.json rename to examples/verdaccio-example/libraries/relationships/package.json diff --git a/examples/libraries/relationships/relationships/aToB.relationship.cm b/examples/verdaccio-example/libraries/relationships/relationships/aToB.relationship.cm similarity index 100% rename from examples/libraries/relationships/relationships/aToB.relationship.cm rename to examples/verdaccio-example/libraries/relationships/relationships/aToB.relationship.cm diff --git a/examples/libraries/relationships/relationships/cToD.relationship.cm b/examples/verdaccio-example/libraries/relationships/relationships/cToD.relationship.cm similarity index 100% rename from examples/libraries/relationships/relationships/cToD.relationship.cm rename to examples/verdaccio-example/libraries/relationships/relationships/cToD.relationship.cm diff --git a/examples/libraries/system/.npmrc b/examples/verdaccio-example/libraries/system/.npmrc similarity index 100% rename from examples/libraries/system/.npmrc rename to examples/verdaccio-example/libraries/system/.npmrc diff --git a/examples/verdaccio-example/libraries/system/entities/entityE.cm b/examples/verdaccio-example/libraries/system/entities/entityE.cm new file mode 100644 index 00000000..06f2bfa6 --- /dev/null +++ b/examples/verdaccio-example/libraries/system/entities/entityE.cm @@ -0,0 +1,3 @@ +entity E { + description := "hello"; +} diff --git a/examples/libraries/system/package.json b/examples/verdaccio-example/libraries/system/package.json similarity index 100% rename from examples/libraries/system/package.json rename to examples/verdaccio-example/libraries/system/package.json diff --git a/examples/libraries/system/views/system.diagram.cm b/examples/verdaccio-example/libraries/system/views/system.diagram.cm similarity index 100% rename from examples/libraries/system/views/system.diagram.cm rename to examples/verdaccio-example/libraries/system/views/system.diagram.cm diff --git a/examples/registry/.verdaccio-db.json b/examples/verdaccio-example/registry/.verdaccio-db.json similarity index 100% rename from examples/registry/.verdaccio-db.json rename to examples/verdaccio-example/registry/.verdaccio-db.json diff --git a/examples/registry/@crossbreeze/entities/entities-1.0.0.tgz b/examples/verdaccio-example/registry/@crossbreeze/entities/entities-1.0.0.tgz similarity index 100% rename from examples/registry/@crossbreeze/entities/entities-1.0.0.tgz rename to examples/verdaccio-example/registry/@crossbreeze/entities/entities-1.0.0.tgz diff --git a/examples/registry/@crossbreeze/entities/package.json b/examples/verdaccio-example/registry/@crossbreeze/entities/package.json similarity index 100% rename from examples/registry/@crossbreeze/entities/package.json rename to examples/verdaccio-example/registry/@crossbreeze/entities/package.json diff --git a/examples/registry/@crossbreeze/example-library/example-library-1.0.0.tgz b/examples/verdaccio-example/registry/@crossbreeze/example-library/example-library-1.0.0.tgz similarity index 100% rename from examples/registry/@crossbreeze/example-library/example-library-1.0.0.tgz rename to examples/verdaccio-example/registry/@crossbreeze/example-library/example-library-1.0.0.tgz diff --git a/examples/registry/@crossbreeze/example-library/package.json b/examples/verdaccio-example/registry/@crossbreeze/example-library/package.json similarity index 100% rename from examples/registry/@crossbreeze/example-library/package.json rename to examples/verdaccio-example/registry/@crossbreeze/example-library/package.json diff --git a/examples/registry/@crossbreeze/relationships/package.json b/examples/verdaccio-example/registry/@crossbreeze/relationships/package.json similarity index 100% rename from examples/registry/@crossbreeze/relationships/package.json rename to examples/verdaccio-example/registry/@crossbreeze/relationships/package.json diff --git a/examples/registry/@crossbreeze/relationships/relationships-1.0.0.tgz b/examples/verdaccio-example/registry/@crossbreeze/relationships/relationships-1.0.0.tgz similarity index 100% rename from examples/registry/@crossbreeze/relationships/relationships-1.0.0.tgz rename to examples/verdaccio-example/registry/@crossbreeze/relationships/relationships-1.0.0.tgz diff --git a/examples/registry/@crossbreeze/system/package.json b/examples/verdaccio-example/registry/@crossbreeze/system/package.json similarity index 100% rename from examples/registry/@crossbreeze/system/package.json rename to examples/verdaccio-example/registry/@crossbreeze/system/package.json diff --git a/examples/registry/@crossbreeze/system/system-1.0.0.tgz b/examples/verdaccio-example/registry/@crossbreeze/system/system-1.0.0.tgz similarity index 100% rename from examples/registry/@crossbreeze/system/system-1.0.0.tgz rename to examples/verdaccio-example/registry/@crossbreeze/system/system-1.0.0.tgz diff --git a/examples/workspace/.npmrc b/examples/verdaccio-example/workspace/.npmrc similarity index 100% rename from examples/workspace/.npmrc rename to examples/verdaccio-example/workspace/.npmrc diff --git a/examples/workspace/.theia/settings.json b/examples/verdaccio-example/workspace/.theia/settings.json similarity index 100% rename from examples/workspace/.theia/settings.json rename to examples/verdaccio-example/workspace/.theia/settings.json diff --git a/examples/workspace/package-lock.json b/examples/verdaccio-example/workspace/package-lock.json similarity index 100% rename from examples/workspace/package-lock.json rename to examples/verdaccio-example/workspace/package-lock.json diff --git a/examples/workspace/package.json b/examples/verdaccio-example/workspace/package.json similarity index 100% rename from examples/workspace/package.json rename to examples/verdaccio-example/workspace/package.json diff --git a/examples/workspace/ws-relationships/aToD.relationship.cm b/examples/verdaccio-example/workspace/ws-relationships/aToD.relationship.cm similarity index 100% rename from examples/workspace/ws-relationships/aToD.relationship.cm rename to examples/verdaccio-example/workspace/ws-relationships/aToD.relationship.cm diff --git a/examples/workspace/ws-relationships/entityA.entity.cm b/examples/verdaccio-example/workspace/ws-relationships/entityA.entity.cm similarity index 100% rename from examples/workspace/ws-relationships/entityA.entity.cm rename to examples/verdaccio-example/workspace/ws-relationships/entityA.entity.cm diff --git a/examples/workspace/ws-relationships/package.json b/examples/verdaccio-example/workspace/ws-relationships/package.json similarity index 100% rename from examples/workspace/ws-relationships/package.json rename to examples/verdaccio-example/workspace/ws-relationships/package.json diff --git a/examples/workspace/ws-system/package.json b/examples/verdaccio-example/workspace/ws-system/package.json similarity index 100% rename from examples/workspace/ws-system/package.json rename to examples/verdaccio-example/workspace/ws-system/package.json diff --git a/examples/workspace/ws-system/views/system.diagram.cm b/examples/verdaccio-example/workspace/ws-system/views/system.diagram.cm similarity index 100% rename from examples/workspace/ws-system/views/system.diagram.cm rename to examples/verdaccio-example/workspace/ws-system/views/system.diagram.cm diff --git a/examples/yaml-example/entities/voorbeeld_taal_entity_customer.cm b/examples/yaml-example/entities/voorbeeld_taal_entity_customer.cm new file mode 100644 index 00000000..72772eb6 --- /dev/null +++ b/examples/yaml-example/entities/voorbeeld_taal_entity_customer.cm @@ -0,0 +1,23 @@ +entity: + id: "Customer" + name: "Customer" + description: "A customer with whom a transaction has been made." + attributes: + - id: "Id" + name: "Id" + datatype: "Integer" + - id: "FirstName" + name: "FirstName" + datatype: "Varchar" + - id: "LastName" + name: "LastName" + datatype: "Varchar" + - id: "City" + name: "City" + datatype: "Varchar" + - id: "Country" + name: "Country" + datatype: "Varchar" + - id: "Phone" + name: "Phone" + datatype: "Varchar" \ No newline at end of file diff --git a/examples/yaml-example/entities/voorbeeld_taal_entity_order.cm b/examples/yaml-example/entities/voorbeeld_taal_entity_order.cm new file mode 100644 index 00000000..4791d007 --- /dev/null +++ b/examples/yaml-example/entities/voorbeeld_taal_entity_order.cm @@ -0,0 +1,20 @@ +entity: + id: "Order" + name: "Order" + description: "Order placed by a customer in the Customer table." + attributes: + - id: "Id" + name: "Id" + datatype: "Integer" + - id: "OrderDate" + name: "OrderDate" + datatype: "Integer" + - id: "OrderNumber" + name: "OrderNumber" + datatype: "Varchar" + - id: "CustomerId" + name: "CustomerId" + datatype: "Integer" + - id: "TotalAmount" + name: "TotalAmount" + datatype: "Float" \ No newline at end of file diff --git a/examples/yaml-example/relationships/voorbeeld_taal_relationship.relationship.cm b/examples/yaml-example/relationships/voorbeeld_taal_relationship.relationship.cm new file mode 100644 index 00000000..fdfd4ab1 --- /dev/null +++ b/examples/yaml-example/relationships/voorbeeld_taal_relationship.relationship.cm @@ -0,0 +1,5 @@ +relationship: + id: 'Order_Customer' + parent: 'Customer' + child: 'Order' + type: '1:1' \ No newline at end of file diff --git a/examples/yaml-example/source/voorbeeld_taal_diagram.diagram.cm b/examples/yaml-example/source/voorbeeld_taal_diagram.diagram.cm new file mode 100644 index 00000000..21bcd69c --- /dev/null +++ b/examples/yaml-example/source/voorbeeld_taal_diagram.diagram.cm @@ -0,0 +1,22 @@ +diagram: + id: "Systemdiagram1" + name: "Systemdiagram1" + description: "This should be the description" + nodes: + - id: "CustomerNode" + entity: "Customer" + x: 322.5893891316664 + y: 262.8195919791379 + width: 122.22364807128906 + height: 151 + - id: "OrderNode" + entity: "Order" + x: 618.4416344093675 + y: 274.85224527770106 + width: 139.6079559326172 + height: 132 + edges: + - id: "CustomerToOrder" + relationship: "Order_Customer" + sourceNode: "CustomerNode" + targetNode: "OrderNode" \ No newline at end of file diff --git a/extensions/crossmodel-lang/jest.config.js b/extensions/crossmodel-lang/jest.config.js new file mode 100644 index 00000000..18d7e3c1 --- /dev/null +++ b/extensions/crossmodel-lang/jest.config.js @@ -0,0 +1,5 @@ +const baseConfig = require('../../configs/jest.config'); + +module.exports = { + ...baseConfig +}; diff --git a/extensions/crossmodel-lang/language-configuration.json b/extensions/crossmodel-lang/language-configuration.json index 8f162a0c..e24db2d7 100644 --- a/extensions/crossmodel-lang/language-configuration.json +++ b/extensions/crossmodel-lang/language-configuration.json @@ -1,9 +1,7 @@ { "comments": { // symbol used for single line comment. Remove this entry if your language does not support line comments - "lineComment": "//", - // symbols used for start and end a block comment. Remove this entry if your language does not support block comments - "blockComment": [ "/*", "*/" ] + "lineComment": "#" }, // symbols used as brackets "brackets": [ diff --git a/extensions/crossmodel-lang/package.json b/extensions/crossmodel-lang/package.json index 2b3af0f1..04114a48 100644 --- a/extensions/crossmodel-lang/package.json +++ b/extensions/crossmodel-lang/package.json @@ -41,6 +41,7 @@ "symlink": "yarn symlink:browser && yarn symlink:electron", "symlink:browser": "symlink-dir . ../../applications/browser-app/plugins/crossmodel-lang", "symlink:electron": "symlink-dir . ../../applications/electron-app/plugins/crossmodel-lang", + "test": "jest", "vscode:prepublish": "yarn lint", "watch": "yarn watch:webpack", "watch:tsc": "tsc -b tsconfig.json --watch", @@ -86,16 +87,16 @@ ] }, "activationEvents": [ - "*" + "onStartupFinished" ], "dependencies": { "@crossbreeze/protocol": "0.0.0", "@eclipse-glsp/layout-elk": "1.1.0-RC10", "@eclipse-glsp/server": "1.1.0-RC10", - "chalk": "^4.1.2", - "chevrotain": "^10.4.1", - "commander": "^8.0.0", - "langium": "^1.1.0", + "chalk": "~4.1.2", + "chevrotain": "~10.4.2", + "commander": "~10.0.0", + "langium": "~1.3.0", "type-fest": "^3.6.1", "vscode-languageclient": "8.0.2", "vscode-languageserver": "8.0.2", @@ -110,7 +111,7 @@ "@typescript-eslint/parser": "^5.28.0", "@vscode/vsce": "^2.17.0", "eslint": "^8.17.0", - "langium-cli": "^1.1.0", + "langium-cli": "~1.3.0", "ts-loader": "^9.4.2", "typescript": "^4.9.4" }, diff --git a/extensions/crossmodel-lang/src/glsp-server/command-palette/add-entity-action-provider.ts b/extensions/crossmodel-lang/src/glsp-server/command-palette/add-entity-action-provider.ts index 9dc10769..418d91d9 100644 --- a/extensions/crossmodel-lang/src/glsp-server/command-palette/add-entity-action-provider.ts +++ b/extensions/crossmodel-lang/src/glsp-server/command-palette/add-entity-action-provider.ts @@ -16,26 +16,28 @@ import { CrossModelState } from '../model/cross-model-state'; */ @injectable() export class CrossModelAddEntityActionProvider implements ContextActionsProvider { - contextId = 'command-palette'; + contextId = 'command-palette'; - @inject(CrossModelState) protected state: CrossModelState; + @inject(CrossModelState) protected state: CrossModelState; - async getActions(editorContext: EditorContext): Promise { - const scopeProvider = this.state.services.language.references.ScopeProvider; - const refInfo = createNodeToEntityReference(this.state.diagramRoot); - const actions: LabeledAction[] = []; - const scope = scopeProvider.getScope(refInfo); - const duplicateStore = new Set(); - scope.getAllElements().forEach(description => { - if (!duplicateStore.has(description.name) && !isExternalDescriptionForLocalPackage(description, this.state.packageId)) { - actions.push({ - label: description.name, - actions: [AddEntityOperation.create(description.name, editorContext.lastMousePosition || Point.ORIGIN)], - icon: codiconCSSString('inspect') - }); - duplicateStore.add(description.name); - } - }); - return actions; - } + async getActions(editorContext: EditorContext): Promise { + const scopeProvider = this.state.services.language.references.ScopeProvider; + const refInfo = createNodeToEntityReference(this.state.diagramRoot); + const actions: LabeledAction[] = []; + const scope = scopeProvider.getScope(refInfo); + const duplicateStore = new Set(); + + scope.getAllElements().forEach(description => { + if (!duplicateStore.has(description.name) && !isExternalDescriptionForLocalPackage(description, this.state.packageId)) { + actions.push({ + label: description.name, + actions: [AddEntityOperation.create(description.name, editorContext.lastMousePosition || Point.ORIGIN)], + icon: codiconCSSString('inspect') + }); + duplicateStore.add(description.name); + } + }); + + return actions; + } } diff --git a/extensions/crossmodel-lang/src/glsp-server/handler/add-entity-operation-handler.ts b/extensions/crossmodel-lang/src/glsp-server/handler/add-entity-operation-handler.ts index a2fd27ed..b3a15bf2 100644 --- a/extensions/crossmodel-lang/src/glsp-server/handler/add-entity-operation-handler.ts +++ b/extensions/crossmodel-lang/src/glsp-server/handler/add-entity-operation-handler.ts @@ -16,35 +16,36 @@ import { CrossModelCommand } from './cross-model-command'; */ @injectable() export class CrossModelAddEntityOperationHandler extends OperationHandler { - override operationType = AddEntityOperation.KIND; + override operationType = AddEntityOperation.KIND; - @inject(CrossModelState) protected state: CrossModelState; + @inject(CrossModelState) protected state: CrossModelState; - createCommand(operation: AddEntityOperation): Command { - return new CrossModelCommand(this.state, () => this.createEntityNode(operation)); - } + createCommand(operation: AddEntityOperation): Command { + return new CrossModelCommand(this.state, () => this.createEntityNode(operation)); + } - protected async createEntityNode(operation: AddEntityOperation): Promise { - const container = this.state.diagramRoot; - const refInfo = createNodeToEntityReference(container); - const scope = this.state.services.language.references.ScopeProvider.getScope(refInfo); - const entityDescription = scope.getElement(operation.entityName); - if (entityDescription) { - // create node for entity - const node: DiagramNode = { - $type: DiagramNode, - $container: container, - name: findAvailableNodeName(container, entityDescription.name + 'Node'), - semanticElement: { - $refText: entityDescription.name, - ref: entityDescription.node as Entity | undefined - }, - x: operation.position.x, - y: operation.position.y, - width: 10, - height: 10 - }; - container.nodes.push(node); - } - } + protected async createEntityNode(operation: AddEntityOperation): Promise { + const container = this.state.diagramRoot; + const refInfo = createNodeToEntityReference(container); + const scope = this.state.services.language.references.ScopeProvider.getScope(refInfo); + const entityDescription = scope.getElement(operation.entityName); + + if (entityDescription) { + // create node for entity + const node: DiagramNode = { + $type: DiagramNode, + $container: container, + name: findAvailableNodeName(container, entityDescription.name + 'Node'), + entity: { + $refText: entityDescription.name, + ref: entityDescription.node as Entity | undefined + }, + x: operation.position.x, + y: operation.position.y, + width: 10, + height: 10 + }; + container.nodes.push(node); + } + } } diff --git a/extensions/crossmodel-lang/src/glsp-server/handler/create-edge-operation-handler.ts b/extensions/crossmodel-lang/src/glsp-server/handler/create-edge-operation-handler.ts index eef38f36..93f148e6 100644 --- a/extensions/crossmodel-lang/src/glsp-server/handler/create-edge-operation-handler.ts +++ b/extensions/crossmodel-lang/src/glsp-server/handler/create-edge-operation-handler.ts @@ -3,14 +3,14 @@ ********************************************************************************/ import { - Command, - CreateEdgeOperation, - CreateOperationHandler, - CreateOperationKind, - DefaultTypes, - OperationHandler, - TriggerEdgeCreationAction, - TriggerNodeCreationAction + Command, + CreateEdgeOperation, + CreateOperationHandler, + CreateOperationKind, + DefaultTypes, + OperationHandler, + TriggerEdgeCreationAction, + TriggerNodeCreationAction } from '@eclipse-glsp/server'; import { inject, injectable } from 'inversify'; import { URI, Utils as UriUtils } from 'vscode-uri'; @@ -21,71 +21,71 @@ import { CrossModelCommand } from './cross-model-command'; @injectable() export class CrossModelCreateEdgeOperationHandler extends OperationHandler implements CreateOperationHandler { - override label = '1:1 Relationship'; - elementTypeIds = [DefaultTypes.EDGE]; - operationType: CreateOperationKind = CreateEdgeOperation.KIND; + override label = '1:1 Relationship'; + elementTypeIds = [DefaultTypes.EDGE]; + operationType: CreateOperationKind = CreateEdgeOperation.KIND; - @inject(CrossModelState) protected state: CrossModelState; + @inject(CrossModelState) protected state: CrossModelState; - getTriggerActions(): (TriggerEdgeCreationAction | TriggerNodeCreationAction)[] { - // return trigger actions that are shown in the tool palette on the client - return this.elementTypeIds.map(typeId => TriggerEdgeCreationAction.create(typeId)); - } + getTriggerActions(): (TriggerEdgeCreationAction | TriggerNodeCreationAction)[] { + // return trigger actions that are shown in the tool palette on the client + return this.elementTypeIds.map(typeId => TriggerEdgeCreationAction.create(typeId)); + } - createCommand(operation: CreateEdgeOperation): Command { - return new CrossModelCommand(this.state, () => this.createEdge(operation)); - } + createCommand(operation: CreateEdgeOperation): Command { + return new CrossModelCommand(this.state, () => this.createEdge(operation)); + } - protected async createEdge(operation: CreateEdgeOperation): Promise { - const sourceNode = this.state.index.findDiagramNode(operation.sourceElementId); - const targetNode = this.state.index.findDiagramNode(operation.targetElementId); - if (sourceNode && targetNode) { - // before we can create a digram edge, we need to create the corresponding relationship that it is based on - const relationship = await this.createAndSaveRelationship(sourceNode, targetNode); - if (relationship) { - const edge: DiagramEdge = { - $type: DiagramEdge, - $container: this.state.diagramRoot, - name: relationship.name, - semanticElement: { ref: relationship, $refText: this.state.nameProvider.getName(relationship) || relationship.name }, - source: { ref: sourceNode, $refText: this.state.nameProvider.getLocalName(sourceNode) || sourceNode.name }, - target: { ref: targetNode, $refText: this.state.nameProvider.getLocalName(targetNode) || targetNode.name } - }; - this.state.diagramRoot.edges.push(edge); - } - } - } + protected async createEdge(operation: CreateEdgeOperation): Promise { + const sourceNode = this.state.index.findDiagramNode(operation.sourceElementId); + const targetNode = this.state.index.findDiagramNode(operation.targetElementId); - /** - * Creates a new relationship and stores it on a file on the file system. - */ - protected async createAndSaveRelationship(sourceNode: DiagramNode, targetNode: DiagramNode): Promise { - const source = sourceNode.semanticElement.ref?.name || sourceNode.semanticElement.$refText; - const target = targetNode.semanticElement.ref?.name || targetNode.semanticElement.$refText; + if (sourceNode && targetNode) { + // before we can create a digram edge, we need to create the corresponding relationship that it is based on + const relationship = await this.createAndSaveRelationship(sourceNode, targetNode); + if (relationship) { + const edge: DiagramEdge = { + $type: DiagramEdge, + $container: this.state.diagramRoot, + name: relationship.name, + relationship: { ref: relationship, $refText: this.state.nameProvider.getName(relationship) || relationship.name || '' }, + sourceNode: { ref: sourceNode, $refText: this.state.nameProvider.getLocalName(sourceNode) || sourceNode.name || '' }, + targetNode: { ref: targetNode, $refText: this.state.nameProvider.getLocalName(targetNode) || targetNode.name || '' } + }; + this.state.diagramRoot.edges.push(edge); + } + } + } - // search for unique file name for the relationship and use file base name as relationship name - // if the user doesn't rename any files we should end up with unique names ;-) - const dirName = UriUtils.dirname(URI.parse(this.state.semanticUri)); - const targetUri = UriUtils.joinPath(dirName, source + 'To' + target + '.relationship.cm'); - const uri = Utils.findNewUri(targetUri); - const name = UriUtils.basename(uri).split('.')[0]; + /** + * Creates a new relationship and stores it on a file on the file system. + */ + protected async createAndSaveRelationship(sourceNode: DiagramNode, targetNode: DiagramNode): Promise { + const source = sourceNode.entity?.ref?.name || sourceNode.entity?.$refText; + const target = targetNode.entity?.ref?.name || targetNode.entity?.$refText; - // create relationship, serialize and re-read to ensure everything is up to date and linked properly - const relationshipRoot: CrossModelRoot = { $type: 'CrossModelRoot' }; - const relationship: Relationship = { - $type: Relationship, - $container: relationshipRoot, - name, - type: '1:1', - properties: [], - source: { $refText: sourceNode.semanticElement.$refText }, - target: { $refText: targetNode.semanticElement.$refText } - }; - relationshipRoot.relationship = relationship; - const text = this.state.semanticSerializer.serialize(relationshipRoot); + // search for unique file name for the relationship and use file base name as relationship name + // if the user doesn't rename any files we should end up with unique names ;-) + const dirName = UriUtils.dirname(URI.parse(this.state.semanticUri)); + const targetUri = UriUtils.joinPath(dirName, source + 'To' + target + '.relationship.cm'); + const uri = Utils.findNewUri(targetUri); + const name = UriUtils.basename(uri).split('.')[0]; - await this.state.modelService.save(uri.toString(), text); - const root = await this.state.modelService.request(uri.toString(), isCrossModelRoot); - return root?.relationship; - } + // create relationship, serialize and re-read to ensure everything is up to date and linked properly + const relationshipRoot: CrossModelRoot = { $type: 'CrossModelRoot' }; + const relationship: Relationship = { + $type: Relationship, + $container: relationshipRoot, + name, + type: '1:1', + parent: { $refText: sourceNode.entity?.$refText || '' }, + child: { $refText: targetNode.entity?.$refText || '' } + }; + relationshipRoot.relationship = relationship; + const text = this.state.semanticSerializer.serialize(relationshipRoot); + + await this.state.modelService.save({ uri: uri.toString(), model: text, clientId: this.state.clientId }); + const root = await this.state.modelService.request(uri.toString(), isCrossModelRoot); + return root?.relationship; + } } diff --git a/extensions/crossmodel-lang/src/glsp-server/handler/drop-entity-operation-handler.ts b/extensions/crossmodel-lang/src/glsp-server/handler/drop-entity-operation-handler.ts index 3b9f6633..e1f10b0e 100644 --- a/extensions/crossmodel-lang/src/glsp-server/handler/drop-entity-operation-handler.ts +++ b/extensions/crossmodel-lang/src/glsp-server/handler/drop-entity-operation-handler.ts @@ -18,37 +18,37 @@ import { CrossModelCommand } from './cross-model-command'; */ @injectable() export class CrossModelDropEntityOperationHandler extends OperationHandler { - override operationType = DropEntityOperation.KIND; + override operationType = DropEntityOperation.KIND; - @inject(CrossModelState) protected state: CrossModelState; + @inject(CrossModelState) protected state: CrossModelState; - createCommand(operation: DropEntityOperation): Command { - return new CrossModelCommand(this.state, () => this.createEntityNode(operation)); - } + createCommand(operation: DropEntityOperation): Command { + return new CrossModelCommand(this.state, () => this.createEntityNode(operation)); + } - protected async createEntityNode(operation: DropEntityOperation): Promise { - const container = this.state.diagramRoot; - let x = operation.position.x; - let y = operation.position.y; - for (const filePath of operation.filePaths) { - const root = await this.state.modelService.request(URI.file(filePath).toString(), isCrossModelRoot); - if (root?.entity) { - // create node for entity - const node: DiagramNode = { - $type: DiagramNode, - $container: container, - name: findAvailableNodeName(container, root.entity.name + 'Node'), - semanticElement: { - $refText: this.state.nameProvider.getFullyQualifiedName(root.entity) || root.entity.name, - ref: root.entity - }, - x: (x += 10), - y: (y += 10), - width: 10, - height: 10 - }; - container.nodes.push(node); - } - } - } + protected async createEntityNode(operation: DropEntityOperation): Promise { + const container = this.state.diagramRoot; + let x = operation.position.x; + let y = operation.position.y; + for (const filePath of operation.filePaths) { + const root = await this.state.modelService.request(URI.file(filePath).toString(), isCrossModelRoot); + if (root?.entity) { + // create node for entity + const node: DiagramNode = { + $type: DiagramNode, + $container: container, + name: findAvailableNodeName(container, root.entity.name + 'Node'), + entity: { + $refText: this.state.nameProvider.getFullyQualifiedName(root.entity) || root.entity.name || '', + ref: root.entity + }, + x: (x += 10), + y: (y += 10), + width: 10, + height: 10 + }; + container.nodes.push(node); + } + } + } } diff --git a/extensions/crossmodel-lang/src/glsp-server/launch.ts b/extensions/crossmodel-lang/src/glsp-server/launch.ts index ebdeb6af..933a5722 100644 --- a/extensions/crossmodel-lang/src/glsp-server/launch.ts +++ b/extensions/crossmodel-lang/src/glsp-server/launch.ts @@ -25,7 +25,7 @@ import { CrossModelLayoutConfigurator } from './layout/cross-model-layout-config * @returns a promise that is resolved as soon as the server is shut down or rejects if an error occurs */ export function startGLSPServer(services: CrossModelLSPServices, workspaceFolder: URI): MaybePromise { - const launchOptions: SocketLaunchOptions = { ...defaultSocketLaunchOptions, logLevel: LogLevel.debug }; + const launchOptions: SocketLaunchOptions = { ...defaultSocketLaunchOptions, logLevel: LogLevel.info }; // create module based on launch options, e.g., logging etc. const appModule = createAppModule(launchOptions); diff --git a/extensions/crossmodel-lang/src/glsp-server/model/builders/gentity-node.ts b/extensions/crossmodel-lang/src/glsp-server/model/builders/node-builder.ts similarity index 69% rename from extensions/crossmodel-lang/src/glsp-server/model/builders/gentity-node.ts rename to extensions/crossmodel-lang/src/glsp-server/model/builders/node-builder.ts index b01a1362..c6b7d957 100644 --- a/extensions/crossmodel-lang/src/glsp-server/model/builders/gentity-node.ts +++ b/extensions/crossmodel-lang/src/glsp-server/model/builders/node-builder.ts @@ -15,7 +15,7 @@ export class GEntityNode extends GNode { export class GEntityNodeBuilder extends GNodeBuilder { addNode(node: DiagramNode): this { // Get the reference that the DiagramNode holds to the Entity in the .langium file. - const entityRef = node.semanticElement.ref; + const entityRef = node.entity?.ref; // Options which are the same for every node this.addCssClasses('diagram-node', 'entity').layout('vbox').addArgs(ArgsUtil.cornerRadius(3)); @@ -27,12 +27,13 @@ export class GEntityNodeBuilder extends GNodeBuilder { // Add the label/name of the node const label = GCompartment.builder() + .id(`${this.proxy.id}_header`) .layout('hbox') .addLayoutOption('hAlign', 'center') .addCssClass('entity-header-compartment') .add( GLabel.builder() - .text(entityRef?.name || 'unresolved') + .text(entityRef?.name_val || 'unresolved') .id(`${this.proxy.id}_label`) .addCssClass('entity-header-label') .build() @@ -44,6 +45,7 @@ export class GEntityNodeBuilder extends GNodeBuilder { // Add the children of the node if (entityRef !== undefined) { const allAttributesCompartment = GCompartment.builder() + .id(`${this.proxy.id}_attributes`) .addCssClass('attributes-compartment') .layout('vbox') .addLayoutOption('hAlign', 'left') @@ -52,14 +54,27 @@ export class GEntityNodeBuilder extends GNodeBuilder { // Add the attributes of the entity. for (const attribute of entityRef.attributes) { const attributeCompartment = GCompartment.builder() + .id(`${this.proxy.id}_${attribute.name}_attribute`) .addCssClass('attribute-compartment') .layout('hbox') .addLayoutOption('paddingBottom', 3) .addLayoutOption('paddingTop', 3); - attributeCompartment.add(GLabel.builder().text(attribute.name).addCssClass('attribute').build()); - attributeCompartment.add(GLabel.builder().text(' : ').build()); - attributeCompartment.add(GLabel.builder().text(attribute.value.toString()).addCssClass('datatype').build()); + attributeCompartment.add( + GLabel.builder() + .id(`${this.proxy.id}_${attribute.name}_attribute_name`) + .text(attribute.name_val || '') + .addCssClass('attribute') + .build() + ); + attributeCompartment.add(GLabel.builder().text(' : ').id(`${this.proxy.id}_${attribute.name}_attribute_del`).build()); + attributeCompartment.add( + GLabel.builder() + .id(`${this.proxy.id}_${attribute.name}_attribute_type`) + .text(attribute.datatype?.toString() || '') + .addCssClass('datatype') + .build() + ); allAttributesCompartment.add(attributeCompartment.build()); } @@ -68,7 +83,9 @@ export class GEntityNodeBuilder extends GNodeBuilder { } // The DiagramNode in the langium file holds the coordinates of node - this.addLayoutOption('prefWidth', node.width).addLayoutOption('prefHeight', node.height).position(node.x, node.y); + this.addLayoutOption('prefWidth', node.width || 100) + .addLayoutOption('prefHeight', node.height || 100) + .position(node.x || 100, node.y || 100); return this; } diff --git a/extensions/crossmodel-lang/src/glsp-server/model/cross-model-gmodel-factory.ts b/extensions/crossmodel-lang/src/glsp-server/model/cross-model-gmodel-factory.ts index 172afb28..490a32ca 100644 --- a/extensions/crossmodel-lang/src/glsp-server/model/cross-model-gmodel-factory.ts +++ b/extensions/crossmodel-lang/src/glsp-server/model/cross-model-gmodel-factory.ts @@ -4,7 +4,7 @@ import { GEdge, GGraph, GModelFactory, GNode } from '@eclipse-glsp/server'; import { inject, injectable } from 'inversify'; import { DiagramEdge, DiagramNode } from '../../language-server/generated/ast'; -import { GEntityNode } from './builders/gentity-node'; +import { GEntityNode } from './builders/node-builder'; import { CrossModelState } from './cross-model-state'; /** @@ -14,39 +14,44 @@ import { CrossModelState } from './cross-model-state'; */ @injectable() export class CrossModelGModelFactory implements GModelFactory { - @inject(CrossModelState) protected readonly modelState: CrossModelState; - - createModel(): void { - const newRoot = this.createGraph(); - if (newRoot) { - // update GLSP root element in state so it can be used in any follow-up actions/commands - this.modelState.updateRoot(newRoot); - } - } - - protected createGraph(): GGraph | undefined { - const diagramRoot = this.modelState.diagramRoot; - - const graphBuilder = GGraph.builder().id(this.modelState.semanticUri); - diagramRoot.nodes.map(node => this.createDiagramNode(node)).forEach(node => graphBuilder.add(node)); - diagramRoot.edges.map(edge => this.createDiagramEdge(edge)).forEach(edge => graphBuilder.add(edge)); - - return graphBuilder.build(); - } - - protected createDiagramNode(node: DiagramNode): GNode { - // Get the reference that the DiagramNode holds to the Entity in the .langium file. - const id = this.modelState.index.createId(node) ?? 'unknown'; - return GEntityNode.builder().id(id).addNode(node).build(); - } - - protected createDiagramEdge(edge: DiagramEdge): GEdge { - const id = this.modelState.index.createId(edge) ?? 'unknown'; - return GEdge.builder() - .id(id) - .addCssClasses('diagram-edge', 'relationship') - .sourceId(edge.source.$refText) - .targetId(edge.target.$refText) - .build(); - } + @inject(CrossModelState) protected readonly modelState: CrossModelState; + + createModel(): void { + const newRoot = this.createGraph(); + if (newRoot) { + // update GLSP root element in state so it can be used in any follow-up actions/commands + this.modelState.updateRoot(newRoot); + } + } + + protected createGraph(): GGraph | undefined { + const diagramRoot = this.modelState.diagramRoot; + const graphBuilder = GGraph.builder().id(this.modelState.semanticUri); + + diagramRoot.nodes.map(node => this.createDiagramNode(node)).forEach(node => graphBuilder.add(node)); + diagramRoot.edges.map(edge => this.createDiagramEdge(edge)).forEach(edge => graphBuilder.add(edge)); + + return graphBuilder.build(); + } + + protected createDiagramNode(node: DiagramNode): GNode { + // Get the reference that the DiagramNode holds to the Entity in the .langium file. + const id = this.modelState.index.createId(node) ?? 'unknown'; + + return GEntityNode.builder().id(id).addNode(node).build(); + } + + protected createDiagramEdge(edge: DiagramEdge): GEdge { + const id = this.modelState.index.createId(edge) ?? 'unknown'; + + const parentDiagramNode = edge.sourceNode?.ref?.name || edge.sourceNode?.$refText; + const childDiagramNode = edge.targetNode?.ref?.name || edge.targetNode?.$refText; + + return GEdge.builder() + .id(id) + .addCssClasses('diagram-edge', 'relationship') + .sourceId(parentDiagramNode || '') + .targetId(childDiagramNode || '') + .build(); + } } diff --git a/extensions/crossmodel-lang/src/glsp-server/model/cross-model-state.ts b/extensions/crossmodel-lang/src/glsp-server/model/cross-model-state.ts index b4856783..d1b6f924 100644 --- a/extensions/crossmodel-lang/src/glsp-server/model/cross-model-state.ts +++ b/extensions/crossmodel-lang/src/glsp-server/model/cross-model-state.ts @@ -8,7 +8,7 @@ import { CrossModelLSPServices } from '../../integration'; import { QualifiedNameProvider } from '../../language-server/cross-model-naming'; import { CrossModelRoot, SystemDiagram } from '../../language-server/generated/ast'; import { ModelService } from '../../model-server/model-service'; -import { DiagramSerializer } from '../../model-server/serializer'; +import { Serializer } from '../../model-server/serializer'; import { CrossModelIndex } from './cross-model-index'; /** @@ -17,55 +17,59 @@ import { CrossModelIndex } from './cross-model-index'; */ @injectable() export class CrossModelState extends DefaultModelState { - @inject(CrossModelIndex) override readonly index: CrossModelIndex; - @inject(CrossModelLSPServices) readonly services: CrossModelLSPServices; + @inject(CrossModelIndex) override readonly index: CrossModelIndex; + @inject(CrossModelLSPServices) readonly services: CrossModelLSPServices; - protected _semanticUri: string; - protected _semanticRoot: CrossModelRoot; - protected _packageId: string; + protected _semanticUri: string; + protected _semanticRoot: CrossModelRoot; + protected _packageId: string; - setSemanticRoot(uri: string, semanticRoot: CrossModelRoot): void { - this._semanticUri = uri; - this._semanticRoot = semanticRoot; - this._packageId = this.services.shared.workspace.PackageManager.getPackageIdByUri(URI.parse(uri)); - this.index.indexSemanticRoot(this.semanticRoot); - } + setSemanticRoot(uri: string, semanticRoot: CrossModelRoot): void { + this._semanticUri = uri; + this._semanticRoot = semanticRoot; + this._packageId = this.services.shared.workspace.PackageManager.getPackageIdByUri(URI.parse(uri)); + this.index.indexSemanticRoot(this.semanticRoot); + } - get semanticUri(): string { - return this._semanticUri; - } + get semanticUri(): string { + return this._semanticUri; + } - get semanticRoot(): CrossModelRoot { - return this._semanticRoot; - } + get semanticRoot(): CrossModelRoot { + return this._semanticRoot; + } - get packageId(): string { - return this._packageId; - } + get packageId(): string { + return this._packageId; + } - get diagramRoot(): SystemDiagram { - return this.semanticRoot.diagram!; - } + get diagramRoot(): SystemDiagram { + return this.semanticRoot.diagram!; + } - get modelService(): ModelService { - return this.services.shared.model.ModelService; - } + get modelService(): ModelService { + return this.services.shared.model.ModelService; + } - get semanticSerializer(): DiagramSerializer { - return this.services.language.serializer.Serializer; - } + get semanticSerializer(): Serializer { + return this.services.language.serializer.Serializer; + } - get nameProvider(): QualifiedNameProvider { - return this.services.language.references.QualifiedNameProvider; - } + get nameProvider(): QualifiedNameProvider { + return this.services.language.references.QualifiedNameProvider; + } - async updateSemanticRoot(content?: string): Promise { - this._semanticRoot = await this.modelService.update(this.semanticUri, content ?? this.semanticRoot); - this.index.indexSemanticRoot(this.semanticRoot); - } + async updateSemanticRoot(content?: string): Promise { + this._semanticRoot = await this.modelService.update({ + uri: this.semanticUri, + model: content ?? this.semanticRoot, + clientId: this.clientId + }); + this.index.indexSemanticRoot(this.semanticRoot); + } - /** Textual representation of the current semantic root. */ - semanticText(): string { - return this.services.language.serializer.Serializer.serialize(this.semanticRoot); - } + /** Textual representation of the current semantic root. */ + semanticText(): string { + return this.services.language.serializer.Serializer.serialize(this.semanticRoot); + } } diff --git a/extensions/crossmodel-lang/src/glsp-server/model/cross-model-storage.ts b/extensions/crossmodel-lang/src/glsp-server/model/cross-model-storage.ts index be6cf167..f258675b 100644 --- a/extensions/crossmodel-lang/src/glsp-server/model/cross-model-storage.ts +++ b/extensions/crossmodel-lang/src/glsp-server/model/cross-model-storage.ts @@ -3,21 +3,25 @@ ********************************************************************************/ import { - ClientSession, - ClientSessionListener, - ClientSessionManager, - GLSPServerError, - Logger, - MaybePromise, - RequestModelAction, - SOURCE_URI_ARG, - SaveModelAction, - SourceModelStorage + ActionDispatcher, + ClientSession, + ClientSessionListener, + ClientSessionManager, + Disposable, + DisposableCollection, + GLSPServerError, + Logger, + MaybePromise, + ModelSubmissionHandler, + RequestModelAction, + SOURCE_URI_ARG, + SaveModelAction, + SourceModelStorage } from '@eclipse-glsp/server'; import { inject, injectable, postConstruct } from 'inversify'; import { findRootNode, streamReferences } from 'langium'; import { URI } from 'vscode-uri'; -import { isCrossModelRoot } from '../../language-server/generated/ast'; +import { CrossModelRoot, isCrossModelRoot } from '../../language-server/generated/ast'; import { CrossModelState } from './cross-model-state'; /** @@ -27,56 +31,71 @@ import { CrossModelState } from './cross-model-state'; */ @injectable() export class CrossModelStorage implements SourceModelStorage, ClientSessionListener { - @inject(Logger) protected logger: Logger; - @inject(CrossModelState) protected state: CrossModelState; - @inject(ClientSessionManager) protected sessionManager: ClientSessionManager; + @inject(Logger) protected logger: Logger; + @inject(CrossModelState) protected state: CrossModelState; + @inject(ClientSessionManager) protected sessionManager: ClientSessionManager; + @inject(ModelSubmissionHandler) protected submissionHandler: ModelSubmissionHandler; + @inject(ActionDispatcher) protected actionDispatcher: ActionDispatcher; - @postConstruct() - protected init(): void { - this.sessionManager.addListener(this, this.state.clientId); - } + protected toDispose = new DisposableCollection(); - async loadSourceModel(action: RequestModelAction): Promise { - // load semantic model from document in language model service - const sourceUri = this.getSourceUri(action); - const rootUri = URI.file(sourceUri).toString(); - const root = await this.state.modelService.request(rootUri, isCrossModelRoot); - if (!root || !root.diagram) { - throw new GLSPServerError('Expected CrossModal Diagram Root'); - } - this.state.setSemanticRoot(rootUri, root); - } + @postConstruct() + protected init(): void { + this.sessionManager.addListener(this, this.state.clientId); + } - saveSourceModel(action: SaveModelAction): MaybePromise { - const saveUri = this.getFileUri(action); + async loadSourceModel(action: RequestModelAction): Promise { + // load semantic model from document in language model service + const sourceUri = this.getSourceUri(action); + const rootUri = URI.file(sourceUri).toString(); + await this.state.modelService.open({ uri: rootUri, clientId: this.state.clientId }); + this.toDispose.push(Disposable.create(() => this.state.modelService.close({ uri: rootUri, clientId: this.state.clientId }))); + const root = await this.state.modelService.request(rootUri, isCrossModelRoot); + if (!root || !root.diagram) { + throw new GLSPServerError('Expected CrossModal Diagram Root'); + } + this.state.setSemanticRoot(rootUri, root); + this.toDispose.push( + this.state.modelService.onUpdate(rootUri, async event => { + if (this.state.clientId !== event.sourceClientId) { + this.state.setSemanticRoot(rootUri, event.model); + this.actionDispatcher.dispatchAll(await this.submissionHandler.submitModel('external')); + } + }) + ); + } - // save document and all related documents - this.state.modelService.save(saveUri, this.state.semanticRoot); - streamReferences(this.state.semanticRoot) - .map(refInfo => refInfo.reference.ref) - .nonNullable() - .map(ref => findRootNode(ref)) - .forEach(root => this.state.modelService.save(root.$document!.uri.toString(), root)); - } + saveSourceModel(action: SaveModelAction): MaybePromise { + const saveUri = this.getFileUri(action); - sessionDisposed(_clientSession: ClientSession): void { - // close loaded document for modification - this.state.modelService.close(this.state.semanticUri); - } + // save document and all related documents + this.state.modelService.save({ uri: saveUri, model: this.state.semanticRoot, clientId: this.state.clientId }); + streamReferences(this.state.semanticRoot) + .map(refInfo => refInfo.reference.ref) + .nonNullable() + .map(ref => findRootNode(ref)) + .forEach(root => + this.state.modelService.save({ uri: root.$document!.uri.toString(), model: root, clientId: this.state.clientId }) + ); + } - protected getSourceUri(action: RequestModelAction): string { - const sourceUri = action.options?.[SOURCE_URI_ARG]; - if (typeof sourceUri !== 'string') { - throw new GLSPServerError(`Invalid RequestModelAction! Missing argument with key '${SOURCE_URI_ARG}'`); - } - return sourceUri; - } + sessionDisposed(_clientSession: ClientSession): void { + this.toDispose.dispose(); + } - protected getFileUri(action: SaveModelAction): string { - const uri = action.fileUri ?? this.state.get(SOURCE_URI_ARG); - if (!uri) { - throw new GLSPServerError('Could not derive fileUri for saving the current source model'); - } - return uri; - } + protected getSourceUri(action: RequestModelAction): string { + const sourceUri = action.options?.[SOURCE_URI_ARG]; + if (typeof sourceUri !== 'string') { + throw new GLSPServerError(`Invalid RequestModelAction! Missing argument with key '${SOURCE_URI_ARG}'`); + } + return sourceUri; + } + + protected getFileUri(action: SaveModelAction): string { + const uri = action.fileUri ?? this.state.get(SOURCE_URI_ARG); + if (!uri) { + throw new GLSPServerError('Could not derive fileUri for saving the current source model'); + } + return uri; + } } diff --git a/extensions/crossmodel-lang/src/language-server/cross-model-formatter.ts b/extensions/crossmodel-lang/src/language-server/cross-model-formatter.ts index 8b25970f..709fa858 100644 --- a/extensions/crossmodel-lang/src/language-server/cross-model-formatter.ts +++ b/extensions/crossmodel-lang/src/language-server/cross-model-formatter.ts @@ -1,39 +1,10 @@ /******************************************************************************** * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ -import { AbstractFormatter, AstNode, Formatting } from 'langium'; - -const ROOT_KEYWORDS = ['entity', 'relationship', 'diagram']; -const OTHER_KEYWORDS = [ - 'description', - 'attributes', - 'source', - 'target', - 'type', - 'properties', - ':=', - 'node', - 'edge', - 'for', - 'x', - 'y', - 'width', - 'height', - 'source', - 'target' -]; +import { AbstractFormatter, AstNode } from 'langium'; export class CrossModelModelFormatter extends AbstractFormatter { - protected format(node: AstNode): void { - const formatter = this.getNodeFormatter(node); - formatter.keywords(...ROOT_KEYWORDS).prepend(Formatting.noSpace({ allowLess: false, allowMore: false, priority: 1 })); - formatter.keywords(...OTHER_KEYWORDS).surround(Formatting.oneSpace({ allowLess: false, allowMore: false, priority: 1 })); - formatter - .interior(formatter.keyword('{'), formatter.keyword('}')) - .prepend(Formatting.indent({ allowLess: false, allowMore: false, priority: 1 })); - formatter - .keywords(';') - .prepend(Formatting.noSpace({ allowLess: false, allowMore: false, priority: 1 })) - .append(Formatting.newLine()); - } + protected format(node: AstNode): void { + return; + } } diff --git a/extensions/crossmodel-lang/src/language-server/cross-model-language-server.ts b/extensions/crossmodel-lang/src/language-server/cross-model-language-server.ts new file mode 100644 index 00000000..91e7b266 --- /dev/null +++ b/extensions/crossmodel-lang/src/language-server/cross-model-language-server.ts @@ -0,0 +1,13 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ +import { DefaultLanguageServer } from 'langium'; +import { TextDocumentSyncKind, type InitializeParams, type InitializeResult } from 'vscode-languageserver-protocol'; + +export class CrossModelLanguageServer extends DefaultLanguageServer { + override async initialize(params: InitializeParams): Promise { + const result = await super.initialize(params); + result.capabilities.textDocumentSync = TextDocumentSyncKind.Full; + return result; + } +} diff --git a/extensions/crossmodel-lang/src/language-server/cross-model-module.ts b/extensions/crossmodel-lang/src/language-server/cross-model-module.ts index 75f2e615..593c4283 100644 --- a/extensions/crossmodel-lang/src/language-server/cross-model-module.ts +++ b/extensions/crossmodel-lang/src/language-server/cross-model-module.ts @@ -2,19 +2,19 @@ * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ import { - AstNode, - createDefaultModule, - createDefaultSharedModule, - DefaultServiceRegistry, - DefaultSharedModuleContext, - inject, - JsonSerializer, - LangiumServices, - LangiumSharedServices, - Module, - PartialLangiumServices, - PartialLangiumSharedServices, - ServiceRegistry + AstNode, + createDefaultModule, + createDefaultSharedModule, + DefaultServiceRegistry, + DefaultSharedModuleContext, + inject, + JsonSerializer, + LangiumServices, + LangiumSharedServices, + Module, + PartialLangiumServices, + PartialLangiumSharedServices, + ServiceRegistry } from 'langium'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { URI } from 'vscode-uri'; @@ -28,6 +28,7 @@ import { CrossModelCompletionProvider } from './cross-model-completion-provider' import { CrossModelDocumentBuilder } from './cross-model-document-builder'; import { CrossModelModelFormatter } from './cross-model-formatter'; import { CrossModelLangiumDocuments } from './cross-model-langium-documents'; +import { CrossModelLanguageServer } from './cross-model-language-server'; import { QualifiedNameProvider } from './cross-model-naming'; import { CrossModelPackageManager } from './cross-model-package-manager'; import { CrossModelScopeComputation } from './cross-model-scope'; @@ -36,70 +37,77 @@ import { CrossModelSerializer } from './cross-model-serializer'; import { CrossModelValidator, registerValidationChecks } from './cross-model-validator'; import { CrossModelWorkspaceManager } from './cross-model-workspace-manager'; import { CrossModelGeneratedModule, CrossModelGeneratedSharedModule } from './generated/module'; +import { CrossModelLexer } from './lexer/cross-model-lexer'; +import { CrossModelTokenBuilder } from './lexer/cross-model-token-generator'; /*************************** * Shared Module ***************************/ export interface ExtendedLangiumServices extends LangiumServices { - serializer: { - JsonSerializer: JsonSerializer; - Serializer: Serializer; - }; + serializer: { + JsonSerializer: JsonSerializer; + Serializer: Serializer; + }; } export class ExtendedServiceRegistry extends DefaultServiceRegistry { - override register(language: ExtendedLangiumServices): void { - super.register(language); - } + override register(language: ExtendedLangiumServices): void { + super.register(language); + } - override getServices(uri: URI): ExtendedLangiumServices { - return super.getServices(uri) as ExtendedLangiumServices; - } + override getServices(uri: URI): ExtendedLangiumServices { + return super.getServices(uri) as ExtendedLangiumServices; + } } export interface ExtendedServiceRegistry extends ServiceRegistry { - register(language: ExtendedLangiumServices): void; - getServices(uri: URI): ExtendedLangiumServices; + register(language: ExtendedLangiumServices): void; + getServices(uri: URI): ExtendedLangiumServices; } /** * Declaration of custom services - add your own service classes here. */ export interface CrossModelAddedSharedServices { - /* override */ ServiceRegistry: ExtendedServiceRegistry; - workspace: { - /* override */ WorkspaceManager: CrossModelWorkspaceManager; - PackageManager: CrossModelPackageManager; - }; - logger: { - ClientLogger: ClientLogger; - }; + /* override */ + ServiceRegistry: ExtendedServiceRegistry; + + workspace: { + /* override */ WorkspaceManager: CrossModelWorkspaceManager; + PackageManager: CrossModelPackageManager; + }; + logger: { + ClientLogger: ClientLogger; + }; } export const CrossModelSharedServices = Symbol('CrossModelSharedServices'); export type CrossModelSharedServices = Omit & - CrossModelAddedSharedServices & - AddedSharedModelServices; + CrossModelAddedSharedServices & + AddedSharedModelServices; export const CrossModelSharedModule: Module< - CrossModelSharedServices, - PartialLangiumSharedServices & CrossModelAddedSharedServices & AddedSharedModelServices + CrossModelSharedServices, + PartialLangiumSharedServices & CrossModelAddedSharedServices & AddedSharedModelServices > = { - ServiceRegistry: () => new ExtendedServiceRegistry(), - workspace: { - WorkspaceManager: services => new CrossModelWorkspaceManager(services), - PackageManager: services => new CrossModelPackageManager(services), - LangiumDocuments: services => new CrossModelLangiumDocuments(services), - TextDocuments: () => new OpenableTextDocuments(TextDocument), - TextDocumentManager: services => new OpenTextDocumentManager(services), - DocumentBuilder: services => new CrossModelDocumentBuilder(services) - }, - logger: { - ClientLogger: services => new ClientLogger(services) - }, - model: { - ModelService: services => new ModelService(services) - } + ServiceRegistry: () => new ExtendedServiceRegistry(), + workspace: { + WorkspaceManager: services => new CrossModelWorkspaceManager(services), + PackageManager: services => new CrossModelPackageManager(services), + LangiumDocuments: services => new CrossModelLangiumDocuments(services), + TextDocuments: services => new OpenableTextDocuments(TextDocument, services.logger.ClientLogger), + TextDocumentManager: services => new OpenTextDocumentManager(services), + DocumentBuilder: services => new CrossModelDocumentBuilder(services) + }, + logger: { + ClientLogger: services => new ClientLogger(services) + }, + lsp: { + LanguageServer: services => new CrossModelLanguageServer(services) + }, + model: { + ModelService: services => new ModelService(services) + } }; /*************************** @@ -107,23 +115,23 @@ export const CrossModelSharedModule: Module< ***************************/ export interface CrossModelModuleContext { - shared: CrossModelSharedServices; + shared: CrossModelSharedServices; } /** * Declaration of custom services - add your own service classes here. */ export interface CrossModelAddedServices { - references: { - QualifiedNameProvider: QualifiedNameProvider; - }; - validation: { - CrossModelValidator: CrossModelValidator; - }; - serializer: { - Serializer: CrossModelSerializer; - }; - /* override */ shared: CrossModelSharedServices; + references: { + QualifiedNameProvider: QualifiedNameProvider; + }; + validation: { + CrossModelValidator: CrossModelValidator; + }; + serializer: { + Serializer: CrossModelSerializer; + }; + /* override */ shared: CrossModelSharedServices; } /** @@ -139,26 +147,30 @@ export const CrossModelServices = Symbol('CrossModelServices'); * selected services, while the custom services must be fully specified. */ export function createCrossModelModule( - context: CrossModelModuleContext + context: CrossModelModuleContext ): Module { - return { - references: { - ScopeComputation: services => new CrossModelScopeComputation(services), - ScopeProvider: services => new CrossModelScopeProvider(services), - QualifiedNameProvider: services => new QualifiedNameProvider(services) - }, - validation: { - CrossModelValidator: () => new CrossModelValidator() - }, - lsp: { - CompletionProvider: services => new CrossModelCompletionProvider(services), - Formatter: () => new CrossModelModelFormatter() - }, - serializer: { - Serializer: services => new CrossModelSerializer(services) - }, - shared: () => context.shared - }; + return { + references: { + ScopeComputation: services => new CrossModelScopeComputation(services), + ScopeProvider: services => new CrossModelScopeProvider(services), + QualifiedNameProvider: services => new QualifiedNameProvider(services) + }, + validation: { + CrossModelValidator: () => new CrossModelValidator() + }, + lsp: { + CompletionProvider: services => new CrossModelCompletionProvider(services), + Formatter: () => new CrossModelModelFormatter() + }, + serializer: { + Serializer: services => new CrossModelSerializer(services) + }, + parser: { + TokenBuilder: () => new CrossModelTokenBuilder(), + Lexer: services => new CrossModelLexer(services) + }, + shared: () => context.shared + }; } /** @@ -177,12 +189,12 @@ export function createCrossModelModule( * @returns An object wrapping the shared services and the language-specific services */ export function createCrossModelServices(context: DefaultSharedModuleContext): { - shared: CrossModelSharedServices; - CrossModel: CrossModelServices; + shared: CrossModelSharedServices; + CrossModel: CrossModelServices; } { - const shared = inject(createDefaultSharedModule(context), CrossModelGeneratedSharedModule, CrossModelSharedModule); - const CrossModel = inject(createDefaultModule({ shared }), CrossModelGeneratedModule, createCrossModelModule({ shared })); - shared.ServiceRegistry.register(CrossModel); - registerValidationChecks(CrossModel); - return { shared, CrossModel }; + const shared = inject(createDefaultSharedModule(context), CrossModelGeneratedSharedModule, CrossModelSharedModule); + const CrossModel = inject(createDefaultModule({ shared }), CrossModelGeneratedModule, createCrossModelModule({ shared })); + shared.ServiceRegistry.register(CrossModel); + registerValidationChecks(CrossModel); + return { shared, CrossModel }; } diff --git a/extensions/crossmodel-lang/src/language-server/cross-model-serializer.ts b/extensions/crossmodel-lang/src/language-server/cross-model-serializer.ts index 9807118a..edb55c78 100644 --- a/extensions/crossmodel-lang/src/language-server/cross-model-serializer.ts +++ b/extensions/crossmodel-lang/src/language-server/cross-model-serializer.ts @@ -2,160 +2,143 @@ * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ -import { isReference, Reference } from 'langium'; -import { DiagramSerializer, Serializer } from '../model-server/serializer'; +import { isReference } from 'langium'; +import { Serializer } from '../model-server/serializer'; import { CrossModelServices } from './cross-model-module'; -import { - Attribute, - CrossModelRoot, - DiagramEdge, - DiagramNode, - Entity, - isCrossModelRoot, - isEntity, - isSystemDiagram, - Property, - Relationship, - SystemDiagram -} from './generated/ast'; +import { CrossModelRoot, Entity, Relationship, SystemDiagram } from './generated/ast'; + +const PROPERTY_ORDER = [ + 'id', + 'name', + 'name_val', + 'datatype', + 'description', + 'attributes', + 'parent', + 'child', + 'type', + 'nodes', + 'edges', + 'entity', + 'x', + 'y', + 'width', + 'height', + 'relationship', + 'sourceNode', + 'targetNode' +]; /** * Hand-written AST serializer as there is currently no out-of-the box serializer from Langium, but it is on the roadmap. * cf. https://github.com/langium/langium/discussions/683 * cf. https://github.com/langium/langium/discussions/863 */ -export class CrossModelSerializer implements Serializer, DiagramSerializer { +export class CrossModelSerializer implements Serializer { + // New line character. + static readonly CHAR_NEWLINE = '\n'; + // Indentation character. + static readonly CHAR_INDENTATION = ' '; + // The amount of spaces to use to indent an object. + static readonly INDENTATION_AMOUNT_OBJECT = 4; + // The amount of spaces to use to indent an array. + static readonly INDENTATION_AMOUNT_ARRAY = 2; + constructor(protected services: CrossModelServices, protected refNameProvider = services.references.QualifiedNameProvider) {} serialize(root: CrossModelRoot): string { - if (root.entity) { - return this.serializeEntity(root.entity); - } - if (root.relationship) { - return this.serializeRelationship(root.relationship); - } - if (root.diagram) { - return this.serializeDiagram(root.diagram); - } - return ''; + const newRoot: CrossModelRoot | Entity | Relationship | SystemDiagram = this.toSerializableObject(root); + return this.serializeValue(newRoot, CrossModelSerializer.INDENTATION_AMOUNT_OBJECT * -1); } - protected serializeEntity(entity: Entity): string { - return `entity ${entity.name} { - description := "${entity.description}"; - attributes { -${this.serializeAttributes(entity.attributes as Array)} } -}`; - } - - private serializeAttributes(attributes: Array | undefined): string { - let result = ''; - - if (attributes) { - for (const [, attribute] of Object.entries(attributes)) { - result += `\t\t${attribute.name} := '${attribute.value}';\n`; - } + private serializeValue(value: any, indentationLevel: number): string { + if (Array.isArray(value)) { + return this.serializeArray(value, indentationLevel); + } else if (typeof value === 'object' && value !== undefined) { + return this.serializeObject(value, indentationLevel + CrossModelSerializer.INDENTATION_AMOUNT_OBJECT); + } else { + return JSON.stringify(value); } - - return result; } - protected serializeRelationship(relationship: Relationship): string { - return `relationship ${relationship.name} { - source := ${this.serializeReference(relationship.source)}${ - relationship.sourceAttribute ? ' with ' + this.serializeReference(relationship.sourceAttribute) : '' - }; - target := ${this.serializeReference(relationship.target)}${ - relationship.targetAttribute ? ' with ' + this.serializeReference(relationship.targetAttribute) : '' - }; - type := ${relationship.type}; - properties { - ${this.serializeProperties(relationship.properties)} - } - }`; + private serializeObject(obj: Record, indentationLevel: number): string { + const indentation = CrossModelSerializer.CHAR_INDENTATION.repeat(indentationLevel); + + const serializedProperties = Object.entries(obj) + .sort((left, right) => PROPERTY_ORDER.indexOf(left[0]) - PROPERTY_ORDER.indexOf(right[0])) + .map(([key, value]) => { + if (Array.isArray(value) && value.length === 0) { + return; + } + + const serializedValue = this.serializeValue(value, indentationLevel); + + // TODO Refactor CrossModel language so key is same as property name. Then the following lines can be removed. + if (key === 'name_val') { + key = 'name'; + } else if (key === 'name') { + key = 'id'; + } + + if (typeof value === 'object') { + return `${indentation}${key}:${CrossModelSerializer.CHAR_NEWLINE}${serializedValue}`; + } else { + return `${indentation}${key}: ${serializedValue}`; + } + }) + .filter(item => item !== undefined); + + return serializedProperties.join(CrossModelSerializer.CHAR_NEWLINE); } - private serializeProperties(properties: Property[] | undefined): string { - return properties && Array.isArray(properties) - ? properties.map(property => `${property.key} := ${property.value}`).join(';\n') - : ''; + private serializeArray(arr: any[], indentationLevel: number): string { + const serializedItems = arr + .map(item => this.serializeValue(item, indentationLevel)) + .map(item => this.changeCharInString(item, indentationLevel + CrossModelSerializer.INDENTATION_AMOUNT_ARRAY, '-')) + .join(CrossModelSerializer.CHAR_NEWLINE); + return serializedItems; } - protected serializeReference(reference: Reference | string | undefined): string { - if (reference === undefined) { - return ''; + private changeCharInString(inputString: string, indexToChange: number, newChar: any): string { + if (indexToChange < 0 || indexToChange >= inputString.length) { + throw Error('invalid'); } - return isReference(reference) ? reference.$refText : reference; - } - protected serializeDiagram(diagram: SystemDiagram): string { - return `diagram { - ${diagram.nodes.map(node => this.serializeDiagramNode(node)).join('\n')} - ${diagram.edges.map(edge => this.serializeDiagramEdge(edge)).join('\n')} - }`; + const modifiedString = inputString.slice(0, indexToChange) + newChar + inputString.slice(indexToChange + 1); + return modifiedString; } - protected serializeDiagramNode(node: DiagramNode): string { - return `node ${node.name} for ${node.semanticElement.$refText} { - x := ${node.x}; - y := ${node.y}; - width := ${node.width}; - height := ${node.height}; - };`; + /** + * Cleans the semantic object of any property that cannot be serialized as a String and thus cannot be sent to the client + * over the RPC connection. + * + * @param obj semantic object + * @returns serializable semantic object + */ + toSerializableObject(obj: T): T { + return Object.entries(obj) + .filter(([key, value]) => !key.startsWith('$')) + .reduce((acc, [key, value]) => ({ ...acc, [key]: this.cleanValue(value) }), {}); } - protected serializeDiagramEdge(edge: DiagramEdge): string { - return `edge ${edge.name} for ${edge.semanticElement.$refText} { - source := ${edge.source.$refText}; - target := ${edge.target.$refText}; - };`; - } - - asDiagram(element: SystemDiagram | Entity | Relationship | CrossModelRoot): string { - if (isCrossModelRoot(element)) { - return element.entity - ? this.asDiagram(element.entity) - : element.relationship - ? this.asDiagram(element.relationship) - : element.diagram - ? this.asDiagram(element.diagram) - : 'diagram { }'; - } - if (isSystemDiagram(element)) { - return this.serializeDiagram(element); - } - if (isEntity(element)) { - return `diagram { - ${this.asDiagramNode(element)}; - }`; + cleanValue(value: any): any { + if (Array.isArray(value)) { + return value.map(item => this.cleanValue(item)); + } else if (this.isContainedObject(value)) { + return this.toSerializableObject(value); + } else { + return this.resolvedValue(value); } - if (!element.source.ref || !element.target.ref) { - return 'diagram { }'; - } - return `diagram { - ${this.asDiagramNode(element.source.ref)}; - ${this.asDiagramNode(element.target.ref)}; - ${this.asDiagramEdge(element)} - }`; - } - - protected toDiagramName(element: Entity | Relationship): string { - return isEntity(element) ? element.name + '_node' : element.name + '_edge'; } - protected asDiagramNode(entity: Entity): string { - return `node ${this.toDiagramName(entity)} for ${this.refNameProvider.getName(entity)} { - x := 0; - y := 0; - width := 100; - height := 100; - }`; + isContainedObject(value: any): boolean { + return value === Object(value) && !isReference(value); } - protected asDiagramEdge(relationship: Relationship): string { - return `edge ${this.toDiagramName(relationship)} for ${this.refNameProvider.getName(relationship)} { - source := ${this.toDiagramName(relationship.source.ref!)}; - target := ${this.toDiagramName(relationship.target.ref!)}; - }`; + resolvedValue(value: any): any { + if (isReference(value)) { + return value.$refText; + } + return value; } } diff --git a/extensions/crossmodel-lang/src/language-server/cross-model-validator.ts b/extensions/crossmodel-lang/src/language-server/cross-model-validator.ts index 9a931195..ae1b90ad 100644 --- a/extensions/crossmodel-lang/src/language-server/cross-model-validator.ts +++ b/extensions/crossmodel-lang/src/language-server/cross-model-validator.ts @@ -3,44 +3,59 @@ ********************************************************************************/ import { ValidationAcceptor, ValidationChecks } from 'langium'; import type { CrossModelServices } from './cross-model-module'; -import { CrossModelAstType, Entity, Relationship } from './generated/ast'; +import { CrossModelAstType, DiagramEdge, Entity, EntityAttribute, Relationship, SystemDiagram } from './generated/ast'; /** * Register custom validation checks. */ export function registerValidationChecks(services: CrossModelServices): void { - const registry = services.validation.ValidationRegistry; - const validator = services.validation.CrossModelValidator; - const checks: ValidationChecks = { - Entity: validator.checkEntityStartsWithCapital, - Relationship: validator.checkRelationshipAttributes - }; - registry.register(checks, validator); + const registry = services.validation.ValidationRegistry; + const validator = services.validation.CrossModelValidator; + + const checks: ValidationChecks = { + Entity: validator.checkEntityHasNecessaryFields, + EntityAttribute: validator.checkAttributeHasNecessaryFields, + SystemDiagram: validator.checkSystemDiagramHasNecessaryFields, + Relationship: validator.checkRelationshipHasNecessaryFields, + DiagramEdge: validator.checkDiagramEdge + }; + registry.register(checks, validator); } /** * Implementation of custom validations. */ export class CrossModelValidator { - checkEntityStartsWithCapital(entity: Entity, accept: ValidationAcceptor): void { - if (entity.name) { - const firstChar = entity.name.substring(0, 1); - if (firstChar.toUpperCase() !== firstChar) { - accept('warning', 'Entity name should start with a capital.', { node: entity, property: 'name' }); - } - } - } + checkSystemDiagramHasNecessaryFields(system: SystemDiagram, accept: ValidationAcceptor): void { + if (!system.name) { + accept('error', 'Systemdiagram missing id field', { node: system, property: 'name' }); + } + } + + checkEntityHasNecessaryFields(entity: Entity, accept: ValidationAcceptor): void { + if (!entity.name) { + accept('error', 'Entity missing id field', { node: entity, property: 'name' }); + } + } + + checkAttributeHasNecessaryFields(attribute: EntityAttribute, accept: ValidationAcceptor): void { + if (!attribute.name) { + accept('error', 'Attribute missing id field', { node: attribute, property: 'name' }); + } + } + + checkRelationshipHasNecessaryFields(relationship: Relationship, accept: ValidationAcceptor): void { + if (!relationship.name) { + accept('error', 'Attribute missing id field', { node: relationship, property: 'name' }); + } + } - checkRelationshipAttributes(relationship: Relationship, accept: ValidationAcceptor): void { - if (relationship.sourceAttribute) { - if (relationship.sourceAttribute.ref?.$container !== relationship.source.ref) { - accept('error', 'Source attribute must come from source entity.', { node: relationship, property: 'sourceAttribute' }); - } - } - if (relationship.targetAttribute) { - if (relationship.targetAttribute.ref?.$container !== relationship.target.ref) { - accept('error', 'Target attribute must come from target entity.', { node: relationship, property: 'targetAttribute' }); - } - } - } + checkDiagramEdge(edge: DiagramEdge, accept: ValidationAcceptor): void { + if (edge.sourceNode?.ref?.entity?.ref?.$type !== edge.relationship?.ref?.parent?.ref?.$type) { + accept('error', 'Source must match type of parent', { node: edge, property: 'sourceNode' }); + } + if (edge.targetNode?.ref?.entity?.ref?.$type !== edge.relationship?.ref?.child?.ref?.$type) { + accept('error', 'Target must match type of child', { node: edge, property: 'targetNode' }); + } + } } diff --git a/extensions/crossmodel-lang/src/language-server/cross-model.langium b/extensions/crossmodel-lang/src/language-server/cross-model.langium index cbe368d8..224bdb88 100644 --- a/extensions/crossmodel-lang/src/language-server/cross-model.langium +++ b/extensions/crossmodel-lang/src/language-server/cross-model.langium @@ -1,66 +1,141 @@ grammar CrossModel entry CrossModelRoot: - (entity=Entity | relationship=Relationship | diagram=SystemDiagram); + (entity=Entity | + relationship=Relationship | + diagram=SystemDiagram)?; +// Entity definition Entity: - 'entity' name=ID '{' - 'description' ':=' description=STRING ';' - ('attributes' '{' (attributes+=Attribute + ';')* '}' )? - '}' - ; + 'entity' ':' + ( + INDENT + EntityFields* + DEDENT + )* +; -Attribute: - name=ID ':=' value=(STRING | NUMBER); +EntityFields infers Entity: + ( + 'id' ':' name=STRING | + 'name' ':' name_val=STRING | + 'description' ':' description=STRING | + 'attributes' ':' EntityAttributes + ) +; -Relationship: - 'relationship' name=ID '{' - 'source' ':=' source=[Entity:QualifiedName] ('with' sourceAttribute=[Attribute:QualifiedName])? ';' - 'target' ':=' target=[Entity:QualifiedName] ('with' targetAttribute=[Attribute:QualifiedName])? ';' - 'type' ':=' type=RelationshipType ';' - ('properties' '{' (properties+=Property + ';')* '}' )? - '}' - ; +EntityAttributes infers Entity: + INDENT + (attributes+=EntityAttribute)* + DEDENT; + +EntityAttribute: + '-' EntityAttributeFields* +; -Property: - key=ID ':=' value=(STRING | NUMBER); +EntityAttributeFields infers EntityAttribute: + ( + 'id' ':' name=STRING | + 'name' ':' name_val=STRING | + 'datatype' ':' datatype=STRING | + 'description' ':' description=STRING + ); -RelationshipType returns string: - '1:1' | '1:n' | 'n:1' | 'n:m'; +// Relationship defintion +Relationship: + 'relationship' ':' + ( + INDENT + RelationshipFields* + DEDENT + )* +; +RelationshipFields infers Relationship: + ( + 'id' ':' name=STRING | + 'name' ':' name_val=STRING | + 'description' ':' description=STRING | + 'parent' ':' parent=[Entity:QualifiedName] | + 'child' ':' child=[Entity:QualifiedName] | + 'type' ':' type=STRING + ) +; +// Diagram defintion SystemDiagram: - 'diagram' '{' - (nodes+=DiagramNode ';')* - (edges+=DiagramEdge ';')* - '}' - ; + 'diagram' ':' + ( + INDENT + SystemDiagramFields* + DEDENT + )* +; + +SystemDiagramFields infers SystemDiagram: + ( + 'nodes' ':' SystemDiagramNodes | + 'edges' ':' SystemDiagramEdge | + 'id' ':' name=STRING | + 'description' ':' description=STRING | + 'name' ':' name_val=STRING + ) +; + +SystemDiagramNodes infers SystemDiagram: + INDENT + (nodes+=DiagramNode)* + DEDENT +; DiagramNode: - 'node' name=ID 'for' semanticElement=[Entity:QualifiedName] '{' - 'x' ':=' x=NUMBER ';' - 'y' ':=' y=NUMBER ';' - 'width' ':=' width=NUMBER ';' - 'height' ':=' height=NUMBER ';' - '}' - ; + '-' DiagramNodeFields* +; + +DiagramNodeFields infers DiagramNode: + 'id' ':' name=STRING | + 'entity' ':' entity=[Entity:QualifiedName] | + 'x' ':' x=NUMBER | + 'y' ':' y=NUMBER | + 'width' ':' width=NUMBER | + 'height' ':' height=NUMBER | + 'name' ':' name_val=STRING | + 'description' ':' description=STRING +; + +SystemDiagramEdge infers SystemDiagram: + INDENT + (edges+=DiagramEdge)* + DEDENT +; DiagramEdge: - 'edge' name=ID 'for' semanticElement=[Relationship:QualifiedName] '{' - 'source' ':=' source=[DiagramNode:QualifiedName] ';' - 'target' ':=' target=[DiagramNode:QualifiedName] ';' - // for non-straight connections we also need to save routing points - '}' - ; + '-' DiagramEdgeFields* +; +DiagramEdgeFields infers DiagramEdge: + ( + 'relationship' ':' relationship=[Relationship:QualifiedName] | + 'sourceNode' ':' sourceNode=[DiagramNode:QualifiedName] | + 'targetNode' ':' targetNode=[DiagramNode:QualifiedName] | + 'id' ':' name=STRING + ) +; +// Identification QualifiedName returns string: - ID ('.' ID)*; + STRING ('.' STRING)*; -hidden terminal WS: /\s+/; -terminal ID: /[_a-zA-Z@][\w_\-@\/#]*/; +// Scalar values terminal STRING: /"[^"]*"|'[^']*'/; terminal NUMBER returns number: /(-)?[0-9]+(\.[0-9]*)?/; -hidden terminal ML_COMMENT: /\/\*[\s\S]*?\*\//; -hidden terminal SL_COMMENT: /\/\/[^\n\r]*/; +// Misc +hidden terminal SL_COMMENT: /#[^\n\r]*/; + +// Terminals to get the indentation working +hidden terminal NEWLINE: 'this_string_does_not_matter_newline#$%^&*(('; +terminal DEDENT: 'this_string_does_not_matter_dedent#$%^&*(('; +terminal INDENT: 'this_string_does_not_matter_indent#$%^&*(('; +hidden terminal SPACES: 'this_string_does_not_matter_spaces#$%^&*(('; + diff --git a/extensions/crossmodel-lang/src/language-server/generated/ast.ts b/extensions/crossmodel-lang/src/language-server/generated/ast.ts index ccf38e50..ed39a285 100644 --- a/extensions/crossmodel-lang/src/language-server/generated/ast.ts +++ b/extensions/crossmodel-lang/src/language-server/generated/ast.ts @@ -1,5 +1,5 @@ /****************************************************************************** - * This file was generated by langium-cli 1.2.1. + * This file was generated by langium-cli 1.3.1. * DO NOT EDIT MANUALLY! ******************************************************************************/ @@ -7,31 +7,22 @@ import type { AstNode, Reference, ReferenceInfo, TypeMetaData } from 'langium'; import { AbstractAstReflection } from 'langium'; +export const CrossModelTerminals = { + STRING: /"[^"]*"|'[^']*'/, + NUMBER: /(-)?[0-9]+(\.[0-9]*)?/, + SL_COMMENT: /#[^\n\r]*/, + NEWLINE: /this_string_does_not_matter_newline#\$%\^&\*\(\(/, + DEDENT: /this_string_does_not_matter_dedent#\$%\^&\*\(\(/, + INDENT: /this_string_does_not_matter_indent#\$%\^&\*\(\(/, + SPACES: /this_string_does_not_matter_spaces#\$%\^&\*\(\(/, +}; + export type QualifiedName = string; export function isQualifiedName(item: unknown): item is QualifiedName { return typeof item === 'string'; } -export type RelationshipType = '1:1' | '1:n' | 'n:1' | 'n:m'; - -export function isRelationshipType(item: unknown): item is RelationshipType { - return item === '1:1' || item === '1:n' || item === 'n:1' || item === 'n:m'; -} - -export interface Attribute extends AstNode { - readonly $container: Entity; - readonly $type: 'Attribute'; - name: string - value: number | string -} - -export const Attribute = 'Attribute'; - -export function isAttribute(item: unknown): item is Attribute { - return reflection.isInstance(item, Attribute); -} - export interface CrossModelRoot extends AstNode { readonly $type: 'CrossModelRoot'; diagram?: SystemDiagram @@ -48,10 +39,10 @@ export function isCrossModelRoot(item: unknown): item is CrossModelRoot { export interface DiagramEdge extends AstNode { readonly $container: SystemDiagram; readonly $type: 'DiagramEdge'; - name: string - semanticElement: Reference - source: Reference - target: Reference + name?: string + relationship?: Reference + sourceNode?: Reference + targetNode?: Reference } export const DiagramEdge = 'DiagramEdge'; @@ -63,12 +54,14 @@ export function isDiagramEdge(item: unknown): item is DiagramEdge { export interface DiagramNode extends AstNode { readonly $container: SystemDiagram; readonly $type: 'DiagramNode'; - height: number - name: string - semanticElement: Reference - width: number - x: number - y: number + description?: string + entity?: Reference + height?: number + name?: string + name_val?: string + width?: number + x?: number + y?: number } export const DiagramNode = 'DiagramNode'; @@ -80,9 +73,10 @@ export function isDiagramNode(item: unknown): item is DiagramNode { export interface Entity extends AstNode { readonly $container: CrossModelRoot; readonly $type: 'Entity'; - attributes: Array - description: string - name: string + attributes: Array + description?: string + name?: string + name_val?: string } export const Entity = 'Entity'; @@ -91,29 +85,30 @@ export function isEntity(item: unknown): item is Entity { return reflection.isInstance(item, Entity); } -export interface Property extends AstNode { - readonly $container: Relationship; - readonly $type: 'Property'; - key: string - value: number | string +export interface EntityAttribute extends AstNode { + readonly $container: Entity; + readonly $type: 'EntityAttribute'; + datatype?: string + description?: string + name?: string + name_val?: string } -export const Property = 'Property'; +export const EntityAttribute = 'EntityAttribute'; -export function isProperty(item: unknown): item is Property { - return reflection.isInstance(item, Property); +export function isEntityAttribute(item: unknown): item is EntityAttribute { + return reflection.isInstance(item, EntityAttribute); } export interface Relationship extends AstNode { readonly $container: CrossModelRoot; readonly $type: 'Relationship'; - name: string - properties: Array - source: Reference - sourceAttribute?: Reference - target: Reference - targetAttribute?: Reference - type: RelationshipType + child?: Reference + description?: string + name?: string + name_val?: string + parent?: Reference + type?: string } export const Relationship = 'Relationship'; @@ -125,7 +120,10 @@ export function isRelationship(item: unknown): item is Relationship { export interface SystemDiagram extends AstNode { readonly $container: CrossModelRoot; readonly $type: 'SystemDiagram'; + description?: string edges: Array + name?: string + name_val?: string nodes: Array } @@ -136,12 +134,11 @@ export function isSystemDiagram(item: unknown): item is SystemDiagram { } export type CrossModelAstType = { - Attribute: Attribute CrossModelRoot: CrossModelRoot DiagramEdge: DiagramEdge DiagramNode: DiagramNode Entity: Entity - Property: Property + EntityAttribute: EntityAttribute Relationship: Relationship SystemDiagram: SystemDiagram } @@ -149,7 +146,7 @@ export type CrossModelAstType = { export class CrossModelAstReflection extends AbstractAstReflection { getAllTypes(): string[] { - return ['Attribute', 'CrossModelRoot', 'DiagramEdge', 'DiagramNode', 'Entity', 'Property', 'Relationship', 'SystemDiagram']; + return ['CrossModelRoot', 'DiagramEdge', 'DiagramNode', 'Entity', 'EntityAttribute', 'Relationship', 'SystemDiagram']; } protected override computeIsSubtype(subtype: string, supertype: string): boolean { @@ -163,22 +160,18 @@ export class CrossModelAstReflection extends AbstractAstReflection { getReferenceType(refInfo: ReferenceInfo): string { const referenceId = `${refInfo.container.$type}:${refInfo.property}`; switch (referenceId) { - case 'DiagramEdge:semanticElement': { + case 'DiagramEdge:relationship': { return Relationship; } - case 'DiagramEdge:source': - case 'DiagramEdge:target': { + case 'DiagramEdge:sourceNode': + case 'DiagramEdge:targetNode': { return DiagramNode; } - case 'DiagramNode:semanticElement': - case 'Relationship:source': - case 'Relationship:target': { + case 'DiagramNode:entity': + case 'Relationship:child': + case 'Relationship:parent': { return Entity; } - case 'Relationship:sourceAttribute': - case 'Relationship:targetAttribute': { - return Attribute; - } default: { throw new Error(`${referenceId} is not a valid reference id.`); } @@ -195,14 +188,6 @@ export class CrossModelAstReflection extends AbstractAstReflection { ] }; } - case 'Relationship': { - return { - name: 'Relationship', - mandatory: [ - { name: 'properties', type: 'array' } - ] - }; - } case 'SystemDiagram': { return { name: 'SystemDiagram', diff --git a/extensions/crossmodel-lang/src/language-server/generated/grammar.ts b/extensions/crossmodel-lang/src/language-server/generated/grammar.ts index d196ca20..c01f174b 100644 --- a/extensions/crossmodel-lang/src/language-server/generated/grammar.ts +++ b/extensions/crossmodel-lang/src/language-server/generated/grammar.ts @@ -1,5 +1,5 @@ /****************************************************************************** - * This file was generated by langium-cli 1.2.1. + * This file was generated by langium-cli 1.3.1. * DO NOT EDIT MANUALLY! ******************************************************************************/ @@ -38,7 +38,7 @@ export const CrossModelGrammar = (): Grammar => loadedCrossModelGrammar ?? (load "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@3" + "$ref": "#/rules@6" }, "arguments": [] } @@ -50,12 +50,13 @@ export const CrossModelGrammar = (): Grammar => loadedCrossModelGrammar ?? (load "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@6" + "$ref": "#/rules@8" }, "arguments": [] } } - ] + ], + "cardinality": "?" }, "definesHiddenTokens": false, "fragment": false, @@ -73,90 +74,37 @@ export const CrossModelGrammar = (): Grammar => loadedCrossModelGrammar ?? (load "$type": "Keyword", "value": "entity" }, - { - "$type": "Assignment", - "feature": "name", - "operator": "=", - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@11" - }, - "arguments": [] - } - }, - { - "$type": "Keyword", - "value": "{" - }, - { - "$type": "Keyword", - "value": "description" - }, - { - "$type": "Keyword", - "value": ":=" - }, - { - "$type": "Assignment", - "feature": "description", - "operator": "=", - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@12" - }, - "arguments": [] - } - }, { "$type": "Keyword", - "value": ";" + "value": ":" }, { "$type": "Group", "elements": [ { - "$type": "Keyword", - "value": "attributes" - }, - { - "$type": "Keyword", - "value": "{" + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@22" + }, + "arguments": [] }, { - "$type": "Group", - "elements": [ - { - "$type": "Assignment", - "feature": "attributes", - "operator": "+=", - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@2" - }, - "arguments": [] - }, - "cardinality": "+" - }, - { - "$type": "Keyword", - "value": ";" - } - ], + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@2" + }, + "arguments": [], "cardinality": "*" }, { - "$type": "Keyword", - "value": "}" + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@21" + }, + "arguments": [] } ], - "cardinality": "?" - }, - { - "$type": "Keyword", - "value": "}" + "cardinality": "*" } ] }, @@ -169,273 +117,108 @@ export const CrossModelGrammar = (): Grammar => loadedCrossModelGrammar ?? (load }, { "$type": "ParserRule", - "name": "Attribute", + "name": "EntityFields", + "inferredType": { + "$type": "InferredType", + "name": "Entity" + }, "definition": { - "$type": "Group", + "$type": "Alternatives", "elements": [ { - "$type": "Assignment", - "feature": "name", - "operator": "=", - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@11" + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "id" }, - "arguments": [] - } - }, - { - "$type": "Keyword", - "value": ":=" - }, - { - "$type": "Assignment", - "feature": "value", - "operator": "=", - "terminal": { - "$type": "Alternatives", - "elements": [ - { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@12" - }, - "arguments": [] - }, - { + { + "$type": "Keyword", + "value": ":" + }, + { + "$type": "Assignment", + "feature": "name", + "operator": "=", + "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@13" + "$ref": "#/rules@17" }, "arguments": [] } - ] - } - } - ] - }, - "definesHiddenTokens": false, - "entry": false, - "fragment": false, - "hiddenTokens": [], - "parameters": [], - "wildcard": false - }, - { - "$type": "ParserRule", - "name": "Relationship", - "definition": { - "$type": "Group", - "elements": [ - { - "$type": "Keyword", - "value": "relationship" - }, - { - "$type": "Assignment", - "feature": "name", - "operator": "=", - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@11" - }, - "arguments": [] - } - }, - { - "$type": "Keyword", - "value": "{" - }, - { - "$type": "Keyword", - "value": "source" - }, - { - "$type": "Keyword", - "value": ":=" - }, - { - "$type": "Assignment", - "feature": "source", - "operator": "=", - "terminal": { - "$type": "CrossReference", - "type": { - "$ref": "#/rules@1" - }, - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@9" - }, - "arguments": [] - }, - "deprecatedSyntax": false - } + } + ] }, { "$type": "Group", "elements": [ { "$type": "Keyword", - "value": "with" + "value": "name" + }, + { + "$type": "Keyword", + "value": ":" }, { "$type": "Assignment", - "feature": "sourceAttribute", + "feature": "name_val", "operator": "=", "terminal": { - "$type": "CrossReference", - "type": { - "$ref": "#/rules@2" - }, - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@9" - }, - "arguments": [] + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@17" }, - "deprecatedSyntax": false + "arguments": [] } } - ], - "cardinality": "?" - }, - { - "$type": "Keyword", - "value": ";" - }, - { - "$type": "Keyword", - "value": "target" - }, - { - "$type": "Keyword", - "value": ":=" - }, - { - "$type": "Assignment", - "feature": "target", - "operator": "=", - "terminal": { - "$type": "CrossReference", - "type": { - "$ref": "#/rules@1" - }, - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@9" - }, - "arguments": [] - }, - "deprecatedSyntax": false - } + ] }, { "$type": "Group", "elements": [ { "$type": "Keyword", - "value": "with" + "value": "description" + }, + { + "$type": "Keyword", + "value": ":" }, { "$type": "Assignment", - "feature": "targetAttribute", + "feature": "description", "operator": "=", "terminal": { - "$type": "CrossReference", - "type": { - "$ref": "#/rules@2" - }, - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@9" - }, - "arguments": [] + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@17" }, - "deprecatedSyntax": false + "arguments": [] } } - ], - "cardinality": "?" - }, - { - "$type": "Keyword", - "value": ";" - }, - { - "$type": "Keyword", - "value": "type" - }, - { - "$type": "Keyword", - "value": ":=" - }, - { - "$type": "Assignment", - "feature": "type", - "operator": "=", - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@5" - }, - "arguments": [] - } - }, - { - "$type": "Keyword", - "value": ";" + ] }, { "$type": "Group", "elements": [ { "$type": "Keyword", - "value": "properties" + "value": "attributes" }, { "$type": "Keyword", - "value": "{" + "value": ":" }, { - "$type": "Group", - "elements": [ - { - "$type": "Assignment", - "feature": "properties", - "operator": "+=", - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@4" - }, - "arguments": [] - }, - "cardinality": "+" - }, - { - "$type": "Keyword", - "value": ";" - } - ], - "cardinality": "*" - }, - { - "$type": "Keyword", - "value": "}" + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@3" + }, + "arguments": [] } - ], - "cardinality": "?" - }, - { - "$type": "Keyword", - "value": "}" + ] } ] }, @@ -448,49 +231,40 @@ export const CrossModelGrammar = (): Grammar => loadedCrossModelGrammar ?? (load }, { "$type": "ParserRule", - "name": "Property", + "name": "EntityAttributes", + "inferredType": { + "$type": "InferredType", + "name": "Entity" + }, "definition": { "$type": "Group", "elements": [ + { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@22" + }, + "arguments": [] + }, { "$type": "Assignment", - "feature": "key", - "operator": "=", + "feature": "attributes", + "operator": "+=", "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@11" + "$ref": "#/rules@4" }, "arguments": [] - } - }, - { - "$type": "Keyword", - "value": ":=" + }, + "cardinality": "*" }, { - "$type": "Assignment", - "feature": "value", - "operator": "=", - "terminal": { - "$type": "Alternatives", - "elements": [ - { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@12" - }, - "arguments": [] - }, - { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@13" - }, - "arguments": [] - } - ] - } + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@21" + }, + "arguments": [] } ] }, @@ -503,26 +277,21 @@ export const CrossModelGrammar = (): Grammar => loadedCrossModelGrammar ?? (load }, { "$type": "ParserRule", - "name": "RelationshipType", - "dataType": "string", + "name": "EntityAttribute", "definition": { - "$type": "Alternatives", + "$type": "Group", "elements": [ { "$type": "Keyword", - "value": "1:1" - }, - { - "$type": "Keyword", - "value": "1:n" - }, - { - "$type": "Keyword", - "value": "n:1" + "value": "-" }, { - "$type": "Keyword", - "value": "n:m" + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@5" + }, + "arguments": [], + "cardinality": "*" } ] }, @@ -535,65 +304,113 @@ export const CrossModelGrammar = (): Grammar => loadedCrossModelGrammar ?? (load }, { "$type": "ParserRule", - "name": "SystemDiagram", + "name": "EntityAttributeFields", + "inferredType": { + "$type": "InferredType", + "name": "EntityAttribute" + }, "definition": { - "$type": "Group", + "$type": "Alternatives", "elements": [ - { - "$type": "Keyword", - "value": "diagram" - }, - { - "$type": "Keyword", - "value": "{" - }, { "$type": "Group", "elements": [ + { + "$type": "Keyword", + "value": "id" + }, + { + "$type": "Keyword", + "value": ":" + }, { "$type": "Assignment", - "feature": "nodes", - "operator": "+=", + "feature": "name", + "operator": "=", "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@7" + "$ref": "#/rules@17" }, "arguments": [] } + } + ] + }, + { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "name" }, { "$type": "Keyword", - "value": ";" + "value": ":" + }, + { + "$type": "Assignment", + "feature": "name_val", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@17" + }, + "arguments": [] + } } - ], - "cardinality": "*" + ] }, { "$type": "Group", "elements": [ + { + "$type": "Keyword", + "value": "datatype" + }, + { + "$type": "Keyword", + "value": ":" + }, { "$type": "Assignment", - "feature": "edges", - "operator": "+=", + "feature": "datatype", + "operator": "=", "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@8" + "$ref": "#/rules@17" }, "arguments": [] } + } + ] + }, + { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "description" }, { "$type": "Keyword", - "value": ";" + "value": ":" + }, + { + "$type": "Assignment", + "feature": "description", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@17" + }, + "arguments": [] + } } - ], - "cardinality": "*" - }, - { - "$type": "Keyword", - "value": "}" + ] } ] }, @@ -606,152 +423,758 @@ export const CrossModelGrammar = (): Grammar => loadedCrossModelGrammar ?? (load }, { "$type": "ParserRule", - "name": "DiagramNode", + "name": "Relationship", "definition": { "$type": "Group", "elements": [ { "$type": "Keyword", - "value": "node" - }, - { - "$type": "Assignment", - "feature": "name", - "operator": "=", - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@11" - }, - "arguments": [] - } + "value": "relationship" }, { "$type": "Keyword", - "value": "for" + "value": ":" }, { - "$type": "Assignment", - "feature": "semanticElement", - "operator": "=", - "terminal": { - "$type": "CrossReference", - "type": { - "$ref": "#/rules@1" - }, - "terminal": { + "$type": "Group", + "elements": [ + { "$type": "RuleCall", "rule": { - "$ref": "#/rules@9" + "$ref": "#/rules@22" }, "arguments": [] }, - "deprecatedSyntax": false - } - }, - { - "$type": "Keyword", - "value": "{" - }, - { - "$type": "Keyword", - "value": "x" - }, - { - "$type": "Keyword", - "value": ":=" - }, - { - "$type": "Assignment", - "feature": "x", - "operator": "=", - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@13" + { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@7" + }, + "arguments": [], + "cardinality": "*" }, - "arguments": [] - } - }, + { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@21" + }, + "arguments": [] + } + ], + "cardinality": "*" + } + ] + }, + "definesHiddenTokens": false, + "entry": false, + "fragment": false, + "hiddenTokens": [], + "parameters": [], + "wildcard": false + }, + { + "$type": "ParserRule", + "name": "RelationshipFields", + "inferredType": { + "$type": "InferredType", + "name": "Relationship" + }, + "definition": { + "$type": "Alternatives", + "elements": [ { - "$type": "Keyword", - "value": ";" + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "id" + }, + { + "$type": "Keyword", + "value": ":" + }, + { + "$type": "Assignment", + "feature": "name", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@17" + }, + "arguments": [] + } + } + ] }, { - "$type": "Keyword", - "value": "y" + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "name" + }, + { + "$type": "Keyword", + "value": ":" + }, + { + "$type": "Assignment", + "feature": "name_val", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@17" + }, + "arguments": [] + } + } + ] }, { - "$type": "Keyword", - "value": ":=" + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "description" + }, + { + "$type": "Keyword", + "value": ":" + }, + { + "$type": "Assignment", + "feature": "description", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@17" + }, + "arguments": [] + } + } + ] }, { - "$type": "Assignment", - "feature": "y", - "operator": "=", - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@13" + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "parent" }, - "arguments": [] - } + { + "$type": "Keyword", + "value": ":" + }, + { + "$type": "Assignment", + "feature": "parent", + "operator": "=", + "terminal": { + "$type": "CrossReference", + "type": { + "$ref": "#/rules@1" + }, + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@16" + }, + "arguments": [] + }, + "deprecatedSyntax": false + } + } + ] }, { - "$type": "Keyword", - "value": ";" - }, + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "child" + }, + { + "$type": "Keyword", + "value": ":" + }, + { + "$type": "Assignment", + "feature": "child", + "operator": "=", + "terminal": { + "$type": "CrossReference", + "type": { + "$ref": "#/rules@1" + }, + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@16" + }, + "arguments": [] + }, + "deprecatedSyntax": false + } + } + ] + }, + { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "type" + }, + { + "$type": "Keyword", + "value": ":" + }, + { + "$type": "Assignment", + "feature": "type", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@17" + }, + "arguments": [] + } + } + ] + } + ] + }, + "definesHiddenTokens": false, + "entry": false, + "fragment": false, + "hiddenTokens": [], + "parameters": [], + "wildcard": false + }, + { + "$type": "ParserRule", + "name": "SystemDiagram", + "definition": { + "$type": "Group", + "elements": [ { "$type": "Keyword", - "value": "width" + "value": "diagram" }, { "$type": "Keyword", - "value": ":=" + "value": ":" + }, + { + "$type": "Group", + "elements": [ + { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@22" + }, + "arguments": [] + }, + { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@9" + }, + "arguments": [], + "cardinality": "*" + }, + { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@21" + }, + "arguments": [] + } + ], + "cardinality": "*" + } + ] + }, + "definesHiddenTokens": false, + "entry": false, + "fragment": false, + "hiddenTokens": [], + "parameters": [], + "wildcard": false + }, + { + "$type": "ParserRule", + "name": "SystemDiagramFields", + "inferredType": { + "$type": "InferredType", + "name": "SystemDiagram" + }, + "definition": { + "$type": "Alternatives", + "elements": [ + { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "nodes" + }, + { + "$type": "Keyword", + "value": ":" + }, + { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@10" + }, + "arguments": [] + } + ] + }, + { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "edges" + }, + { + "$type": "Keyword", + "value": ":" + }, + { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@13" + }, + "arguments": [] + } + ] + }, + { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "id" + }, + { + "$type": "Keyword", + "value": ":" + }, + { + "$type": "Assignment", + "feature": "name", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@17" + }, + "arguments": [] + } + } + ] + }, + { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "description" + }, + { + "$type": "Keyword", + "value": ":" + }, + { + "$type": "Assignment", + "feature": "description", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@17" + }, + "arguments": [] + } + } + ] + }, + { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "name" + }, + { + "$type": "Keyword", + "value": ":" + }, + { + "$type": "Assignment", + "feature": "name_val", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@17" + }, + "arguments": [] + } + } + ] + } + ] + }, + "definesHiddenTokens": false, + "entry": false, + "fragment": false, + "hiddenTokens": [], + "parameters": [], + "wildcard": false + }, + { + "$type": "ParserRule", + "name": "SystemDiagramNodes", + "inferredType": { + "$type": "InferredType", + "name": "SystemDiagram" + }, + "definition": { + "$type": "Group", + "elements": [ + { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@22" + }, + "arguments": [] }, { "$type": "Assignment", - "feature": "width", - "operator": "=", + "feature": "nodes", + "operator": "+=", "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@13" + "$ref": "#/rules@11" }, "arguments": [] - } + }, + "cardinality": "*" }, + { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@21" + }, + "arguments": [] + } + ] + }, + "definesHiddenTokens": false, + "entry": false, + "fragment": false, + "hiddenTokens": [], + "parameters": [], + "wildcard": false + }, + { + "$type": "ParserRule", + "name": "DiagramNode", + "definition": { + "$type": "Group", + "elements": [ { "$type": "Keyword", - "value": ";" + "value": "-" + }, + { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@12" + }, + "arguments": [], + "cardinality": "*" + } + ] + }, + "definesHiddenTokens": false, + "entry": false, + "fragment": false, + "hiddenTokens": [], + "parameters": [], + "wildcard": false + }, + { + "$type": "ParserRule", + "name": "DiagramNodeFields", + "inferredType": { + "$type": "InferredType", + "name": "DiagramNode" + }, + "definition": { + "$type": "Alternatives", + "elements": [ + { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "id" + }, + { + "$type": "Keyword", + "value": ":" + }, + { + "$type": "Assignment", + "feature": "name", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@17" + }, + "arguments": [] + } + } + ] + }, + { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "entity" + }, + { + "$type": "Keyword", + "value": ":" + }, + { + "$type": "Assignment", + "feature": "entity", + "operator": "=", + "terminal": { + "$type": "CrossReference", + "type": { + "$ref": "#/rules@1" + }, + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@16" + }, + "arguments": [] + }, + "deprecatedSyntax": false + } + } + ] + }, + { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "x" + }, + { + "$type": "Keyword", + "value": ":" + }, + { + "$type": "Assignment", + "feature": "x", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@18" + }, + "arguments": [] + } + } + ] + }, + { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "y" + }, + { + "$type": "Keyword", + "value": ":" + }, + { + "$type": "Assignment", + "feature": "y", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@18" + }, + "arguments": [] + } + } + ] + }, + { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "width" + }, + { + "$type": "Keyword", + "value": ":" + }, + { + "$type": "Assignment", + "feature": "width", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@18" + }, + "arguments": [] + } + } + ] + }, + { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "height" + }, + { + "$type": "Keyword", + "value": ":" + }, + { + "$type": "Assignment", + "feature": "height", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@18" + }, + "arguments": [] + } + } + ] + }, + { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "name" + }, + { + "$type": "Keyword", + "value": ":" + }, + { + "$type": "Assignment", + "feature": "name_val", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@17" + }, + "arguments": [] + } + } + ] }, { - "$type": "Keyword", - "value": "height" - }, + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "description" + }, + { + "$type": "Keyword", + "value": ":" + }, + { + "$type": "Assignment", + "feature": "description", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@17" + }, + "arguments": [] + } + } + ] + } + ] + }, + "definesHiddenTokens": false, + "entry": false, + "fragment": false, + "hiddenTokens": [], + "parameters": [], + "wildcard": false + }, + { + "$type": "ParserRule", + "name": "SystemDiagramEdge", + "inferredType": { + "$type": "InferredType", + "name": "SystemDiagram" + }, + "definition": { + "$type": "Group", + "elements": [ { - "$type": "Keyword", - "value": ":=" + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@22" + }, + "arguments": [] }, { "$type": "Assignment", - "feature": "height", - "operator": "=", + "feature": "edges", + "operator": "+=", "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@13" + "$ref": "#/rules@14" }, "arguments": [] - } - }, - { - "$type": "Keyword", - "value": ";" + }, + "cardinality": "*" }, { - "$type": "Keyword", - "value": "}" + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@21" + }, + "arguments": [] } ] }, @@ -770,112 +1193,155 @@ export const CrossModelGrammar = (): Grammar => loadedCrossModelGrammar ?? (load "elements": [ { "$type": "Keyword", - "value": "edge" - }, - { - "$type": "Assignment", - "feature": "name", - "operator": "=", - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@11" - }, - "arguments": [] - } + "value": "-" }, { - "$type": "Keyword", - "value": "for" - }, + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@15" + }, + "arguments": [], + "cardinality": "*" + } + ] + }, + "definesHiddenTokens": false, + "entry": false, + "fragment": false, + "hiddenTokens": [], + "parameters": [], + "wildcard": false + }, + { + "$type": "ParserRule", + "name": "DiagramEdgeFields", + "inferredType": { + "$type": "InferredType", + "name": "DiagramEdge" + }, + "definition": { + "$type": "Alternatives", + "elements": [ { - "$type": "Assignment", - "feature": "semanticElement", - "operator": "=", - "terminal": { - "$type": "CrossReference", - "type": { - "$ref": "#/rules@3" + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "relationship" }, - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@9" - }, - "arguments": [] + { + "$type": "Keyword", + "value": ":" }, - "deprecatedSyntax": false - } - }, - { - "$type": "Keyword", - "value": "{" - }, - { - "$type": "Keyword", - "value": "source" - }, - { - "$type": "Keyword", - "value": ":=" + { + "$type": "Assignment", + "feature": "relationship", + "operator": "=", + "terminal": { + "$type": "CrossReference", + "type": { + "$ref": "#/rules@6" + }, + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@16" + }, + "arguments": [] + }, + "deprecatedSyntax": false + } + } + ] }, { - "$type": "Assignment", - "feature": "source", - "operator": "=", - "terminal": { - "$type": "CrossReference", - "type": { - "$ref": "#/rules@7" + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "sourceNode" }, - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@9" - }, - "arguments": [] + { + "$type": "Keyword", + "value": ":" }, - "deprecatedSyntax": false - } - }, - { - "$type": "Keyword", - "value": ";" - }, - { - "$type": "Keyword", - "value": "target" - }, - { - "$type": "Keyword", - "value": ":=" + { + "$type": "Assignment", + "feature": "sourceNode", + "operator": "=", + "terminal": { + "$type": "CrossReference", + "type": { + "$ref": "#/rules@11" + }, + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@16" + }, + "arguments": [] + }, + "deprecatedSyntax": false + } + } + ] }, { - "$type": "Assignment", - "feature": "target", - "operator": "=", - "terminal": { - "$type": "CrossReference", - "type": { - "$ref": "#/rules@7" + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "targetNode" }, - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@9" - }, - "arguments": [] + { + "$type": "Keyword", + "value": ":" }, - "deprecatedSyntax": false - } - }, - { - "$type": "Keyword", - "value": ";" + { + "$type": "Assignment", + "feature": "targetNode", + "operator": "=", + "terminal": { + "$type": "CrossReference", + "type": { + "$ref": "#/rules@11" + }, + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@16" + }, + "arguments": [] + }, + "deprecatedSyntax": false + } + } + ] }, { - "$type": "Keyword", - "value": "}" + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "id" + }, + { + "$type": "Keyword", + "value": ":" + }, + { + "$type": "Assignment", + "feature": "name", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@17" + }, + "arguments": [] + } + } + ] } ] }, @@ -896,7 +1362,7 @@ export const CrossModelGrammar = (): Grammar => loadedCrossModelGrammar ?? (load { "$type": "RuleCall", "rule": { - "$ref": "#/rules@11" + "$ref": "#/rules@17" }, "arguments": [] }, @@ -910,7 +1376,7 @@ export const CrossModelGrammar = (): Grammar => loadedCrossModelGrammar ?? (load { "$type": "RuleCall", "rule": { - "$ref": "#/rules@11" + "$ref": "#/rules@17" }, "arguments": [] } @@ -928,65 +1394,87 @@ export const CrossModelGrammar = (): Grammar => loadedCrossModelGrammar ?? (load }, { "$type": "TerminalRule", - "hidden": true, - "name": "WS", + "name": "STRING", "definition": { "$type": "RegexToken", - "regex": "\\\\s+" + "regex": "/\\"[^\\"]*\\"|'[^']*'/" }, - "fragment": false + "fragment": false, + "hidden": false }, { "$type": "TerminalRule", - "name": "ID", + "name": "NUMBER", + "type": { + "$type": "ReturnType", + "name": "number" + }, "definition": { "$type": "RegexToken", - "regex": "[_a-zA-Z@][\\\\w_\\\\-@\\\\/#]*" + "regex": "/(-)?[0-9]+(\\\\.[0-9]*)?/" }, "fragment": false, "hidden": false }, { "$type": "TerminalRule", - "name": "STRING", + "hidden": true, + "name": "SL_COMMENT", "definition": { "$type": "RegexToken", - "regex": "\\"[^\\"]*\\"|'[^']*'" + "regex": "/#[^\\\\n\\\\r]*/" }, - "fragment": false, - "hidden": false + "fragment": false }, { "$type": "TerminalRule", - "name": "NUMBER", - "type": { - "$type": "ReturnType", - "name": "number" + "hidden": true, + "name": "NEWLINE", + "definition": { + "$type": "CharacterRange", + "left": { + "$type": "Keyword", + "value": "this_string_does_not_matter_newline#$%^&*((" + } }, + "fragment": false + }, + { + "$type": "TerminalRule", + "name": "DEDENT", "definition": { - "$type": "RegexToken", - "regex": "(-)?[0-9]+(\\\\.[0-9]*)?" + "$type": "CharacterRange", + "left": { + "$type": "Keyword", + "value": "this_string_does_not_matter_dedent#$%^&*((" + } }, "fragment": false, "hidden": false }, { "$type": "TerminalRule", - "hidden": true, - "name": "ML_COMMENT", + "name": "INDENT", "definition": { - "$type": "RegexToken", - "regex": "\\\\/\\\\*[\\\\s\\\\S]*?\\\\*\\\\/" + "$type": "CharacterRange", + "left": { + "$type": "Keyword", + "value": "this_string_does_not_matter_indent#$%^&*((" + } }, - "fragment": false + "fragment": false, + "hidden": false }, { "$type": "TerminalRule", "hidden": true, - "name": "SL_COMMENT", + "name": "SPACES", "definition": { - "$type": "RegexToken", - "regex": "\\\\/\\\\/[^\\\\n\\\\r]*" + "$type": "CharacterRange", + "left": { + "$type": "Keyword", + "value": "this_string_does_not_matter_spaces#$%^&*((" + } }, "fragment": false } diff --git a/extensions/crossmodel-lang/src/language-server/generated/module.ts b/extensions/crossmodel-lang/src/language-server/generated/module.ts index efbbb163..ea1a4391 100644 --- a/extensions/crossmodel-lang/src/language-server/generated/module.ts +++ b/extensions/crossmodel-lang/src/language-server/generated/module.ts @@ -1,5 +1,5 @@ /****************************************************************************** - * This file was generated by langium-cli 1.2.1. + * This file was generated by langium-cli 1.3.1. * DO NOT EDIT MANUALLY! ******************************************************************************/ @@ -7,11 +7,11 @@ import type { LangiumGeneratedServices, LangiumGeneratedSharedServices, LangiumS import { CrossModelAstReflection } from './ast'; import { CrossModelGrammar } from './grammar'; -export const CrossModelLanguageMetaData: LanguageMetaData = { +export const CrossModelLanguageMetaData = { languageId: 'cross-model', fileExtensions: ['.cm'], caseInsensitive: false -}; +} as const satisfies LanguageMetaData; export const CrossModelGeneratedSharedModule: Module = { AstReflection: () => new CrossModelAstReflection() diff --git a/extensions/crossmodel-lang/src/language-server/lexer/cross-model-indent-stack.ts b/extensions/crossmodel-lang/src/language-server/lexer/cross-model-indent-stack.ts new file mode 100644 index 00000000..e61b763d --- /dev/null +++ b/extensions/crossmodel-lang/src/language-server/lexer/cross-model-indent-stack.ts @@ -0,0 +1,81 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ +import _ from 'lodash'; +import { IndentStackError } from './cross-model-lexer-error'; + +/** + * Class to hold the current indentation levels. Has a few basic functions to handle + * basic operations. + */ +class IndentStack { + /** + * Stack to store the indentation levels. + */ + private stack: number[] = [0]; + + /** + * Retrieves a copy of the current stack. + * @returns {number[]} A copy of the current stack. + */ + public get(): number[] { + return [...this.stack]; + } + + /** + * Pushes a new indentation level onto the stack. + * @param {number} value - The indentation level to push. + */ + public push(value: number): void { + this.stack.push(value); + } + + /** + * Resets the stack to contain only the initial indentation level. + */ + public reset(): void { + this.stack = [0]; + } + + /** + * Removes and returns the topmost indentation level from the stack. + * @returns {number | undefined} The popped indentation level, or undefined if the stack is empty. + */ + public pop(): number | undefined { + return this.stack.pop(); + } + + /** + * Returns the length of indentation levels in the stack. + * @returns {number} The length of the stack. + */ + public length(): number { + return this.stack.length; + } + + /** + * Retrieves the last indentation level from the stack. + * @returns {number} The last indentation level. + * @throws {Error} If the stack is empty. + */ + public getLast(): number { + const lastValue = _.last(this.stack); + + if (lastValue === undefined) { + throw new IndentStackError('Indent stack is empty.'); + } + + return lastValue; + } + + /** + * Finds the last index of the specified indentation level in the stack. + * @param {number} value - The indentation level to search for. + * @returns {number} The index of the last occurrence of the indentation level, or -1 if not found. + */ + public findLastIndex(value: number): number { + return this.stack.lastIndexOf(value); + } +} + +export const indentStack = new IndentStack(); diff --git a/extensions/crossmodel-lang/src/language-server/lexer/cross-model-indentation-tokens.ts b/extensions/crossmodel-lang/src/language-server/lexer/cross-model-indentation-tokens.ts new file mode 100644 index 00000000..45a029c2 --- /dev/null +++ b/extensions/crossmodel-lang/src/language-server/lexer/cross-model-indentation-tokens.ts @@ -0,0 +1,140 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ +/* eslint-disable no-null/no-null */ + +import { IToken, Lexer, createToken, createTokenInstance } from 'chevrotain'; +import _ from 'lodash'; +import { indentStack } from './cross-model-indent-stack'; +import { IndentationError } from './cross-model-lexer-error'; + +export const NAMES = { + INDENT: 'INDENT', + DEDENT: 'DEDENT', + SPACES: 'SPACES', + NEWLINE: 'NEWLINE' +}; + +export const NEWLINE = createToken({ + name: NAMES.NEWLINE, + pattern: /\n|\r\n?/, + group: 'nl' +}); + +// Spaces that are not an indent or dedent should be ignored +// Done by giving the group Lexer.SKIPPED +export const SPACES = createToken({ + name: NAMES.SPACES, + pattern: / +/, + group: Lexer.SKIPPED +}); + +export const INDENT = createToken({ + name: NAMES.INDENT, + pattern: _.partialRight(matchIndentBase, 'indent'), + // custom token patterns should explicitly specify the line_breaks option + line_breaks: false +}); + +export const DEDENT = createToken({ + name: NAMES.DEDENT, + pattern: _.partialRight(matchIndentBase, 'dedent'), + // custom token patterns should explicitly specify the line_breaks option + line_breaks: false +}); + +/** + * Indentation/dedenentation tokens based on rules and returns the matched token. + * + * @param text The input text being analyzed. + * @param offset The current offset in the input text. + * @param matchedTokens An array of tokens that have been matched so far. + * @param groups An object containing groups of tokens matched so far. + * @param type The type of indentation to check for ("indent" or "dedent"). + * @returns The matched indent or dedent token. null if no indentation match is found. + * @throws {IndentationError} If an invalid outdent is encountered. + */ +function matchIndentBase(text: string, offset: number, matchedTokens: IToken[], groups: any, type: string) { + const noTokensMatchedYet = _.isEmpty(matchedTokens); + const newLines: Array = groups.nl; + const noNewLinesMatchedYet = _.isEmpty(newLines); + const isFirstLine = noTokensMatchedYet && noNewLinesMatchedYet; + const last_newline = _.last(newLines); + + // Windows line endings are \r\n, linux is only \n. This variable accounts for that. + const offset_match = /\r\n/.exec(last_newline?.image as string); + const offset_newline = offset_match ? 2 : 1; + + const isStartOfLine = + // only newlines matched so far + (noTokensMatchedYet && !noNewLinesMatchedYet) || + // Both newlines and other Tokens have been matched AND the offset is just after the last newline + (!noTokensMatchedYet && !noNewLinesMatchedYet && last_newline && offset === last_newline.startOffset + offset_newline); + + // indentation can only be matched at the start of a line. + if (isFirstLine || isStartOfLine) { + let currIndentLevel: number | undefined = undefined; + const prevIndentLevel = indentStack.getLast(); + + const wsRegExp = /[ ]+/y; + wsRegExp.lastIndex = offset; + const match = wsRegExp.exec(text); + + // possible non-empty indentation + if (match !== null) { + currIndentLevel = match[0].length; + + // To get the - working for the lists + const minusRegex = /-[ ]*/y; + minusRegex.lastIndex = match[0].length + offset; + const minusMatch = minusRegex.exec(text); + if (minusMatch) { + currIndentLevel = currIndentLevel + minusMatch[0].length; + } + } + // "empty" indentation means indentLevel of 0. + else { + currIndentLevel = 0; + } + + // deeper indentation + if (currIndentLevel > prevIndentLevel && type === 'indent') { + indentStack.push(currIndentLevel); + return match; + } + // shallower indentation + else if (currIndentLevel < prevIndentLevel && type === 'dedent') { + const matchIndentIndex = indentStack.findLastIndex(currIndentLevel); + + // any outdent must match some previous indentation level. + if (matchIndentIndex === -1) { + throw new IndentationError(`invalid outdent at offset: ${offset}`); + } + + const numberOfDedents = indentStack.length() - matchIndentIndex - 1; + + // This is a little tricky + // 1. If there is no match (0 level indent) than this custom token + // matcher would return "null" and so we need to add all the required outdents ourselves. + // 2. If there was match (> 0 level indent) than we need to add minus one number of outdents + // because the lexer would create one due to returning a none null result. + const iStart = match !== null ? 1 : 0; + for (let i = iStart; i < numberOfDedents; i++) { + indentStack.pop(); + matchedTokens.push(createTokenInstance(DEDENT, NAMES.DEDENT, offset, offset, newLines.length, newLines.length, 0, 0)); + } + + // even though we are adding fewer outdents directly we still need to update the indent stack fully. + if (iStart === 1) { + indentStack.pop(); + } + return match; + } else { + // same indent, this should be lexed as simple whitespace and ignored + return null; + } + } else { + // indentation cannot be matched under other circumstances + return null; + } +} diff --git a/extensions/crossmodel-lang/src/language-server/lexer/cross-model-lexer-error.ts b/extensions/crossmodel-lang/src/language-server/lexer/cross-model-lexer-error.ts new file mode 100644 index 00000000..258b4aca --- /dev/null +++ b/extensions/crossmodel-lang/src/language-server/lexer/cross-model-lexer-error.ts @@ -0,0 +1,14 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ +export class IndentationError extends Error { + constructor(message: string) { + super(message); + } +} + +export class IndentStackError extends Error { + constructor(message: string) { + super(message); + } +} diff --git a/extensions/crossmodel-lang/src/language-server/lexer/cross-model-lexer.ts b/extensions/crossmodel-lang/src/language-server/lexer/cross-model-lexer.ts new file mode 100644 index 00000000..e8f76c16 --- /dev/null +++ b/extensions/crossmodel-lang/src/language-server/lexer/cross-model-lexer.ts @@ -0,0 +1,90 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ + +import { createTokenInstance } from 'chevrotain'; +import { DefaultLexer, LexerResult } from 'langium'; +import { indentStack } from './cross-model-indent-stack'; +import { DEDENT, NAMES } from './cross-model-indentation-tokens'; +import { IndentationError } from './cross-model-lexer-error'; + +/** + * Custom CrossModelLexer to get indentation working. + */ +export class CrossModelLexer extends DefaultLexer { + /** + * Tokenize the given text. custom implementation to get indentation working. + * + * @param text The text to tokenize + * @returns LexerResult, The result of the lexing + */ + override tokenize(text: string): LexerResult { + indentStack.reset(); + let chevrotainResult; + + // In case there is a error lexing, Most of the time this is a lexer error but can + try { + chevrotainResult = super.tokenize(text); + } catch (error) { + const returnResult: LexerResult = { + tokens: [], + hidden: [], + errors: [] + }; + + if (error instanceof IndentationError) { + returnResult.errors.push({ + message: 'Indentation error: Make sure the indentation is correct in the file', + offset: 0, + line: 1, + column: 1, + length: 1 + }); + } else { + returnResult.errors.push({ + message: 'Unknown error Lexer error', + offset: 0, + line: 1, + column: 1, + length: 1 + }); + + throw error; + } + + return returnResult; + } + + // The lexer does not add trailing dedents at the end of the file + // this method does it for us + this.createTrailingDedentTokens(text, chevrotainResult); + + return chevrotainResult; + } + + /** + * Add dedents tokens at the end of the tokenlist. + * + * @param text The text that was tokenized + * @param lexingResult The token results of the text + */ + private createTrailingDedentTokens(text: string, lexingResult: LexerResult): void { + // These are there to put the error warning in the right place in the editor + const lines = text.split(/\r\n|\r|\n/); + const lastLine = lines[lines.length - 1]; + + // add remaining dedents + while (indentStack.pop()) { + // chevrotrain uses 1-based indices for tokens which Langium transforms into 0-based indices by deducting 1 + // see for instance https://github.com/eclipse-langium/langium/blob/eea5bc2/packages/langium/src/utils/cst-util.ts#L49 + const startOffset = text.length || 1; + const endOffset = text.length || 1; + const startLine = lines.length || 1; + const endLine = lines.length || 1; + const startColumn = lastLine?.length || 0 + 1; + const endColumn = lastLine?.length || 0; // for some reason end-column uses the correct index + const lineToken = createTokenInstance(DEDENT, NAMES.DEDENT, startOffset, endOffset, startLine, endLine, startColumn, endColumn); + lexingResult.tokens.push(lineToken); + } + } +} diff --git a/extensions/crossmodel-lang/src/language-server/lexer/cross-model-token-generator.ts b/extensions/crossmodel-lang/src/language-server/lexer/cross-model-token-generator.ts new file mode 100644 index 00000000..5e68e8c8 --- /dev/null +++ b/extensions/crossmodel-lang/src/language-server/lexer/cross-model-token-generator.ts @@ -0,0 +1,84 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ + +import { DefaultTokenBuilder, TokenBuilderOptions } from 'langium'; +import { Grammar, TerminalRule } from 'langium/lib/grammar/generated/ast'; +import { TokenType, TokenVocabulary } from 'chevrotain'; +import { DEDENT, INDENT, NEWLINE, SPACES, NAMES } from './cross-model-indentation-tokens'; + +/** + * Custom implementation of TokenBuilder for the CrossModel language. + * Overrides the default behavior to handle custom indentation tokens. + */ +export class CrossModelTokenBuilder extends DefaultTokenBuilder { + /** + * Overrides the base implementation to handle custom indentation tokens. + * + * Makes use of the overridden method but shifts around the given tokens to make + * it working with indentation. + * + * @param grammar The grammar of the language. + * @param options + * @returns The token vocabulary for the language. + * @throws Error if any of the required custom indentation tokens (SPACES, INDENT, DEDENT, NEWLINE) is missing in the grammar. + */ + override buildTokens(grammar: Grammar, options?: TokenBuilderOptions): TokenVocabulary { + const tokens: TokenType[] = super.buildTokens(grammar, options) as TokenType[]; + + const updatedTokens: TokenType[] = []; + let tokenWithSpaces: TokenType | undefined = undefined; + let tokenIndent: TokenType | undefined = undefined; + let tokenDedent: TokenType | undefined = undefined; + let tokenNewLine: TokenType | undefined = undefined; + + for (const token of tokens) { + if (token.name === NAMES.SPACES) { + tokenWithSpaces = token; + } else if (token.name === NAMES.DEDENT) { + tokenDedent = token; + } else if (token.name === NAMES.INDENT) { + tokenIndent = token; + } else if (token.name === NAMES.NEWLINE) { + tokenNewLine = token; + } else { + updatedTokens.push(token); + } + } + + if (!tokenWithSpaces || !tokenIndent || !tokenDedent || !tokenNewLine) { + throw new Error('Missing indentation, new line or spaces tokens in grammar'); + } + + updatedTokens.push(tokenWithSpaces); + updatedTokens.unshift(tokenNewLine, tokenDedent, tokenIndent); + + return updatedTokens; + } + + /** + * Build a terminal token for the given TerminalRule. + * Overrides the base implementation to handle custom indentation tokens. + * + * @param terminal The TerminalRule for which to build the token. + * @returns The TokenType representing the terminal token. + * + */ + protected override buildTerminalToken(terminal: TerminalRule): TokenType { + let token; + + if (terminal.name === NAMES.NEWLINE) { + token = NEWLINE; + } else if (terminal.name === NAMES.INDENT) { + token = INDENT; + } else if (terminal.name === NAMES.DEDENT) { + token = DEDENT; + } else if (terminal.name === NAMES.SPACES) { + token = SPACES; + } else { + token = super.buildTerminalToken(terminal); + } + + return token; + } +} diff --git a/extensions/crossmodel-lang/src/language-server/util/ast-util.ts b/extensions/crossmodel-lang/src/language-server/util/ast-util.ts index b963efbe..14e3b517 100644 --- a/extensions/crossmodel-lang/src/language-server/util/ast-util.ts +++ b/extensions/crossmodel-lang/src/language-server/util/ast-util.ts @@ -5,13 +5,13 @@ import { Reference, ReferenceInfo } from 'langium'; import { DiagramNode, SystemDiagram } from '../generated/ast'; export function createNodeToEntityReference(root: SystemDiagram): ReferenceInfo { - return { - reference: {} as Reference, - container: { - $type: DiagramNode, - $container: root, - $containerProperty: 'nodes' - }, - property: 'semanticElement' - }; + return { + reference: {} as Reference, + container: { + $type: DiagramNode, + $container: root, + $containerProperty: 'nodes' + }, + property: 'for' + }; } diff --git a/extensions/crossmodel-lang/src/language-server/util/name-util.test.ts b/extensions/crossmodel-lang/src/language-server/util/name-util.test.ts deleted file mode 100644 index 3a06ff4f..00000000 --- a/extensions/crossmodel-lang/src/language-server/util/name-util.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2023 CrossBreeze. - ********************************************************************************/ -import assert from 'assert'; -import { EmptyFileSystem } from 'langium'; -import { parseDocument } from 'langium/test'; -import 'mocha'; -import 'reflect-metadata'; -import { describe, test } from 'vitest'; -import { createCrossModelServices } from '../cross-model-module'; -import { CrossModelRoot } from '../generated/ast'; -import { findAvailableNodeName } from './name-util'; - -const services = createCrossModelServices({ ...EmptyFileSystem }); -const cmServices = services.CrossModel; - -describe('NameUtil', () => { - describe('findAvailableNodeName', () => { - test('should return given name if unique', async () => { - const document = await parseDocument(cmServices, 'diagram {}'); - assert.strictEqual(findAvailableNodeName(document.parseResult.value.diagram!, 'nodeA'), 'nodeA'); - }); - - test('should return unique name if given is taken', async () => { - const document = await parseDocument( - cmServices, - `diagram { - node nodeA for A { x := 10; y := 10; width := 10; height := 10; }; - }` - ); - assert.strictEqual(findAvailableNodeName(document.parseResult.value.diagram!, 'nodeA'), 'nodeA1'); - }); - - test('should properly count up if name is taken', async () => { - const document = await parseDocument( - cmServices, - `diagram { - node nodeA for A { x := 10; y := 10; width := 10; height := 10; }; - node nodeA1 for A { x := 10; y := 10; width := 10; height := 10; }; - }` - ); - assert.strictEqual(findAvailableNodeName(document.parseResult.value.diagram!, 'nodeA'), 'nodeA2'); - }); - - test('should find lowest count if multiple are taken', async () => { - const document = await parseDocument( - cmServices, - `diagram { - node nodeA for A { x := 10; y := 10; width := 10; height := 10; }; - node nodeA1 for A { x := 10; y := 10; width := 10; height := 10; }; - node nodeA2 for A { x := 10; y := 10; width := 10; height := 10; }; - node nodeA4 for A { x := 10; y := 10; width := 10; height := 10; }; - }` - ); - assert.strictEqual(findAvailableNodeName(document.parseResult.value.diagram!, 'nodeA'), 'nodeA3'); - }); - }); -}); diff --git a/extensions/crossmodel-lang/src/model-server/launch.ts b/extensions/crossmodel-lang/src/model-server/launch.ts index 9686a256..6c251778 100644 --- a/extensions/crossmodel-lang/src/model-server/launch.ts +++ b/extensions/crossmodel-lang/src/model-server/launch.ts @@ -11,38 +11,38 @@ import { ModelServer } from './model-server'; const currentConnections: rpc.MessageConnection[] = []; /** - * Creates a socket-based RCP model server that acts as a facade to the Langium-based semantic model index (documents). + * Creates a socket-based RPC model server that acts as a facade to the Langium-based semantic model index (documents). * * @param services language services * @returns a promise that is resolved as soon as the server is shut down or rejects if an error occurs */ export function startModelServer(services: CrossModelLSPServices, workspaceFolder: URI): Promise { - const netServer = net.createServer(socket => createClientConnection(socket, services)); - netServer.listen(0); - netServer.on('listening', () => { - const addressInfo = netServer.address(); - if (!addressInfo) { - console.error('[ModelServer] Could not resolve address info. Shutting down.'); - close(netServer); - return; - } else if (typeof addressInfo === 'string') { - console.error(`[ModelServer] Unexpectedly listening to pipe or domain socket "${addressInfo}". Shutting down.`); - close(netServer); - return; - } - console.log(`[ModelServer] Ready to accept new client requests on port: ${addressInfo.port}`); + const netServer = net.createServer(socket => createClientConnection(socket, services)); + netServer.listen(0); + netServer.on('listening', () => { + const addressInfo = netServer.address(); + if (!addressInfo) { + console.error('[ModelServer] Could not resolve address info. Shutting down.'); + close(netServer); + return; + } else if (typeof addressInfo === 'string') { + console.error(`[ModelServer] Unexpectedly listening to pipe or domain socket "${addressInfo}". Shutting down.`); + close(netServer); + return; + } + console.log(`[ModelServer] Ready to accept new client requests on port: ${addressInfo.port}`); - // write dynamically assigned port to workspace folder to let clients know we are ready to accept connections - writePortFileToWorkspace(workspaceFolder, MODELSERVER_PORT_FILE, addressInfo); - }); - netServer.on('error', err => { - console.error('[ModelServer] Error: ', err); - close(netServer); - }); - return new Promise((resolve, reject) => { - netServer.on('close', () => resolve(undefined)); - netServer.on('error', error => reject(error)); - }); + // Write dynamically assigned port to workspace folder to let clients know we are ready to accept connections + writePortFileToWorkspace(workspaceFolder, MODELSERVER_PORT_FILE, addressInfo); + }); + netServer.on('error', err => { + console.error('[ModelServer] Error: ', err); + close(netServer); + }); + return new Promise((resolve, reject) => { + netServer.on('close', () => resolve(undefined)); + netServer.on('error', error => reject(error)); + }); } /** @@ -53,21 +53,21 @@ export function startModelServer(services: CrossModelLSPServices, workspaceFolde * @returns a promise that is resolved as soon as the connection is closed or rejects if an error occurs */ async function createClientConnection(socket: net.Socket, services: CrossModelLSPServices): Promise { - console.info(`[ModelServer] Starting model server connection for client: '${socket.localAddress}'`); - const connection = createConnection(socket); - currentConnections.push(connection); + console.info(`[ModelServer] Starting model server connection for client: '${socket.localAddress}'`); + const connection = createConnection(socket); + currentConnections.push(connection); - const modelServer = new ModelServer(connection, services.shared.model.ModelService); - connection.onDispose(() => modelServer.dispose()); - socket.on('close', () => modelServer.dispose()); + const modelServer = new ModelServer(connection, services.shared.model.ModelService); + connection.onDispose(() => modelServer.dispose()); + socket.on('close', () => modelServer.dispose()); - connection.listen(); - console.info(`[ModelServer] Connecting to client: '${socket.localAddress}'`); + connection.listen(); + console.info(`[ModelServer] Connecting to client: '${socket.localAddress}'`); - return new Promise((resolve, rejects) => { - connection.onClose(() => resolve(undefined)); - connection.onError(error => rejects(error)); - }); + return new Promise((resolve, rejects) => { + connection.onClose(() => resolve(undefined)); + connection.onError(error => rejects(error)); + }); } /** @@ -77,7 +77,7 @@ async function createClientConnection(socket: net.Socket, services: CrossModelLS * @returns message connection */ function createConnection(socket: net.Socket): rpc.MessageConnection { - return rpc.createMessageConnection(new rpc.SocketMessageReader(socket), new rpc.SocketMessageWriter(socket), console); + return rpc.createMessageConnection(new rpc.SocketMessageReader(socket), new rpc.SocketMessageWriter(socket), console); } /** @@ -86,6 +86,6 @@ function createConnection(socket: net.Socket): rpc.MessageConnection { * @param netServer server to be closed */ function close(netServer: net.Server): void { - currentConnections.forEach(connection => connection.dispose()); - netServer.close(); + currentConnections.forEach(connection => connection.dispose()); + netServer.close(); } diff --git a/extensions/crossmodel-lang/src/model-server/model-server.ts b/extensions/crossmodel-lang/src/model-server/model-server.ts index 39616abc..a505c754 100644 --- a/extensions/crossmodel-lang/src/model-server/model-server.ts +++ b/extensions/crossmodel-lang/src/model-server/model-server.ts @@ -4,15 +4,20 @@ import { CloseModel, + CloseModelArgs, CrossModelRoot, OnSave, + OnUpdated, OpenModel, + OpenModelArgs, RequestModel, RequestModelDiagramNode, SaveModel, - UpdateModel + SaveModelArgs, + UpdateModel, + UpdateModelArgs } from '@crossbreeze/protocol'; -import { AstNode, isReference } from 'langium'; +import { isReference } from 'langium'; import { Disposable } from 'vscode-jsonrpc'; import * as rpc from 'vscode-jsonrpc/node'; import { CrossModelRoot as CrossModelRootAst, DiagramNode, Entity, isCrossModelRoot } from '../language-server/generated/ast'; @@ -25,18 +30,19 @@ import { ModelService } from './model-service'; */ export class ModelServer implements Disposable { protected toDispose: Disposable[] = []; + protected toDisposeForSession: Map = new Map(); constructor(protected connection: rpc.MessageConnection, protected modelService: ModelService) { this.initialize(connection); } protected initialize(connection: rpc.MessageConnection): void { - this.toDispose.push(connection.onRequest(OpenModel, uri => this.openModel(uri))); - this.toDispose.push(connection.onRequest(CloseModel, uri => this.closeModel(uri))); + this.toDispose.push(connection.onRequest(OpenModel, args => this.openModel(args))); + this.toDispose.push(connection.onRequest(CloseModel, args => this.closeModel(args))); this.toDispose.push(connection.onRequest(RequestModel, uri => this.requestModel(uri))); this.toDispose.push(connection.onRequest(RequestModelDiagramNode, (uri, id) => this.requestModelDiagramNode(uri, id))); - this.toDispose.push(connection.onRequest(UpdateModel, (uri, model) => this.updateModel(uri, model))); - this.toDispose.push(connection.onRequest(SaveModel, (uri, model) => this.saveModel(uri, model))); + this.toDispose.push(connection.onRequest(UpdateModel, args => this.updateModel(args))); + this.toDispose.push(connection.onRequest(SaveModel, args => this.saveModel(args))); } /** @@ -59,47 +65,65 @@ export class ModelServer implements Disposable { for (const node of root.diagram.nodes) { if (node.name === id) { - if (diagramNode) { - throw new Error('Multiple nodes with the same name'); - } - diagramNode = node; } } - if (!diagramNode) { - throw new Error('No node found with the given id'); - } - - const ref: Entity | undefined = diagramNode.semanticElement.ref; + const entity: Entity | undefined = diagramNode?.entity?.ref; - if (!ref || !ref.$container.$document) { + if (!entity?.$container.$document) { throw new Error('No node found with the given id'); } - const entityUri = ref.$container.$document.uri.toString(); - const serializedEntity: CrossModelRoot | undefined = toSerializable({ + const serializedEntity = toSerializable({ $type: 'CrossModelRoot', - entity: diagramNode.semanticElement.ref - }); + entity: entity + }) as CrossModelRoot; return { - uri: entityUri, + uri: entity.$container.$document.uri.toString(), model: serializedEntity }; } - protected async openModel(uri: string): Promise { - await this.modelService.open(uri); + protected async openModel(args: OpenModelArgs): Promise { + if (!this.modelService.isOpen(args.uri)) { + await this.modelService.open(args); + } + this.setupListeners(args); + return this.requestModel(args.uri); + } + + protected setupListeners(args: OpenModelArgs): void { + this.disposeListeners(args); + const listenersForClient = []; + listenersForClient.push( + this.modelService.onSave(args.uri, event => + this.connection.sendNotification(OnSave, { + uri: args.uri, + model: toSerializable(event.model) as CrossModelRoot, + sourceClientId: event.sourceClientId + }) + ), + this.modelService.onUpdate(args.uri, event => + this.connection.sendNotification(OnUpdated, { + uri: args.uri, + model: toSerializable(event.model) as CrossModelRoot, + sourceClientId: event.sourceClientId + }) + ) + ); + this.toDisposeForSession.set(args.clientId, listenersForClient); + } - this.modelService.onSave(uri, newModel => { - // TODO: Research if this also has to be closed after the document closes - this.connection.sendNotification(OnSave, uri, toSerializable(newModel) as CrossModelRoot); - }); + protected disposeListeners(args: CloseModelArgs): void { + this.toDisposeForSession.get(args.clientId)?.forEach(disposable => disposable.dispose()); + this.toDisposeForSession.delete(args.clientId); } - protected async closeModel(uri: string): Promise { - await this.modelService.close(uri); + protected async closeModel(args: CloseModelArgs): Promise { + this.disposeListeners(args); + return this.modelService.close(args); } protected async requestModel(uri: string): Promise { @@ -107,13 +131,13 @@ export class ModelServer implements Disposable { return toSerializable(root) as CrossModelRoot; } - protected async updateModel(uri: string, model: CrossModelRoot): Promise { - const updated = await this.modelService.update(uri, model); + protected async updateModel(args: UpdateModelArgs): Promise { + const updated = await this.modelService.update(args); return toSerializable(updated) as CrossModelRoot; } - protected async saveModel(uri: string, model: AstNode): Promise { - await this.modelService.save(uri, model); + protected async saveModel(args: SaveModelArgs): Promise { + await this.modelService.save(args); } dispose(): void { diff --git a/extensions/crossmodel-lang/src/model-server/model-service.ts b/extensions/crossmodel-lang/src/model-server/model-service.ts index 2f5e8b32..86e36591 100644 --- a/extensions/crossmodel-lang/src/model-server/model-service.ts +++ b/extensions/crossmodel-lang/src/model-server/model-service.ts @@ -2,11 +2,12 @@ * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ +import { CloseModelArgs, ModelSavedEvent, ModelUpdatedEvent, OpenModelArgs, SaveModelArgs, UpdateModelArgs } from '@crossbreeze/protocol'; import { AstNode, DocumentState, isAstNode } from 'langium'; -import { Disposable } from 'vscode-languageserver'; -import { TextDocument } from 'vscode-languageserver-textdocument'; +import { Disposable, OptionalVersionedTextDocumentIdentifier, Range, TextDocumentEdit, TextEdit, uinteger } from 'vscode-languageserver'; import { URI } from 'vscode-uri'; import { CrossModelSharedServices } from '../language-server/cross-model-module'; +import { LANGUAGE_CLIENT_ID } from './openable-text-documents'; /** * The model service serves as a facade to access and update semantic models from the language server as a non-LSP client. @@ -18,15 +19,44 @@ export class ModelService { protected documentManager = shared.workspace.TextDocumentManager, protected documents = shared.workspace.LangiumDocuments, protected documentBuilder = shared.workspace.DocumentBuilder - ) {} + ) { + // sync updates with language client + this.documentBuilder.onBuildPhase(DocumentState.Validated, (allChangedDocuments, _token) => { + for (const changedDocument of allChangedDocuments) { + const sourceClientId = this.documentManager.getSourceClientId(changedDocument, allChangedDocuments); + if (sourceClientId === LANGUAGE_CLIENT_ID) { + continue; + } + const textDocument = changedDocument.textDocument; + if (this.documentManager.isOpenInLanguageClient(textDocument.uri)) { + // we only want to apply a text edit if the editor is already open + // because opening and updating at the same time might cause problems as the open call resets the document to filesystem + this.shared.lsp.Connection?.workspace.applyEdit({ + label: 'Update Model', + documentChanges: [ + // we use a null version to indicate that the version is known + // eslint-disable-next-line no-null/no-null + TextDocumentEdit.create(OptionalVersionedTextDocumentIdentifier.create(textDocument.uri, null), [ + TextEdit.replace(Range.create(0, 0, uinteger.MAX_VALUE, uinteger.MAX_VALUE), textDocument.getText()) + ]) + ] + }); + } + } + }); + } /** * Opens the document with the given URI for modification. * * @param uri document URI */ - async open(uri: string): Promise { - return this.documentManager.open(uri); + async open(args: OpenModelArgs): Promise { + return this.documentManager.open(args); + } + + isOpen(uri: string): boolean { + return this.documentManager.isOpen(uri); } /** @@ -34,8 +64,8 @@ export class ModelService { * * @param uri document URI */ - async close(uri: string): Promise { - return this.documentManager.close(uri); + async close(args: CloseModelArgs): Promise { + return this.documentManager.close(args); } /** @@ -54,7 +84,6 @@ export class ModelService { */ request(uri: string, guard: (item: unknown) => item is T): Promise; async request(uri: string, guard?: (item: unknown) => item is T): Promise { - this.open(uri); const document = this.documents.getOrCreateDocument(URI.parse(uri)); const root = document.parseResult.value; const check = guard ?? isAstNode; @@ -70,34 +99,27 @@ export class ModelService { * @param model semantic model or textual representation of it * @returns the stored semantic model */ - async update(uri: string, model: T | string): Promise { - await this.open(uri); - const document = this.documents.getOrCreateDocument(URI.parse(uri)); + async update(args: UpdateModelArgs): Promise { + await this.open(args); + const document = this.documents.getOrCreateDocument(URI.parse(args.uri)); const root = document.parseResult.value; if (!isAstNode(root)) { - throw new Error(`No AST node to update exists in '${uri}'`); + throw new Error(`No AST node to update exists in '${args.uri}'`); } - - const text = typeof model === 'string' ? model : this.serialize(URI.parse(uri), model); - const version = document.textDocument.version + 1; - - TextDocument.update(document.textDocument, [{ text }], version); - await this.documentManager.update(uri, version, text); - await this.documentBuilder.update([URI.parse(uri)], []); - + const textDocument = document.textDocument; + const text = typeof args.model === 'string' ? args.model : this.serialize(URI.parse(args.uri), args.model); + if (text === textDocument.getText()) { + return document.parseResult.value as T; + } + await this.documentManager.update(args.uri, textDocument.version + 1, text, args.clientId); return document.parseResult.value as T; } - onUpdate(uri: string, listener: (model: T) => void): Disposable { - return this.documentBuilder.onBuildPhase(DocumentState.Validated, (allChangedDocuments, _token) => { - const changedDocument = allChangedDocuments.find(document => document.uri.toString() === uri); - if (changedDocument) { - listener(changedDocument.parseResult.value as T); - } - }); + onUpdate(uri: string, listener: (model: ModelUpdatedEvent) => void): Disposable { + return this.documentManager.onUpdate(uri, listener); } - onSave(uri: string, listener: (model: T) => void): Disposable { + onSave(uri: string, listener: (model: ModelSavedEvent) => void): Disposable { return this.documentManager.onSave(uri, listener); } @@ -107,9 +129,14 @@ export class ModelService { * @param uri document uri * @param model semantic model or text */ - async save(uri: string, model: AstNode | string): Promise { - const text = typeof model === 'string' ? model : this.serialize(URI.parse(uri), model); - return this.documentManager.save(uri, text); + async save(args: SaveModelArgs): Promise { + // sync: implicit update of internal data structure to match file system (similar to workspace initialization) + if (this.documents.hasDocument(URI.parse(args.uri))) { + await this.update(args); + } + + const text = typeof args.model === 'string' ? args.model : this.serialize(URI.parse(args.uri), args.model); + return this.documentManager.save(args.uri, text, args.clientId); } /** diff --git a/extensions/crossmodel-lang/src/model-server/open-text-document-manager.ts b/extensions/crossmodel-lang/src/model-server/open-text-document-manager.ts index 57333e5d..96e15f50 100644 --- a/extensions/crossmodel-lang/src/model-server/open-text-document-manager.ts +++ b/extensions/crossmodel-lang/src/model-server/open-text-document-manager.ts @@ -2,14 +2,24 @@ * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ +import { CloseModelArgs, ModelSavedEvent, ModelUpdatedEvent, OpenModelArgs } from '@crossbreeze/protocol'; import * as fs from 'fs'; -import { AstNode, FileSystemProvider, LangiumDefaultSharedServices, LangiumDocuments } from 'langium'; +import { + AstNode, + DocumentBuilder, + DocumentState, + FileSystemProvider, + LangiumDefaultSharedServices, + LangiumDocument, + LangiumDocuments +} from 'langium'; +import { Disposable } from 'vscode-languageserver'; import { TextDocumentIdentifier, TextDocumentItem, VersionedTextDocumentIdentifier } from 'vscode-languageserver-protocol'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { URI } from 'vscode-uri'; +import { CrossModelLanguageMetaData } from '../language-server/generated/module'; import { AddedSharedModelServices } from './model-module'; import { OpenableTextDocuments } from './openable-text-documents'; -import { Disposable } from 'vscode-languageserver'; /** * A manager class that suppors handling documents with a simple open-update-save/close lifecycle. @@ -20,17 +30,18 @@ export class OpenTextDocumentManager { protected textDocuments: OpenableTextDocuments; protected fileSystemProvider: FileSystemProvider; protected langiumDocs: LangiumDocuments; - - /** Normalized URIs of open documents */ - protected openDocuments: string[] = []; + protected documentBuilder: DocumentBuilder; constructor(services: AddedSharedModelServices & LangiumDefaultSharedServices) { this.textDocuments = services.workspace.TextDocuments; this.fileSystemProvider = services.workspace.FileSystemProvider; this.langiumDocs = services.workspace.LangiumDocuments; + this.documentBuilder = services.workspace.DocumentBuilder; - this.textDocuments.onDidOpen(event => this.open(event.document.uri, event.document.languageId)); - this.textDocuments.onDidClose(event => this.close(event.document.uri)); + this.textDocuments.onDidOpen(event => + this.open({ clientId: event.clientId, uri: event.document.uri, languageId: event.document.languageId }) + ); + this.textDocuments.onDidClose(event => this.close({ clientId: event.clientId, uri: event.document.uri })); } /** @@ -41,63 +52,94 @@ export class OpenTextDocumentManager { * @param listener Callback to be called * @returns Disposable object */ - onSave(uri: string, listener: (model: T) => void): Disposable { - return this.textDocuments.onDidSave(e => { - const documentURI = URI.parse(e.document.uri); + onSave(uri: string, listener: (model: ModelSavedEvent) => void): Disposable { + return this.textDocuments.onDidSave(event => { + const documentURI = URI.parse(event.document.uri); // Check if the uri of the saved document and the uri of the listener are equal. - if (e.document.uri === uri && documentURI !== undefined && this.langiumDocs.hasDocument(documentURI)) { + if (event.document.uri === uri && documentURI !== undefined && this.langiumDocs.hasDocument(documentURI)) { const document = this.langiumDocs.getOrCreateDocument(documentURI); const root = document.parseResult.value; - return listener(root as T); + return listener({ model: root as T, uri: event.document.uri, sourceClientId: event.clientId }); } return undefined; }); } - async open(uri: string, languageId?: string): Promise { - if (this.isOpen(uri)) { - return; - } - this.openDocuments.push(this.normalizedUri(uri)); - const textDocument = await this.readFromFilesystem(uri, languageId); - this.textDocuments.notifyDidOpenTextDocument({ textDocument }); + onUpdate(uri: string, listener: (model: ModelUpdatedEvent) => void): Disposable { + return this.documentBuilder.onBuildPhase(DocumentState.Validated, (allChangedDocuments, _token) => { + const changedDocument = allChangedDocuments.find(document => document.uri.toString() === uri); + if (changedDocument) { + const sourceClientId = this.getSourceClientId(changedDocument, allChangedDocuments); + listener({ + model: changedDocument.parseResult.value as T, + sourceClientId, + uri: changedDocument.textDocument.uri + }); + } + }); } - async close(uri: string): Promise { - if (!this.isOpen(uri)) { - return; + getSourceClientId(preferred: LangiumDocument, rest: LangiumDocument[]): string { + const clientId = this.textDocuments.getChangeSource(preferred.textDocument.uri, preferred.textDocument.version); + if (clientId) { + return clientId; } - this.removeFromOpenedDocuments(uri); - this.textDocuments.notifyDidCloseTextDocument({ textDocument: TextDocumentIdentifier.create(uri) }); + return ( + rest + .map(document => this.textDocuments.getChangeSource(document.textDocument.uri, document.textDocument.version)) + .find(source => source !== undefined) || 'unknown' + ); } - async update(uri: string, version: number, text: string): Promise { + async open(args: OpenModelArgs): Promise { + // only create a dummy document if it is already open as we use the synced state anyway + const textDocument = this.isOpen(args.uri) + ? this.createDummyDocument(args.uri) + : await this.createDocumentFromFileSystem(args.uri, args.languageId); + this.textDocuments.notifyDidOpenTextDocument({ textDocument }, args.clientId); + } + + async close(args: CloseModelArgs): Promise { + this.textDocuments.notifyDidCloseTextDocument({ textDocument: TextDocumentIdentifier.create(args.uri) }, args.clientId); + } + + async update(uri: string, version: number, text: string, clientId: string): Promise { if (!this.isOpen(uri)) { throw new Error(`Document ${uri} hasn't been opened for updating yet`); } - this.textDocuments.notifyDidChangeTextDocument({ - textDocument: VersionedTextDocumentIdentifier.create(uri, version), - contentChanges: [{ text }] - }); + this.textDocuments.notifyDidChangeTextDocument( + { + textDocument: VersionedTextDocumentIdentifier.create(uri, version), + contentChanges: [{ text }] + }, + clientId + ); } - async save(uri: string, text: string): Promise { + async save(uri: string, text: string, clientId: string): Promise { const vscUri = URI.parse(uri); fs.writeFileSync(vscUri.fsPath, text); - this.textDocuments.notifyDidSaveTextDocument({ textDocument: TextDocumentIdentifier.create(uri) }); + this.textDocuments.notifyDidSaveTextDocument({ textDocument: TextDocumentIdentifier.create(uri), text }, clientId); + } + + isOpen(uri: string): boolean { + return !!this.textDocuments.get(this.normalizedUri(uri)) || !!this.textDocuments.get(uri); } - protected isOpen(uri: string): boolean { - return this.openDocuments.includes(this.normalizedUri(uri)); + isOpenInLanguageClient(uri: string): boolean { + return this.textDocuments.isOpenInLanguageClient(this.normalizedUri(uri)); } - protected removeFromOpenedDocuments(uri: string): void { - this.openDocuments.splice(this.openDocuments.indexOf(this.normalizedUri(uri))); + protected createDummyDocument(uri: string): TextDocumentItem { + return TextDocumentItem.create(this.normalizedUri(uri), CrossModelLanguageMetaData.languageId, 1, ''); } - protected async readFromFilesystem(uri: string, languageId = 'cross-model'): Promise { + protected async createDocumentFromFileSystem( + uri: string, + languageId: string = CrossModelLanguageMetaData.languageId + ): Promise { const vscUri = URI.parse(uri); const content = this.fileSystemProvider.readFileSync(vscUri); return TextDocumentItem.create(vscUri.toString(), languageId, 1, content.toString()); diff --git a/extensions/crossmodel-lang/src/model-server/openable-text-documents.ts b/extensions/crossmodel-lang/src/model-server/openable-text-documents.ts index 3f6ebc66..bae68d1d 100644 --- a/extensions/crossmodel-lang/src/model-server/openable-text-documents.ts +++ b/extensions/crossmodel-lang/src/model-server/openable-text-documents.ts @@ -3,166 +3,248 @@ * Copyright (c) Microsoft Corporation and EclipseSource. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. * ------------------------------------------------------------------------------------------ */ +import { basename } from 'path'; import { - CancellationToken, - DidChangeTextDocumentParams, - DidCloseTextDocumentParams, - DidOpenTextDocumentParams, - DidSaveTextDocumentParams, - Disposable, - DocumentUri, - Emitter, - HandlerResult, - RequestHandler, - TextDocumentChangeEvent, - TextDocuments, - TextDocumentsConfiguration, - TextDocumentSyncKind, - TextDocumentWillSaveEvent, - TextEdit, - WillSaveTextDocumentParams + CancellationToken, + DidChangeTextDocumentParams, + DidCloseTextDocumentParams, + DidOpenTextDocumentParams, + DidSaveTextDocumentParams, + Disposable, + Emitter, + Event, + HandlerResult, + RequestHandler, + TextDocumentChangeEvent, + TextDocuments, + TextDocumentsConfiguration, + TextDocumentSyncKind, + TextDocumentWillSaveEvent, + TextEdit, + WillSaveTextDocumentParams } from 'vscode-languageserver'; +import { TextDocument } from 'vscode-languageserver-textdocument'; +import { URI } from 'vscode-uri'; +import { ClientLogger } from '../language-server/cross-model-client-logger'; + +export const LANGUAGE_CLIENT_ID = 'language-client'; + +export interface ClientTextDocumentChangeEvent extends TextDocumentChangeEvent { + clientId: string; +} /** - * This subclass of `TextDocuments` is actually entirely equivalent to `TextDocuments` but opens up - * methods to be able to invoke events from within the language server (see json-server.ts). - * - * Otherwise the document will be read all the time from the disk - * langium/src/workspace/documents.ts:222 which relies on _syncedDocuments to be open - * vscode-languageserver/lib/common/textDocuments.js:119 + * This subclass of `TextDocuments` supports multiple clients to open the same document and sync their state. */ -export class OpenableTextDocuments extends TextDocuments { - public constructor(protected configuration: TextDocumentsConfiguration) { - super(configuration); - } - - protected get __syncedDocuments(): Map { - return this['_syncedDocuments']; - } - - protected get __onDidChangeContent(): Emitter> { - return this['_onDidChangeContent']; - } - - protected get __onDidOpen(): Emitter> { - return this['_onDidOpen']; - } - - protected get __onDidClose(): Emitter> { - return this['_onDidClose']; - } - - protected get __onDidSave(): Emitter> { - return this['_onDidSave']; - } - - protected get __onWillSave(): Emitter> { - return this['_onWillSave']; - } - - protected get __willSaveWaitUntil(): RequestHandler, TextEdit[], void> | undefined { - return this['_willSaveWaitUntil']; - } - - public override listen(connection: any): Disposable { - (connection).__textDocumentSync = TextDocumentSyncKind.Incremental; - const disposables: Disposable[] = []; - disposables.push( - connection.onDidOpenTextDocument((event: DidOpenTextDocumentParams) => { - this.notifyDidOpenTextDocument(event); - }) - ); - disposables.push( - connection.onDidChangeTextDocument((event: DidChangeTextDocumentParams) => { - this.notifyDidChangeTextDocument(event); - }) - ); - disposables.push( - connection.onDidCloseTextDocument((event: DidCloseTextDocumentParams) => { - this.notifyDidCloseTextDocument(event); - }) - ); - disposables.push( - connection.onWillSaveTextDocument((event: WillSaveTextDocumentParams) => { - this.notifyWillSaveTextDocument(event); - }) - ); - disposables.push( - connection.onWillSaveTextDocumentWaitUntil((event: WillSaveTextDocumentParams, token: CancellationToken) => - this.notifyWillSaveTextDocumentWaitUntil(event, token) - ) - ); - disposables.push( - connection.onDidSaveTextDocument((event: DidSaveTextDocumentParams) => { - this.notifyDidSaveTextDocument(event); - }) - ); - return Disposable.create(() => { - disposables.forEach(disposable => disposable.dispose()); - }); - } - - public notifyDidChangeTextDocument(event: DidChangeTextDocumentParams): void { - const td = event.textDocument; - const changes = event.contentChanges; - if (changes.length === 0) { - return; - } - - const { version } = td; - // eslint-disable-next-line no-null/no-null - if (version === null || version === undefined) { - throw new Error(`Received document change event for ${td.uri} without valid version identifier`); - } - - let syncedDocument = this.__syncedDocuments.get(td.uri); - if (syncedDocument !== undefined) { - syncedDocument = this.configuration.update(syncedDocument, changes, version); - this.__syncedDocuments.set(td.uri, syncedDocument); - this.__onDidChangeContent.fire(Object.freeze({ document: syncedDocument })); - } - } - - public notifyDidCloseTextDocument(event: DidCloseTextDocumentParams): void { - const syncedDocument = this.__syncedDocuments.get(event.textDocument.uri); - if (syncedDocument !== undefined) { - this.__syncedDocuments.delete(event.textDocument.uri); - this.__onDidClose.fire(Object.freeze({ document: syncedDocument })); - } - } - - public notifyWillSaveTextDocument(event: WillSaveTextDocumentParams): void { - const syncedDocument = this.__syncedDocuments.get(event.textDocument.uri); - if (syncedDocument !== undefined) { - this.__onWillSave.fire(Object.freeze({ document: syncedDocument, reason: event.reason })); - } - } - - public notifyWillSaveTextDocumentWaitUntil( - event: WillSaveTextDocumentParams, - token: CancellationToken - ): HandlerResult { - const syncedDocument = this.__syncedDocuments.get(event.textDocument.uri); - if (syncedDocument !== undefined && this.__willSaveWaitUntil) { - return this.__willSaveWaitUntil(Object.freeze({ document: syncedDocument, reason: event.reason }), token); - } else { - return []; - } - } - - public notifyDidSaveTextDocument(event: DidSaveTextDocumentParams): void { - const syncedDocument = this.__syncedDocuments.get(event.textDocument.uri); - if (syncedDocument !== undefined) { - this.__onDidSave.fire(Object.freeze({ document: syncedDocument })); - } - } - - public notifyDidOpenTextDocument(event: DidOpenTextDocumentParams): void { - const td = event.textDocument; - const document = this.configuration.create(td.uri, td.languageId, td.version, td.text); - this.__syncedDocuments.set(td.uri, document); - const toFire = Object.freeze({ document }); - this.__onDidOpen.fire(toFire); - this.__onDidChangeContent.fire(toFire); - } +export class OpenableTextDocuments extends TextDocuments { + protected __clientDocuments = new Map>(); + protected __changeHistory = new Map(); + + public constructor(protected configuration: TextDocumentsConfiguration, protected logger: ClientLogger) { + super(configuration); + } + + protected get __syncedDocuments(): Map { + return this['_syncedDocuments']; + } + + protected get __onDidChangeContent(): Emitter> { + return this['_onDidChangeContent']; + } + + override get onDidChangeContent(): Event> { + return this.__onDidChangeContent.event; + } + + protected get __onDidOpen(): Emitter> { + return this['_onDidOpen']; + } + + override get onDidOpen(): Event> { + return this.__onDidOpen.event; + } + + protected get __onDidClose(): Emitter> { + return this['_onDidClose']; + } + + override get onDidClose(): Event> { + return this.__onDidClose.event; + } + + protected get __onDidSave(): Emitter> { + return this['_onDidSave']; + } + + override get onDidSave(): Event> { + return this['__onDidSave'].event; + } + + protected get __onWillSave(): Emitter> { + return this['_onWillSave']; + } + + protected get __willSaveWaitUntil(): RequestHandler, TextEdit[], void> | undefined { + return this['_willSaveWaitUntil']; + } + + public override listen(connection: any): Disposable { + (connection).__textDocumentSync = TextDocumentSyncKind.Incremental; + const disposables: Disposable[] = []; + disposables.push( + connection.onDidOpenTextDocument((event: DidOpenTextDocumentParams) => { + this.notifyDidOpenTextDocument(event); + }) + ); + disposables.push( + connection.onDidChangeTextDocument((event: DidChangeTextDocumentParams) => { + this.notifyDidChangeTextDocument(event); + }) + ); + disposables.push( + connection.onDidCloseTextDocument((event: DidCloseTextDocumentParams) => { + this.notifyDidCloseTextDocument(event); + }) + ); + disposables.push( + connection.onWillSaveTextDocument((event: WillSaveTextDocumentParams) => { + this.notifyWillSaveTextDocument(event); + }) + ); + disposables.push( + connection.onWillSaveTextDocumentWaitUntil((event: WillSaveTextDocumentParams, token: CancellationToken) => + this.notifyWillSaveTextDocumentWaitUntil(event, token) + ) + ); + disposables.push( + connection.onDidSaveTextDocument((event: DidSaveTextDocumentParams) => { + this.notifyDidSaveTextDocument(event); + }) + ); + return Disposable.create(() => { + disposables.forEach(disposable => disposable.dispose()); + }); + } + + public notifyDidChangeTextDocument(event: DidChangeTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void { + const td = event.textDocument; + const changes = event.contentChanges; + if (changes.length === 0) { + return; + } + + const { version } = td; + // eslint-disable-next-line no-null/no-null + if (version === null || version === undefined) { + throw new Error(`Received document change event for ${td.uri} without valid version identifier`); + } + + let document = this.__syncedDocuments.get(td.uri); + if (document !== undefined) { + if (document.version >= td.version) { + this.log(document.uri, `Update is out of date (${document.version} >= ${td.version}): Ignore update by ${clientId}`); + return; + } + document = this.configuration.update(document, changes, version); + this.__syncedDocuments.set(td.uri, document); + const changeHistory = this.__changeHistory.get(td.uri) || []; + changeHistory[td.version] = clientId; + this.__changeHistory.set(td.uri, changeHistory); + this.log(document.uri, `Update to version ${td.version} by ${clientId}`); + this.__onDidChangeContent.fire(Object.freeze({ document, clientId })); + } + } + + public notifyDidCloseTextDocument(event: DidCloseTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void { + if (!this.isOpenInClient(event.textDocument.uri, clientId)) { + return; + } + this.__clientDocuments.get(event.textDocument.uri)?.delete(clientId); + const syncedDocument = this.__syncedDocuments.get(event.textDocument.uri); + if (syncedDocument !== undefined) { + this.log(syncedDocument.uri, `Closed synced document: ${syncedDocument.version} by ${clientId}`); + this.__onDidClose.fire(Object.freeze({ document: syncedDocument, clientId })); + + if (!this.__clientDocuments.get(event.textDocument.uri)?.size) { + // last client closed the document, delete sync state + this.log(syncedDocument.uri, `Remove synced document: ${syncedDocument.version} (no client left)`); + this.__syncedDocuments.delete(event.textDocument.uri); + this.__changeHistory.delete(event.textDocument.uri); + } + } + } + + public notifyWillSaveTextDocument(event: WillSaveTextDocumentParams): void { + const syncedDocument = this.__syncedDocuments.get(event.textDocument.uri); + if (syncedDocument !== undefined) { + this.__onWillSave.fire(Object.freeze({ document: syncedDocument, reason: event.reason })); + } + } + + public notifyWillSaveTextDocumentWaitUntil( + event: WillSaveTextDocumentParams, + token: CancellationToken + ): HandlerResult { + const syncedDocument = this.__syncedDocuments.get(event.textDocument.uri); + if (syncedDocument !== undefined && this.__willSaveWaitUntil) { + return this.__willSaveWaitUntil(Object.freeze({ document: syncedDocument, reason: event.reason }), token); + } else { + return []; + } + } + + public notifyDidSaveTextDocument(event: DidSaveTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void { + const syncedDocument = this.__syncedDocuments.get(event.textDocument.uri); + if (syncedDocument !== undefined) { + this.log(syncedDocument.uri, `Saved synced document: ${syncedDocument.version} by ${clientId}`); + this.__onDidSave.fire(Object.freeze({ document: syncedDocument, clientId })); + } + } + + public notifyDidOpenTextDocument(event: DidOpenTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void { + if (this.isOpenInClient(event.textDocument.uri, clientId)) { + return; + } + const td = event.textDocument; + let document = this.__syncedDocuments.get(td.uri); + const clients = this.__clientDocuments.get(td.uri) || new Set(); + clients.add(clientId); + this.__clientDocuments.set(td.uri, clients); + if (!document) { + // no synced document yet, create new one + this.log(td.uri, `Opened new document: ${td.version} by ${clientId}`); + document = this.configuration.create(td.uri, td.languageId, td.version, td.text); + this.__syncedDocuments.set(td.uri, document); + this.__changeHistory.set(td.uri, [clientId]); + const toFire = Object.freeze({ document, clientId }); + this.__onDidOpen.fire(toFire); + this.__onDidChangeContent.fire(toFire); + } else { + // document was already synced, so we just change a content change + this.log(td.uri, `Opened synced document: ${td.version} by ${clientId}`); + const toFire = Object.freeze({ document, clientId }); + this.__onDidChangeContent.fire(toFire); + } + } + + getChangeSource(uri: string, version: number): string | undefined { + return this.__changeHistory.get(uri)?.[version]; + } + + isOpen(uri: string): boolean { + return this.__syncedDocuments.has(uri); + } + + isOpenInClient(uri: string, client: string): boolean { + return !!this.__clientDocuments.get(uri)?.has(client); + } + + isOpenInLanguageClient(uri: string): boolean { + return this.isOpenInClient(uri, LANGUAGE_CLIENT_ID); + } + + protected log(uri: string, message: string): void { + const full = URI.parse(uri); + this.logger.info(`[Documents][${basename(full.fsPath)}] ${message}`); + } } diff --git a/extensions/crossmodel-lang/syntaxes/cross-model.tmLanguage.json b/extensions/crossmodel-lang/syntaxes/cross-model.tmLanguage.json index a8c982f0..aa676d7e 100644 --- a/extensions/crossmodel-lang/syntaxes/cross-model.tmLanguage.json +++ b/extensions/crossmodel-lang/syntaxes/cross-model.tmLanguage.json @@ -10,7 +10,7 @@ }, { "name": "keyword.control.cross-model", - "match": "\\b(1:n|attributes|description|diagram|edge|entity|for|height|n:1|n:m|node|properties|relationship|source|target|type|width|with|x|y)\\b" + "match": "\\b(attributes|child|datatype|description|diagram|edges|entity|height|id|name|nodes|parent|relationship|sourceNode|targetNode|type|width|x|y)\\b" }, { "name": "string.quoted.double.cross-model", @@ -36,15 +36,25 @@ "repository": { "comments": { "patterns": [ + { + "begin": "#", + "beginCaptures": { + "1": { + "name": "punctuation.whitespace.comment.leading.cross-model" + } + }, + "end": "(?=$)", + "name": "comment.line.cross-model" + }, { "name": "comment.block.cross-model", - "begin": "/\\*", + "begin": "this_string_does_not_matter_newline#\\$%\\^&\\*\\(\\(", "beginCaptures": { "0": { "name": "punctuation.definition.comment.cross-model" } }, - "end": "\\*/", + "end": "this_string_does_not_matter_newline#\\$%\\^&\\*\\(\\(", "endCaptures": { "0": { "name": "punctuation.definition.comment.cross-model" @@ -52,14 +62,19 @@ } }, { - "begin": "//", + "name": "comment.block.cross-model", + "begin": "this_string_does_not_matter_spaces#\\$%\\^&\\*\\(\\(", "beginCaptures": { - "1": { - "name": "punctuation.whitespace.comment.leading.cross-model" + "0": { + "name": "punctuation.definition.comment.cross-model" } }, - "end": "(?=$)", - "name": "comment.line.cross-model" + "end": "this_string_does_not_matter_spaces#\\$%\\^&\\*\\(\\(", + "endCaptures": { + "0": { + "name": "punctuation.definition.comment.cross-model" + } + } } ] }, diff --git a/extensions/crossmodel-lang/test/language-server/cross-model-lang-diagram.test.ts b/extensions/crossmodel-lang/test/language-server/cross-model-lang-diagram.test.ts new file mode 100644 index 00000000..ebd10d3c --- /dev/null +++ b/extensions/crossmodel-lang/test/language-server/cross-model-lang-diagram.test.ts @@ -0,0 +1,133 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ +import { describe, expect, test } from '@jest/globals'; +import { EmptyFileSystem, isReference } from 'langium'; + +import { diagram1, diagram2, diagram3, diagram4, diagram5, diagram6 } from './test-utils/test-documents/diagram/index'; +import { parseDocument } from './test-utils/utils'; + +import { createCrossModelServices } from '../../src/language-server/cross-model-module'; +import { CrossModelRoot } from '../../src/language-server/generated/ast'; + +const services = createCrossModelServices({ ...EmptyFileSystem }).CrossModel; + +describe('CrossModel language Diagram', () => { + describe('Diagram without nodes and edges', () => { + test('Simple file for diagram', async () => { + const document = diagram1; + const parsedDocument = await parseDocument(services, document); + const model = parsedDocument.parseResult.value as CrossModelRoot; + + expect(model).toHaveProperty('diagram'); + expect(parsedDocument.parseResult.lexerErrors.length).toBe(0); + expect(parsedDocument.parseResult.parserErrors.length).toBe(0); + + expect(model.diagram?.name).toBe('Systemdiagram1'); + }); + + test('Diagram with indentation error', async () => { + const document = diagram4; + const parsedDocument = await parseDocument(services, document); + const model = parsedDocument.parseResult.value as CrossModelRoot; + + expect(model).toHaveProperty('diagram'); + expect(parsedDocument.parseResult.lexerErrors.length).toBe(0); + expect(parsedDocument.parseResult.parserErrors.length).toBe(1); + }); + }); + + describe('Diagram with nodes', () => { + test('Simple file for diagram and nodes', async () => { + const document = diagram2; + const parsedDocument = await parseDocument(services, document); + const model = parsedDocument.parseResult.value as CrossModelRoot; + const node1 = model.diagram?.nodes[0]; + + expect(model).toHaveProperty('diagram'); + expect(parsedDocument.parseResult.lexerErrors.length).toBe(0); + expect(parsedDocument.parseResult.parserErrors.length).toBe(0); + + expect(model.diagram?.nodes.length).toBe(1); + + expect(node1?.name).toBe('CustomerNode'); + expect(isReference(node1?.entity)).toBe(true); + expect(node1?.entity?.$refText).toBe('Customer'); + expect(node1?.x).toBe(100); + }); + }); + + describe('Diagram with edges', () => { + test('Simple file for diagram and edges', async () => { + const document = diagram3; + const parsedDocument = await parseDocument(services, document); + const model = parsedDocument.parseResult.value as CrossModelRoot; + const edge1 = model.diagram?.edges[0]; + + expect(model).toHaveProperty('diagram'); + expect(parsedDocument.parseResult.lexerErrors.length).toBe(0); + expect(parsedDocument.parseResult.parserErrors.length).toBe(0); + + expect(model.diagram?.edges.length).toBe(1); + + expect(edge1?.name).toBe('OrderCustomerEdge'); + expect(isReference(edge1?.relationship)).toBe(true); + expect(edge1?.relationship?.$refText).toBe('Order_Customer'); + }); + }); + + describe('Diagram with nodes and edges', () => { + test('Simple file for diagram and edges', async () => { + const document = diagram5; + const parsedDocument = await parseDocument(services, document); + const model = parsedDocument.parseResult.value as CrossModelRoot; + const node1 = model.diagram?.nodes[0]; + const edge1 = model.diagram?.edges[0]; + + expect(model).toHaveProperty('diagram'); + + expect(parsedDocument.parseResult.lexerErrors.length).toBe(0); + expect(parsedDocument.parseResult.parserErrors.length).toBe(0); + + expect(model.diagram?.name_val).toBe('System diagram 1'); + expect(model.diagram?.description).toBe('This is a basic diagram with nodes and edges'); + expect(model.diagram?.nodes.length).toBe(1); + expect(model.diagram?.edges.length).toBe(1); + + expect(node1?.name).toBe('CustomerNode'); + expect(isReference(node1?.entity)).toBe(true); + expect(node1?.entity?.$refText).toBe('Customer'); + expect(node1?.x).toBe(100); + + expect(edge1?.name).toBe('OrderCustomerEdge'); + expect(isReference(edge1?.relationship)).toBe(true); + expect(edge1?.relationship?.$refText).toBe('Order_Customer'); + }); + + test('Simple file for diagram and edges, but descirption and name coming last', async () => { + const document = diagram6; + const parsedDocument = await parseDocument(services, document); + const model = parsedDocument.parseResult.value as CrossModelRoot; + const node1 = model.diagram?.nodes[0]; + const edge1 = model.diagram?.edges[0]; + + expect(model).toHaveProperty('diagram'); + expect(parsedDocument.parseResult.lexerErrors.length).toBe(0); + expect(parsedDocument.parseResult.parserErrors.length).toBe(0); + + expect(model.diagram?.name_val).toBe('System diagram 1'); + expect(model.diagram?.description).toBe('This is a basic diagram with nodes and edges'); + expect(model.diagram?.nodes.length).toBe(1); + expect(model.diagram?.edges.length).toBe(1); + + expect(node1?.name).toBe('CustomerNode'); + expect(isReference(node1?.entity)).toBe(true); + expect(node1?.entity?.$refText).toBe('Customer'); + expect(node1?.x).toBe(100); + + expect(edge1?.name).toBe('OrderCustomerEdge'); + expect(isReference(edge1?.relationship)).toBe(true); + expect(edge1?.relationship?.$refText).toBe('Order_Customer'); + }); + }); +}); diff --git a/extensions/crossmodel-lang/test/language-server/cross-model-lang-entity.test.ts b/extensions/crossmodel-lang/test/language-server/cross-model-lang-entity.test.ts new file mode 100644 index 00000000..c9a2e7ac --- /dev/null +++ b/extensions/crossmodel-lang/test/language-server/cross-model-lang-entity.test.ts @@ -0,0 +1,79 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ + +import { describe, expect, test } from '@jest/globals'; +import { EmptyFileSystem } from 'langium'; + +import { parseDocument } from './test-utils/utils'; +import { entity1, entity2, entity3, entity4 } from './test-utils/test-documents/entity/index'; + +import { CrossModelRoot } from '../../src/language-server/generated/ast'; +import { createCrossModelServices } from '../../src/language-server/cross-model-module'; + +const services = createCrossModelServices({ ...EmptyFileSystem }).CrossModel; + +describe('CrossModel language Entity', () => { + describe('Without attributes', () => { + test('Simple file for entity', async () => { + const document = entity1; + const parsedDocument = await parseDocument(services, document); + const model = parsedDocument.parseResult.value as CrossModelRoot; + + expect(model).toHaveProperty('entity'); + expect(parsedDocument.parseResult.lexerErrors.length).toBe(0); + expect(parsedDocument.parseResult.parserErrors.length).toBe(0); + + expect(model.entity?.name).toBe('Customer'); + expect(model.entity?.name_val).toBe('Customer'); + expect(model.entity?.description).toBe('A customer with whom a transaction has been made.'); + }); + }); + + describe('With attributes', () => { + test('entity with attributes', async () => { + const document = entity2; + const parsedDocument = await parseDocument(services, document); + const model = parsedDocument.parseResult.value as CrossModelRoot; + + expect(model).toHaveProperty('entity'); + + expect(parsedDocument.parseResult.lexerErrors.length).toBe(0); + expect(parsedDocument.parseResult.parserErrors.length).toBe(0); + + expect(model.entity?.attributes.length).toBe(6); + expect(model.entity?.attributes[0].name).toBe('Id'); + expect(model.entity?.attributes[0].name_val).toBe('Id'); + expect(model.entity?.attributes[0].datatype).toBe('int'); + }); + + test('entity with attributes coming before the description and name', async () => { + const document = entity4; + const parsedDocument = await parseDocument(services, document); + const model = parsedDocument.parseResult.value as CrossModelRoot; + + expect(model).toHaveProperty('entity'); + expect(parsedDocument.parseResult.lexerErrors.length).toBe(0); + expect(parsedDocument.parseResult.parserErrors.length).toBe(0); + + expect(model.entity?.name).toBe('Customer'); + expect(model.entity?.name_val).toBe('Customer'); + expect(model.entity?.description).toBe('A customer with whom a transaction has been made.'); + + expect(model.entity?.attributes.length).toBe(6); + expect(model.entity?.attributes[0].name).toBe('Id'); + expect(model.entity?.attributes[0].name_val).toBe('Id'); + expect(model.entity?.attributes[0].datatype).toBe('int'); + }); + + test('entity with indentation error', async () => { + const document = entity3; + const parsedDocument = await parseDocument(services, document); + const model = parsedDocument.parseResult.value as CrossModelRoot; + + expect(model).toHaveProperty('entity'); + expect(parsedDocument.parseResult.lexerErrors.length).toBe(0); + expect(parsedDocument.parseResult.parserErrors.length).toBe(1); + }); + }); +}); diff --git a/extensions/crossmodel-lang/test/language-server/cross-model-lang-relationship.test.ts b/extensions/crossmodel-lang/test/language-server/cross-model-lang-relationship.test.ts new file mode 100644 index 00000000..567d96bc --- /dev/null +++ b/extensions/crossmodel-lang/test/language-server/cross-model-lang-relationship.test.ts @@ -0,0 +1,46 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ + +import { describe, expect, test } from '@jest/globals'; +import { EmptyFileSystem, isReference } from 'langium'; + +import { parseDocument } from './test-utils/utils'; +import { relationship1, relationship2 } from './test-utils/test-documents/relationship/index'; + +import { CrossModelRoot } from '../../src/language-server/generated/ast'; +import { createCrossModelServices } from '../../src/language-server/cross-model-module'; + +const services = createCrossModelServices({ ...EmptyFileSystem }).CrossModel; + +describe('CrossModel language Relationship', () => { + test('Simple file for relationship', async () => { + const document = relationship1; + const parsedDocument = await parseDocument(services, document); + const model = parsedDocument.parseResult.value as CrossModelRoot; + + expect(model).toHaveProperty('relationship'); + expect(parsedDocument.parseResult.lexerErrors.length).toBe(0); + expect(parsedDocument.parseResult.parserErrors.length).toBe(0); + + expect(model.relationship?.name).toBe('Order_Customer'); + expect(model.relationship?.name_val).toBe('Customer Order relationship'); + expect(model.relationship?.type).toBe('1:1'); + expect(model.relationship?.description).toBe('A relationship between a customer and an order.'); + + expect(isReference(model.relationship?.parent)).toBe(true); + expect(isReference(model.relationship?.child)).toBe(true); + expect(model.relationship?.parent?.$refText).toBe('Customer'); + expect(model.relationship?.child?.$refText).toBe('Order'); + }); + + test('relationship with indentation error', async () => { + const document = relationship2; + const parsedDocument = await parseDocument(services, document); + const model = parsedDocument.parseResult.value as CrossModelRoot; + + expect(model).toHaveProperty('relationship'); + expect(parsedDocument.parseResult.lexerErrors.length).toBe(0); + expect(parsedDocument.parseResult.parserErrors.length).toBe(1); + }); +}); diff --git a/extensions/crossmodel-lang/test/language-server/lexer/cross-model-indent-stack.test.ts b/extensions/crossmodel-lang/test/language-server/lexer/cross-model-indent-stack.test.ts new file mode 100644 index 00000000..0a2d1fe6 --- /dev/null +++ b/extensions/crossmodel-lang/test/language-server/lexer/cross-model-indent-stack.test.ts @@ -0,0 +1,129 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ + +import { describe, expect, test, beforeEach } from '@jest/globals'; +import { indentStack } from '../../../src/language-server/lexer/cross-model-indent-stack'; + +describe('IndentStack', () => { + beforeEach(() => { + indentStack.reset(); + }); + + describe('get', () => { + test('should return a copy of the current stack', () => { + indentStack.push(2); + indentStack.push(4); + + const stack = indentStack.get(); + + expect(stack).toEqual([0, 2, 4]); + }); + + test('should return an [0] if the stack is empty', () => { + const stack = indentStack.get(); + + expect(stack).toEqual([0]); + }); + + test('should not modify the original stack when modifying the returned array', () => { + indentStack.push(2); + indentStack.push(4); + + const stack = indentStack.get(); + stack.pop(); // Modify the returned array + + expect(stack).toEqual([0, 2]); // The returned array is modified + expect(indentStack.get()).toEqual([0, 2, 4]); // The original stack remains unchanged + }); + }); + + describe('push', () => { + test('should push the given value onto the stack', () => { + indentStack.push(2); + expect(indentStack.get()).toEqual([0, 2]); + }); + + test('should push multiple values onto the stack', () => { + indentStack.push(2); + indentStack.push(4); + indentStack.push(6); + expect(indentStack.get()).toEqual([0, 2, 4, 6]); + }); + }); + + describe('reset', () => { + test('should reset the stack to contain only the initial indentation level', () => { + indentStack.push(2); + indentStack.push(4); + indentStack.reset(); + expect(indentStack.get()).toEqual([0]); + }); + }); + + describe('pop', () => { + test('should remove and return the topmost indentation level from the stack', () => { + indentStack.push(2); + + const poppedValue = indentStack.pop(); + + expect(poppedValue).toBe(2); + expect(indentStack.get()).toEqual([0]); + }); + + test('should return undefined if the stack is empty', () => { + // Pop 0 after reset + const poppedValue1 = indentStack.pop(); + const poppedValue2 = indentStack.pop(); + + expect(poppedValue1).toBe(0); + expect(poppedValue2).toBeUndefined(); + }); + }); + + describe('length', () => { + test('should return 1 when only the initial indentation level is present', () => { + expect(indentStack.length()).toBe(1); + }); + + test('should return the length of indentation levels in the stack', () => { + indentStack.push(2); + indentStack.push(4); + expect(indentStack.length()).toBe(3); + }); + }); + + describe('getLast', () => { + test('should return the last indentation level from the stack', () => { + indentStack.push(2); + indentStack.push(4); + + const lastValue = indentStack.getLast(); + + expect(lastValue).toBe(4); + }); + + test('should throw an IndentStackError if the stack is empty', () => { + indentStack.pop(); + + expect(() => indentStack.getLast()).toThrow(); + }); + }); + + describe('findLastIndex', () => { + test('should return the index of the last occurrence of the given value in the stack', () => { + indentStack.push(2); + indentStack.push(4); + indentStack.push(2); + + const lastIndex = indentStack.findLastIndex(2); + + expect(lastIndex).toBe(3); + }); + + test('should return -1 if the given value is not found in the stack', () => { + const lastIndex = indentStack.findLastIndex(2); + expect(lastIndex).toBe(-1); + }); + }); +}); diff --git a/extensions/crossmodel-lang/test/language-server/lexer/cross-model-indentation-tokens.test.ts b/extensions/crossmodel-lang/test/language-server/lexer/cross-model-indentation-tokens.test.ts new file mode 100644 index 00000000..d431b4b1 --- /dev/null +++ b/extensions/crossmodel-lang/test/language-server/lexer/cross-model-indentation-tokens.test.ts @@ -0,0 +1,306 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ +import { describe, expect, test, beforeEach, beforeAll } from '@jest/globals'; +import { Lexer, TokenType, createToken, tokenMatcher } from 'chevrotain'; + +import { SPACES, NEWLINE, INDENT, DEDENT } from '../../../src/language-server/lexer/cross-model-indentation-tokens'; +import { indentStack } from '../../../src/language-server/lexer/cross-model-indent-stack'; + +describe('matchIndentBase', () => { + let TESTTOKEN: TokenType; + let LINETOKEN: TokenType; + let testLexer: Lexer; + + beforeAll(() => { + TESTTOKEN = createToken({ + name: 'TESTTOKEN', + pattern: /TESTTOKEN/ + }); + + LINETOKEN = createToken({ + name: 'LINETOKEN', + pattern: /-/ + }); + + testLexer = new Lexer([NEWLINE, DEDENT, INDENT, LINETOKEN, TESTTOKEN, SPACES]); + }); + + beforeEach(() => { + indentStack.reset(); + }); + + describe('SPACES token', () => { + test('should not produce a token for spaces between words', () => { + const input = 'TESTTOKEN TESTTOKEN'; + + const lexResult = testLexer.tokenize(input); + + expect(lexResult.tokens).toHaveLength(2); + lexResult.tokens.map(token => { + expect(tokenMatcher(token, TESTTOKEN)).toBe(true); + }); + }); + + test('should not produce a token for spaces at the end of a line', () => { + const input = 'TESTTOKEN '; + const lexResult = testLexer.tokenize(input); + + expect(lexResult.tokens).toHaveLength(1); + lexResult.tokens.map(token => { + expect(tokenMatcher(token, TESTTOKEN)).toBe(true); + }); + }); + }); + + describe('NEWLINE token', () => { + test('should match a newline character', () => { + const input = '\n'; + const lexResult = testLexer.tokenize(input); + + expect(lexResult.tokens).toHaveLength(0); + expect(lexResult.groups).toHaveProperty('nl'); + expect(lexResult.groups.nl).toHaveLength(1); + expect(tokenMatcher(lexResult.groups.nl[0], NEWLINE)).toBe(true); + }); + + test('should match a newline character with carriage return', () => { + const input = '\r\n'; + const lexResult = testLexer.tokenize(input); + + expect(lexResult.tokens).toHaveLength(0); + expect(lexResult.groups).toHaveProperty('nl'); + expect(lexResult.groups.nl).toHaveLength(1); + expect(tokenMatcher(lexResult.groups.nl[0], NEWLINE)).toBe(true); + }); + + test('should match a newline in the middle of a line', () => { + const input = 'TESTTOKEN\nTESTTOKEN'; + const lexResult = testLexer.tokenize(input); + + expect(lexResult.tokens).toHaveLength(2); + expect(tokenMatcher(lexResult.tokens[0], TESTTOKEN)).toBe(true); + expect(tokenMatcher(lexResult.tokens[1], TESTTOKEN)).toBe(true); + + expect(lexResult.groups).toHaveProperty('nl'); + expect(lexResult.groups.nl).toHaveLength(1); + expect(tokenMatcher(lexResult.groups.nl[0], NEWLINE)).toBe(true); + }); + + test('should match a newline preceded by spaces', () => { + const input = ' \n'; + const lexResult = testLexer.tokenize(input); + + expect(lexResult.groups).toHaveProperty('nl'); + expect(lexResult.groups.nl).toHaveLength(1); + expect(tokenMatcher(lexResult.groups.nl[0], NEWLINE)).toBe(true); + expect(lexResult.groups.nl[0].image).toBe('\n'); + }); + + test('should match a newline followed by spaces', () => { + const input = '\n '; + const lexResult = testLexer.tokenize(input); + + expect(lexResult.groups).toHaveProperty('nl'); + expect(lexResult.groups.nl).toHaveLength(1); + expect(tokenMatcher(lexResult.groups.nl[0], NEWLINE)).toBe(true); + expect(lexResult.groups.nl[0].image).toBe('\n'); + }); + }); + + describe('INDENT token', () => { + test('should match indentation at the start of a line', () => { + const input = ' '; + const lexResult = testLexer.tokenize(input); + + expect(lexResult.tokens).toHaveLength(1); + expect(tokenMatcher(lexResult.tokens[0], INDENT)).toBe(true); + expect(lexResult.tokens[0].image).toBe(input); + expect(indentStack.getLast()).toBe(4); + }); + + test('should match indentation at the start of a line, when there are other token after it', () => { + const input = ' TESTTOKEN'; + const lexResult = testLexer.tokenize(input); + + expect(lexResult.tokens).toHaveLength(2); + expect(tokenMatcher(lexResult.tokens[0], INDENT)).toBe(true); + expect(tokenMatcher(lexResult.tokens[1], TESTTOKEN)).toBe(true); + expect(lexResult.tokens[0].image).toBe(' '); + // Check what the current indentation is + expect(indentStack.getLast()).toBe(4); + }); + + test('should match indentation at the start of a line', () => { + const input = 'TESTTOKEN\n '; + const lexResult = testLexer.tokenize(input); + + expect(lexResult.tokens).toHaveLength(2); + expect(tokenMatcher(lexResult.tokens[0], TESTTOKEN)).toBe(true); + expect(tokenMatcher(lexResult.tokens[1], INDENT)).toBe(true); + expect(lexResult.tokens[1].image).toBe(' '); + // Check what the current indentation is + expect(indentStack.getLast()).toBe(4); + }); + + test('should match indentation when only new lines preceding', () => { + const input = '\n\n\n\n '; + const lexResult = testLexer.tokenize(input); + + expect(lexResult.tokens).toHaveLength(1); + expect(tokenMatcher(lexResult.tokens[0], INDENT)).toBe(true); + expect(lexResult.tokens[0].image).toBe(' '); + expect(indentStack.getLast()).toBe(4); + expect(lexResult.groups.nl).toHaveLength(4); + }); + + test('Should only match follow up indentation', () => { + const input = ' \n '; + const lexResult = testLexer.tokenize(input); + + expect(lexResult.tokens).toHaveLength(2); + expect(lexResult.groups.nl).toHaveLength(1); + + expect(tokenMatcher(lexResult.tokens[0], INDENT)).toBe(true); + expect(lexResult.tokens[0].image).toBe(' '); + expect(tokenMatcher(lexResult.tokens[1], INDENT)).toBe(true); + expect(lexResult.tokens[1].image).toBe(' '); + expect(indentStack.getLast()).toBe(6); + }); + + // Should not match + test('should not match indentation after another token', () => { + const input = 'TESTTOKEN '; + const lexResult = testLexer.tokenize(input); + + expect(lexResult.tokens).toHaveLength(1); + expect(tokenMatcher(lexResult.tokens[0], TESTTOKEN)).toBe(true); + expect(indentStack.getLast()).toBe(0); + }); + + test('Should not match sucessive same indentation level', () => { + const input = ' \n '; + const lexResult = testLexer.tokenize(input); + + expect(lexResult.tokens).toHaveLength(1); + expect(lexResult.groups.nl).toHaveLength(1); + + expect(tokenMatcher(lexResult.tokens[0], INDENT)).toBe(true); + expect(lexResult.tokens[0].image).toBe(' '); + expect(indentStack.getLast()).toBe(4); + }); + + test('Should not match lower indentation', () => { + const input = ' \n \n '; + const lexResult = testLexer.tokenize(input); + + expect(lexResult.tokens).toHaveLength(3); + expect(lexResult.groups.nl).toHaveLength(2); + + expect(tokenMatcher(lexResult.tokens[0], INDENT)).toBe(true); + expect(lexResult.tokens[0].image).toBe(' '); + expect(tokenMatcher(lexResult.tokens[1], INDENT)).toBe(true); + expect(lexResult.tokens[1].image).toBe(' '); + expect(tokenMatcher(lexResult.tokens[2], DEDENT)).toBe(true); + expect(indentStack.getLast()).toBe(2); + }); + }); + + describe('INDENT token and lists(-)', () => { + test('should match a single level of indentation at the start of a line with -', () => { + const input = ' - '; + const lexResult = testLexer.tokenize(input); + + expect(lexResult.tokens).toHaveLength(2); + expect(lexResult.tokens[0].image).toBe(' '); + expect(tokenMatcher(lexResult.tokens[0], INDENT)).toBe(true); + expect(tokenMatcher(lexResult.tokens[1], LINETOKEN)).toBe(true); + expect(indentStack.getLast()).toBe(6); + }); + + test('should match a indentation after indentation with -', () => { + const input = ' - \n '; + const lexResult = testLexer.tokenize(input); + + expect(lexResult.tokens).toHaveLength(3); + expect(lexResult.tokens[0].image).toBe(' '); + expect(tokenMatcher(lexResult.tokens[0], INDENT)).toBe(true); + + expect(tokenMatcher(lexResult.tokens[1], LINETOKEN)).toBe(true); + + expect(lexResult.tokens[2].image).toBe(' '); + expect(tokenMatcher(lexResult.tokens[2], INDENT)).toBe(true); + expect(indentStack.getLast()).toBe(8); + }); + + test('should match a dedentation after indentation with -', () => { + const input = ' \n - \n '; + const lexResult = testLexer.tokenize(input); + + expect(lexResult.tokens).toHaveLength(4); + expect(lexResult.tokens[0].image).toBe(' '); + expect(tokenMatcher(lexResult.tokens[0], INDENT)).toBe(true); + + expect(tokenMatcher(lexResult.tokens[1], INDENT)).toBe(true); + expect(lexResult.tokens[1].image).toBe(' '); + + expect(tokenMatcher(lexResult.tokens[2], LINETOKEN)).toBe(true); + + expect(tokenMatcher(lexResult.tokens[3], DEDENT)).toBe(true); + expect(indentStack.getLast()).toBe(2); + }); + + test('should not match second indentation with same level', () => { + const input = ' - \n '; + const lexResult = testLexer.tokenize(input); + + expect(lexResult.tokens).toHaveLength(2); + expect(lexResult.tokens[0].image).toBe(' '); + expect(tokenMatcher(lexResult.tokens[0], INDENT)).toBe(true); + expect(tokenMatcher(lexResult.tokens[1], LINETOKEN)).toBe(true); + expect(indentStack.getLast()).toBe(6); + }); + }); + + describe('DEDENT token', () => { + test('should match a dedentation', () => { + const input = ' \n \n '; + + const lexResult = testLexer.tokenize(input); + + expect(lexResult.tokens).toHaveLength(3); + expect(lexResult.tokens[0].image).toBe(' '); + expect(tokenMatcher(lexResult.tokens[0], INDENT)).toBe(true); + + expect(tokenMatcher(lexResult.tokens[1], INDENT)).toBe(true); + expect(lexResult.tokens[1].image).toBe(' '); + + expect(tokenMatcher(lexResult.tokens[2], DEDENT)).toBe(true); + expect(indentStack.getLast()).toBe(2); + }); + + test('should match a dedentation after dedentation', () => { + const input = ' \n \n \n \n '; + + const lexResult = testLexer.tokenize(input); + + expect(lexResult.tokens).toHaveLength(5); + expect(tokenMatcher(lexResult.tokens[0], INDENT)).toBe(true); + expect(tokenMatcher(lexResult.tokens[1], INDENT)).toBe(true); + expect(tokenMatcher(lexResult.tokens[2], INDENT)).toBe(true); + + expect(tokenMatcher(lexResult.tokens[3], DEDENT)).toBe(true); + expect(tokenMatcher(lexResult.tokens[4], DEDENT)).toBe(true); + expect(indentStack.getLast()).toBe(2); + }); + + test('should not match a dedentation whens on the same level', () => { + const input = ' \n '; + const lexResult = testLexer.tokenize(input); + + expect(lexResult.tokens).toHaveLength(1); + expect(lexResult.tokens[0].image).toBe(' '); + expect(tokenMatcher(lexResult.tokens[0], INDENT)).toBe(true); + }); + }); +}); diff --git a/extensions/crossmodel-lang/test/language-server/lexer/cross-model-lexer.test.ts b/extensions/crossmodel-lang/test/language-server/lexer/cross-model-lexer.test.ts new file mode 100644 index 00000000..46338d94 --- /dev/null +++ b/extensions/crossmodel-lang/test/language-server/lexer/cross-model-lexer.test.ts @@ -0,0 +1,95 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ + +import { describe, expect, test, beforeAll } from '@jest/globals'; +import { EmptyFileSystem } from 'langium'; +import { tokenMatcher } from 'chevrotain'; + +import { CrossModelLexer } from '../../../src/language-server/lexer/cross-model-lexer'; +import { DEDENT, INDENT } from '../../../src/language-server/lexer/cross-model-indentation-tokens'; +import { createCrossModelServices } from '../../../src/language-server/cross-model-module'; + +const services = createCrossModelServices({ ...EmptyFileSystem }).CrossModel; + +describe('CrossModelLexer', () => { + let crossModelLexer: CrossModelLexer; + + beforeAll(() => { + crossModelLexer = new CrossModelLexer(services); + }); + + describe('Simple keywords', () => { + test('should tokenize a simple word', () => { + const input = 'entity'; + + const lexResult = crossModelLexer.tokenize(input); + + expect(lexResult.tokens).toHaveLength(1); + lexResult.tokens.map(token => { + expect(token.image).toBe('entity'); + }); + }); + + test('should tokenize a couple of simple words', () => { + const input = 'entity entity entity'; + + const lexResult = crossModelLexer.tokenize(input); + + expect(lexResult.tokens).toHaveLength(3); + lexResult.tokens.map(token => { + expect(token.image).toBe('entity'); + }); + }); + }); + + describe('Indentation', () => { + test('Simple indentation, should give indent and dedent token', () => { + const input = ' '; + + const lexResult = crossModelLexer.tokenize(input); + + expect(lexResult.tokens).toHaveLength(2); + + expect(tokenMatcher(lexResult.tokens[0], INDENT)).toBe(true); + expect(tokenMatcher(lexResult.tokens[1], DEDENT)).toBe(true); + }); + + test('single indentation but stay on same level, should give 1 indent and 1 dedent token', () => { + const input = ' \n '; + + const lexResult = crossModelLexer.tokenize(input); + + expect(lexResult.tokens).toHaveLength(2); + + expect(tokenMatcher(lexResult.tokens[0], INDENT)).toBe(true); + expect(tokenMatcher(lexResult.tokens[1], DEDENT)).toBe(true); + }); + + test('double indentation, should give indent and dedent token', () => { + const input = ' \n '; + + const lexResult = crossModelLexer.tokenize(input); + + expect(lexResult.tokens).toHaveLength(4); + + expect(tokenMatcher(lexResult.tokens[0], INDENT)).toBe(true); + expect(tokenMatcher(lexResult.tokens[1], INDENT)).toBe(true); + expect(tokenMatcher(lexResult.tokens[2], DEDENT)).toBe(true); + expect(tokenMatcher(lexResult.tokens[3], DEDENT)).toBe(true); + }); + + test('double indentation, but dedent in text', () => { + const input = ' \n \n '; + + const lexResult = crossModelLexer.tokenize(input); + + expect(lexResult.tokens).toHaveLength(4); + + expect(tokenMatcher(lexResult.tokens[0], INDENT)).toBe(true); + expect(tokenMatcher(lexResult.tokens[1], INDENT)).toBe(true); + expect(tokenMatcher(lexResult.tokens[2], DEDENT)).toBe(true); + expect(tokenMatcher(lexResult.tokens[3], DEDENT)).toBe(true); + }); + }); +}); diff --git a/extensions/crossmodel-lang/test/language-server/lexer/cross-model-token-generator.test.ts b/extensions/crossmodel-lang/test/language-server/lexer/cross-model-token-generator.test.ts new file mode 100644 index 00000000..211f5ac5 --- /dev/null +++ b/extensions/crossmodel-lang/test/language-server/lexer/cross-model-token-generator.test.ts @@ -0,0 +1,63 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ + +import { describe, expect, test, beforeAll } from '@jest/globals'; + +import { Grammar } from 'langium'; +import { TokenType } from 'chevrotain'; + +import { DEDENT, INDENT, NEWLINE, SPACES } from '../../../src/language-server/lexer/cross-model-indentation-tokens'; +import { CrossModelTokenBuilder } from '../../../src/language-server/lexer/cross-model-token-generator'; +import { CrossModelGrammar } from '../../../src/language-server/generated/grammar'; +import _ from 'lodash'; + +describe('CrossModelTokenBuilder', () => { + let tokenBuilder: CrossModelTokenBuilder; + let crossModelGrammer: Grammar; + let crossModelGrammerWithoutIndentation: Grammar; + + beforeAll(() => { + tokenBuilder = new CrossModelTokenBuilder(); + crossModelGrammer = CrossModelGrammar(); + // CrossModelGrammar loads the grammar in memory instead of making a new grammar + crossModelGrammerWithoutIndentation = _.cloneDeep(crossModelGrammer); + + crossModelGrammerWithoutIndentation.rules = crossModelGrammerWithoutIndentation.rules.filter( + rule => ![DEDENT.name, INDENT.name, NEWLINE.name, SPACES.name].includes(rule.name) + ); + }); + + describe('buildTokens', () => { + test('Should give NEWLINE token in first spot', () => { + const tokens = tokenBuilder.buildTokens(crossModelGrammer) as TokenType[]; + + expect(tokens[0]).toBe(NEWLINE); + }); + + test('Should give DEDENT token in second spot', () => { + const tokens = tokenBuilder.buildTokens(crossModelGrammer) as TokenType[]; + + expect(tokens[1]).toBe(DEDENT); + }); + + test('Should give INDENT token in third spot', () => { + const tokens = tokenBuilder.buildTokens(crossModelGrammer) as TokenType[]; + + expect(tokens[2]).toBe(INDENT); + }); + + test('Should give SPACE token in last spot', () => { + const tokens = tokenBuilder.buildTokens(crossModelGrammer) as TokenType[]; + + const spaceToken = tokens.pop(); + expect(spaceToken).toBe(SPACES); + }); + + test('Should throw error when missing indentation in grammar', () => { + expect(() => { + tokenBuilder.buildTokens(crossModelGrammerWithoutIndentation) as TokenType[]; + }).toThrow(new Error('Missing indentation, new line or spaces tokens in grammar')); + }); + }); +}); diff --git a/extensions/crossmodel-lang/test/language-server/serializer/cross-model-serializer.test.ts b/extensions/crossmodel-lang/test/language-server/serializer/cross-model-serializer.test.ts new file mode 100644 index 00000000..d6d387c6 --- /dev/null +++ b/extensions/crossmodel-lang/test/language-server/serializer/cross-model-serializer.test.ts @@ -0,0 +1,281 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ + +import { beforeAll, describe, expect, test } from '@jest/globals'; +import { EmptyFileSystem, Reference } from 'langium'; + +import _ from 'lodash'; +import { createCrossModelServices } from '../../../src/language-server/cross-model-module'; +import { CrossModelSerializer } from '../../../src/language-server/cross-model-serializer'; +import { CrossModelRoot, Entity, Relationship } from '../../../src/language-server/generated/ast'; + +const services = createCrossModelServices({ ...EmptyFileSystem }).CrossModel; + +describe('CrossModelLexer', () => { + let serializer: CrossModelSerializer; + + beforeAll(() => { + serializer = new CrossModelSerializer(services); + }); + + describe('Serialize entity', () => { + let crossModelRoot: CrossModelRoot; + let crossModelRootwithoutAttributes: CrossModelRoot; + let crossModelRootwithAttributesDifPlace: CrossModelRoot; + + beforeAll(() => { + crossModelRoot = { + $type: 'CrossModelRoot' + }; + + crossModelRootwithAttributesDifPlace = _.cloneDeep(crossModelRoot); + + crossModelRoot.entity = { + $container: crossModelRoot, + $type: 'Entity', + description: 'Test description', + name: 'test id', + name_val: 'test Name', + attributes: [] + }; + + crossModelRootwithoutAttributes = _.cloneDeep(crossModelRoot); + + crossModelRoot.entity.attributes = [ + { $container: crossModelRoot.entity, $type: 'EntityAttribute', name: 'Attribute 1', datatype: 'Datatype Attribute 1' }, + { $container: crossModelRoot.entity, $type: 'EntityAttribute', name: 'Attribute 2', datatype: 'Datatype Attribute 2' } + ]; + + crossModelRootwithAttributesDifPlace.entity = { + $container: crossModelRoot, + $type: 'Entity', + description: 'Test description', + attributes: [], + name: 'test id', + name_val: 'test Name' + }; + crossModelRootwithAttributesDifPlace.entity.attributes = [ + { $container: crossModelRoot.entity, $type: 'EntityAttribute', name: 'Attribute 1', datatype: 'Datatype Attribute 1' }, + { $container: crossModelRoot.entity, $type: 'EntityAttribute', name: 'Attribute 2', datatype: 'Datatype Attribute 2' } + ]; + }); + + test('serialize entity with attributes', () => { + const parseResult = serializer.serialize(crossModelRoot); + expect(parseResult).toBe(expected_result); + }); + + test('serialize entity without attributes', () => { + const parseResult = serializer.serialize(crossModelRootwithoutAttributes); + + expect(parseResult).toBe(expected_result2); + }); + + test('serialize entity with attributes in different place', () => { + const parseResult = serializer.serialize(crossModelRootwithAttributesDifPlace); + + expect(parseResult).toBe(expected_result3); + }); + }); + + describe('Serialize relationship', () => { + let crossModelRoot: CrossModelRoot; + + beforeAll(() => { + crossModelRoot = { + $type: 'CrossModelRoot' + }; + + const ref1: Reference = { + $refText: 'Ref1', + ref: { + $container: crossModelRoot, + $type: 'Entity', + description: 'Test description', + attributes: [], + name: 'Ref1', + name_val: 'test Name' + } + }; + + const ref2: Reference = { + $refText: 'Ref2', + ref: { + $container: crossModelRoot, + $type: 'Entity', + description: 'Test description', + attributes: [], + name: 'Ref2', + name_val: 'test Name' + } + }; + + crossModelRoot.relationship = { + $container: crossModelRoot, + $type: 'Relationship', + description: 'Test description', + name: 'test id', + name_val: 'test Name', + parent: ref1, + child: ref2, + type: 'n:m' + }; + }); + + test('serialize entity with attributes', () => { + const parseResult = serializer.serialize(crossModelRoot); + expect(parseResult).toBe(expected_result4); + }); + }); + + describe('Serialize diagram', () => { + let crossModelRoot: CrossModelRoot; + + beforeAll(() => { + crossModelRoot = { + $type: 'CrossModelRoot' + }; + + const ref1: Reference = { + $refText: 'Ref1', + ref: { + $container: crossModelRoot, + $type: 'Entity', + description: 'Test description', + attributes: [], + name: 'Ref1', + name_val: 'test Name' + } + }; + + const ref2: Reference = { + $refText: 'Ref2', + ref: { + $container: crossModelRoot, + $type: 'Entity', + description: 'Test description', + attributes: [], + name: 'Ref2', + name_val: 'test Name' + } + }; + + const ref3: Reference = { + $refText: 'Ref3', + ref: { + $container: crossModelRoot, + $type: 'Relationship', + description: 'Test description', + name: 'test id', + name_val: 'test Name', + parent: ref1, + child: ref2, + type: 'n:m' + } + }; + + crossModelRoot.diagram = { + $container: crossModelRoot, + $type: 'SystemDiagram', + description: 'Test description', + name: 'test id', + name_val: 'test Name', + nodes: [], + edges: [] + }; + + crossModelRoot.diagram.nodes = [ + { + $container: crossModelRoot.diagram, + $type: 'DiagramNode', + x: 100, + y: 101, + width: 102, + height: 102, + entity: ref1, + name: 'Node1', + name_val: 'Node 1' + }, + { + $container: crossModelRoot.diagram, + $type: 'DiagramNode', + x: 100, + y: 101, + width: 102, + height: 102, + entity: ref2, + name: 'Node2', + name_val: 'Node 2' + } + ]; + + crossModelRoot.diagram.edges = [ + { + $container: crossModelRoot.diagram, + $type: 'DiagramEdge', + relationship: ref3, + name: 'Edge1' + } + ]; + }); + + test('serialize entity with attributes', () => { + const parseResult = serializer.serialize(crossModelRoot); + expect(parseResult).toBe(expected_result5); + }); + }); +}); + +const expected_result = `entity: + id: "test id" + name: "test Name" + description: "Test description" + attributes: + - id: "Attribute 1" + datatype: "Datatype Attribute 1" + - id: "Attribute 2" + datatype: "Datatype Attribute 2"`; +const expected_result2 = `entity: + id: "test id" + name: "test Name" + description: "Test description"`; +const expected_result3 = `entity: + id: "test id" + name: "test Name" + description: "Test description" + attributes: + - id: "Attribute 1" + datatype: "Datatype Attribute 1" + - id: "Attribute 2" + datatype: "Datatype Attribute 2"`; + +const expected_result4 = `relationship: + id: "test id" + name: "test Name" + description: "Test description" + parent: "Ref1" + child: "Ref2" + type: "n:m"`; +const expected_result5 = `diagram: + id: "test id" + name: "test Name" + description: "Test description" + nodes: + - id: "Node1" + name: "Node 1" + entity: "Ref1" + x: 100 + y: 101 + width: 102 + height: 102 + - id: "Node2" + name: "Node 2" + entity: "Ref2" + x: 100 + y: 101 + width: 102 + height: 102 + edges: + - id: "Edge1" + relationship: "Ref3"`; diff --git a/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/diagram/diagram1.ts b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/diagram/diagram1.ts new file mode 100644 index 00000000..493fbe17 --- /dev/null +++ b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/diagram/diagram1.ts @@ -0,0 +1,5 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ +export const diagram1 = `diagram: + id: "Systemdiagram1"`; diff --git a/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/diagram/diagram2.ts b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/diagram/diagram2.ts new file mode 100644 index 00000000..b4027f25 --- /dev/null +++ b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/diagram/diagram2.ts @@ -0,0 +1,12 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ +export const diagram2 = `diagram: + id: "Systemdiagram1" + nodes: + - id: 'CustomerNode' + entity: 'Customer' + x: 100 + y: 100 + height: 100 + width: 100`; diff --git a/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/diagram/diagram3.ts b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/diagram/diagram3.ts new file mode 100644 index 00000000..61ae46a4 --- /dev/null +++ b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/diagram/diagram3.ts @@ -0,0 +1,8 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ +export const diagram3 = `diagram: + id: "Systemdiagram1" + edges: + - id: 'OrderCustomerEdge' + relationship: 'Order_Customer'`; diff --git a/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/diagram/diagram4.ts b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/diagram/diagram4.ts new file mode 100644 index 00000000..bb4fbdea --- /dev/null +++ b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/diagram/diagram4.ts @@ -0,0 +1,5 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ +export const diagram4 = `diagram: +id: "Systemdiagram1"`; diff --git a/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/diagram/diagram5.ts b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/diagram/diagram5.ts new file mode 100644 index 00000000..901abb8a --- /dev/null +++ b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/diagram/diagram5.ts @@ -0,0 +1,17 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ +export const diagram5 = `diagram: + id: "Systemdiagram1" + name: "System diagram 1" + description: "This is a basic diagram with nodes and edges" + edges: + - id: 'OrderCustomerEdge' + relationship: 'Order_Customer' + nodes: + - id: 'CustomerNode' + entity: 'Customer' + x: 100 + y: 100 + height: 100 + width: 100`; diff --git a/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/diagram/diagram6.ts b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/diagram/diagram6.ts new file mode 100644 index 00000000..4692cf1b --- /dev/null +++ b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/diagram/diagram6.ts @@ -0,0 +1,17 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ +export const diagram6 = `diagram: + id: "Systemdiagram1" + edges: + - id: 'OrderCustomerEdge' + relationship: 'Order_Customer' + nodes: + - id: 'CustomerNode' + entity: 'Customer' + x: 100 + y: 100 + height: 100 + width: 100 + name: "System diagram 1" + description: "This is a basic diagram with nodes and edges"`; diff --git a/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/diagram/index.ts b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/diagram/index.ts new file mode 100644 index 00000000..8cc7d55f --- /dev/null +++ b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/diagram/index.ts @@ -0,0 +1,9 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ +export * from './diagram1'; +export * from './diagram2'; +export * from './diagram3'; +export * from './diagram4'; +export * from './diagram5'; +export * from './diagram6'; diff --git a/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/entity/entity1.ts b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/entity/entity1.ts new file mode 100644 index 00000000..2673bf71 --- /dev/null +++ b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/entity/entity1.ts @@ -0,0 +1,7 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ +export const entity1 = `entity: + id: 'Customer' + name: 'Customer' + description: 'A customer with whom a transaction has been made.'`; diff --git a/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/entity/entity2.ts b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/entity/entity2.ts new file mode 100644 index 00000000..4c471bc1 --- /dev/null +++ b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/entity/entity2.ts @@ -0,0 +1,26 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ +export const entity2 = `entity: + id: 'Customer' + name: 'Customer' + description: 'A customer with whom a transaction has been made.' + attributes: + - id: 'Id' + name: 'Id' + datatype: 'int' + - id: 'FirstName' + name: 'FirstName' + datatype: 'varchar' + - id: 'LastName' + name: 'LastName' + datatype: 'varchar' + - id: 'City' + name: 'City' + datatype: 'varchar' + - id: 'Country' + name: 'Country' + datatype: 'varchar' + - id: 'Phone' + name: 'Phone' + datatype: 'varchar'`; diff --git a/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/entity/entity3.ts b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/entity/entity3.ts new file mode 100644 index 00000000..7dd97165 --- /dev/null +++ b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/entity/entity3.ts @@ -0,0 +1,26 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ +export const entity3 = `entity: + id: 'Customer' + name: 'Customer' + description: 'A customer with whom a transaction has been made.' +attributes: + - id: 'Id' + name: 'Id' + datatype: 'int' + - id: 'FirstName' + name: 'FirstName' + datatype: 'varchar' + - id: 'LastName' + name: 'LastName' + datatype: 'varchar' + - id: 'City' + name: 'City' + datatype: 'varchar' + - id: 'Country' + name: 'Country' + datatype: 'varchar' + - id: 'Phone' + name: 'Phone' + datatype: 'varchar'`; diff --git a/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/entity/entity4.ts b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/entity/entity4.ts new file mode 100644 index 00000000..30a2b35a --- /dev/null +++ b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/entity/entity4.ts @@ -0,0 +1,26 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ +export const entity4 = `entity: + id: 'Customer' + attributes: + - id: 'Id' + name: 'Id' + datatype: 'int' + - id: 'FirstName' + name: 'FirstName' + datatype: 'varchar' + - id: 'LastName' + name: 'LastName' + datatype: 'varchar' + - id: 'City' + name: 'City' + datatype: 'varchar' + - id: 'Country' + name: 'Country' + datatype: 'varchar' + - id: 'Phone' + name: 'Phone' + datatype: 'varchar' + name: 'Customer' + description: 'A customer with whom a transaction has been made.'`; diff --git a/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/entity/index.ts b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/entity/index.ts new file mode 100644 index 00000000..c96f8df6 --- /dev/null +++ b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/entity/index.ts @@ -0,0 +1,7 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ +export * from './entity1'; +export * from './entity2'; +export * from './entity3'; +export * from './entity4'; diff --git a/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/relationship/index.ts b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/relationship/index.ts new file mode 100644 index 00000000..9eee07d4 --- /dev/null +++ b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/relationship/index.ts @@ -0,0 +1,5 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ +export * from './relationship1'; +export * from './relationship2'; diff --git a/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/relationship/relationship1.ts b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/relationship/relationship1.ts new file mode 100644 index 00000000..7eda160e --- /dev/null +++ b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/relationship/relationship1.ts @@ -0,0 +1,10 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ +export const relationship1 = `relationship: + id: 'Order_Customer' + parent: 'Customer' + child: 'Order' + type: "1:1" + name: "Customer Order relationship" + description: "A relationship between a customer and an order."`; diff --git a/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/relationship/relationship2.ts b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/relationship/relationship2.ts new file mode 100644 index 00000000..53d57d99 --- /dev/null +++ b/extensions/crossmodel-lang/test/language-server/test-utils/test-documents/relationship/relationship2.ts @@ -0,0 +1,8 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ +export const relationship2 = `relationship: + id: 'Order_Customer' +parent: 'Customer' + child: 'Order' + type: "1:1"`; diff --git a/extensions/crossmodel-lang/test/language-server/test-utils/utils.ts b/extensions/crossmodel-lang/test/language-server/test-utils/utils.ts new file mode 100644 index 00000000..bcf2ea34 --- /dev/null +++ b/extensions/crossmodel-lang/test/language-server/test-utils/utils.ts @@ -0,0 +1,26 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ +import { AstNode, LangiumDocument, LangiumServices } from 'langium'; +import { URI } from 'vscode-uri'; + +export async function parseDocument(services: LangiumServices, input: string): Promise> { + const document = await parseHelper(services)(input); + if (!document.parseResult) { + throw new Error('Could not parse document'); + } + return document; +} + +export function parseHelper(services: LangiumServices): (input: string) => Promise> { + const metaData = services.LanguageMetaData; + const documentBuilder = services.shared.workspace.DocumentBuilder; + return async input => { + const randomNumber = Math.floor(Math.random() * 10000000) + 1000000; + const uri = URI.parse(`file:///${randomNumber}${metaData.fileExtensions[0]}`); + const document = services.shared.workspace.LangiumDocumentFactory.fromString(input, uri); + services.shared.workspace.LangiumDocuments.addDocument(document); + await documentBuilder.build([document]); + return document; + }; +} diff --git a/extensions/crossmodel-lang/test/language-server/util/name-util.test.ts b/extensions/crossmodel-lang/test/language-server/util/name-util.test.ts new file mode 100644 index 00000000..c3acb466 --- /dev/null +++ b/extensions/crossmodel-lang/test/language-server/util/name-util.test.ts @@ -0,0 +1,58 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ +import { describe, expect, test } from '@jest/globals'; +import { EmptyFileSystem } from 'langium'; + +import { createCrossModelServices } from '../../../src/language-server/cross-model-module'; +import { CrossModelRoot } from '../../../src/language-server/generated/ast'; +import { findAvailableNodeName } from '../../../src/language-server/util/name-util'; +import { parseDocument } from '../test-utils/utils'; + +const services = createCrossModelServices({ ...EmptyFileSystem }); +const cmServices = services.CrossModel; + +const ex1 = 'diagram:'; +const ex2 = `diagram: + nodes: + - id: "nodeA"`; +const ex3 = `diagram: + nodes: + - id: "nodeA" + - id: "nodeA1"`; +const ex4 = `diagram: + nodes: + - id: "nodeA" + - id: "nodeA1" + - id: "nodeA2" + - id: "nodeA4"`; + +describe('NameUtil', () => { + describe('findAvailableNodeName', () => { + test('should return given name if unique', async () => { + const document = await parseDocument(cmServices, ex1); + + expect(findAvailableNodeName(document.parseResult.value.diagram!, 'nodeA')).toBe('nodeA'); + }); + + test('should return unique name if given is taken', async () => { + const document = await parseDocument(cmServices, ex2); + + const resultat = findAvailableNodeName(document.parseResult.value.diagram!, 'nodeA'); + + expect(resultat).toBe('nodeA1'); + }); + + test('should properly count up if name is taken', async () => { + const document = await parseDocument(cmServices, ex3); + + expect(findAvailableNodeName(document.parseResult.value.diagram!, 'nodeA')).toBe('nodeA2'); + }); + + test('should find lowest count if multiple are taken', async () => { + const document = await parseDocument(cmServices, ex4); + + expect(findAvailableNodeName(document.parseResult.value.diagram!, 'nodeA')).toBe('nodeA3'); + }); + }); +}); diff --git a/package.json b/package.json index f1a48722..9f5cc0b5 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "start:browser": "yarn theia:browser start", "start:electron": "yarn theia:electron start", "start:verdaccio": "yarn verdaccio --config verdaccio-config.yaml", - "test": "vitest --config configs/vitest.config.ts", + "test": "yarn --cwd extensions/crossmodel-lang test", "theia:browser": "yarn --cwd applications/browser-app", "theia:electron": "yarn --cwd applications/electron-app", "watch": "lerna run --parallel watch" diff --git a/packages/form-client/src/browser/form-editor-open-handler.ts b/packages/form-client/src/browser/form-editor-open-handler.ts index 0075dbd6..9e2e3704 100644 --- a/packages/form-client/src/browser/form-editor-open-handler.ts +++ b/packages/form-client/src/browser/form-editor-open-handler.ts @@ -3,24 +3,24 @@ ********************************************************************************/ import { MaybePromise, nls } from '@theia/core'; -import { NavigatableWidgetOpenHandler, WidgetOpenerOptions } from '@theia/core/lib/browser'; +import { NavigatableWidgetOpenHandler } from '@theia/core/lib/browser'; import URI from '@theia/core/lib/common/uri'; import { injectable } from '@theia/core/shared/inversify'; import { FormEditorWidget } from './form-editor-widget'; @injectable() export class FormEditorOpenHandler extends NavigatableWidgetOpenHandler { - static ID = 'form-editor-opener'; + static ID = 'form-editor-opener'; - readonly id = FormEditorOpenHandler.ID; // must match the id of the widget factory - readonly label = nls.localize('form-client/form-editor', 'Form Editor'); + readonly id = FormEditorOpenHandler.ID; // must match the id of the widget factory + readonly label = nls.localize('form-client/form-editor', 'Form Editor'); - canHandle(uri: URI, options?: WidgetOpenerOptions): MaybePromise { - return uri.path.ext === '.cm' ? 1 : -1; - } + canHandle(uri: URI): MaybePromise { + return uri.path.ext === '.cm' ? 1 : -1; + } } export function createFormEditorId(uri: URI, counter?: number): string { - // ensure we create a unique ID - return FormEditorOpenHandler.ID + `:${uri.toString()}` + (counter !== undefined ? `:${counter}` : ''); + // ensure we create a unique ID + return FormEditorOpenHandler.ID + `:${uri.toString()}` + (counter !== undefined ? `:${counter}` : ''); } diff --git a/packages/form-client/src/browser/form-editor-widget.tsx b/packages/form-client/src/browser/form-editor-widget.tsx index b55175a5..73f00424 100644 --- a/packages/form-client/src/browser/form-editor-widget.tsx +++ b/packages/form-client/src/browser/form-editor-widget.tsx @@ -2,29 +2,39 @@ * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ +import { ModelService, ModelServiceClient } from '@crossbreeze/model-service/lib/common'; import { CrossModelRoot } from '@crossbreeze/protocol'; import { CommandService, Emitter, Event } from '@theia/core'; -import { LabelProvider, NavigatableWidget, NavigatableWidgetOptions, ReactWidget, SaveOptions, Saveable } from '@theia/core/lib/browser'; +import { + LabelProvider, + Message, + NavigatableWidget, + NavigatableWidgetOptions, + ReactWidget, + SaveOptions, + Saveable +} from '@theia/core/lib/browser'; import URI from '@theia/core/lib/common/uri'; import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import * as React from '@theia/core/shared/react'; - -import { ModelService, ModelServiceClient } from '@crossbreeze/model-service/lib/common'; import '../../style/form-view.css'; import { App } from './react-components/App'; +import debounce = require('p-debounce'); +import deepEqual = require('fast-deep-equal'); export const FormEditorWidgetOptions = Symbol('FormEditorWidgetOptions'); export interface FormEditorWidgetOptions extends NavigatableWidgetOptions { id: string; } +const FORM_CLIENT_ID = 'form-client'; + @injectable() export class FormEditorWidget extends ReactWidget implements NavigatableWidget, Saveable { dirty = false; autoSave: 'off' | 'afterDelay' | 'onFocusChange' | 'onWindowChange'; public readonly onDirtyChangedEmitter = new Emitter(); onDirtyChanged: Event = this.onDirtyChangedEmitter.event; - saveUpdate = false; @inject(FormEditorWidgetOptions) protected options: FormEditorWidgetOptions; @inject(LabelProvider) protected labelProvider: LabelProvider; @@ -32,7 +42,7 @@ export class FormEditorWidget extends ReactWidget implements NavigatableWidget, @inject(CommandService) protected commandService: CommandService; @inject(ModelServiceClient) protected formClient: ModelServiceClient; - protected model: CrossModelRoot | undefined = undefined; + protected syncedModel: CrossModelRoot | undefined = undefined; protected error: string | undefined = undefined; @postConstruct() @@ -46,15 +56,20 @@ export class FormEditorWidget extends ReactWidget implements NavigatableWidget, this.updateModel = this.updateModel.bind(this); this.getResourceUri = this.getResourceUri.bind(this); this.loadModel(); + + this.formClient.onUpdate(event => { + if (event.sourceClientId !== FORM_CLIENT_ID && event.uri === this.getResourceUri().toString()) { + this.modelUpdated(event.model); + } + }); } protected async loadModel(): Promise { try { const uri = this.getResourceUri().toString(); - await this.modelService.open(uri); - const model = await this.modelService.request(uri); + const model = await this.modelService.open({ uri, clientId: FORM_CLIENT_ID }); if (model) { - this.model = model; + this.syncedModel = model; } } catch (error: any) { this.error = error; @@ -64,31 +79,30 @@ export class FormEditorWidget extends ReactWidget implements NavigatableWidget, } async save(options?: SaveOptions | undefined): Promise { - if (this.model === undefined) { + if (this.syncedModel === undefined) { throw new Error('Cannot save undefined model'); } this.setDirty(false); - // When the model on the model-server is updated we will get a notification that the model has been saved. - // This variable lets us know that we were the ones that saved the model - this.saveUpdate = true; - - await this.modelService.save(this.getResourceUri().toString(), this.model); + await this.modelService.save({ uri: this.getResourceUri().toString(), model: this.syncedModel, clientId: FORM_CLIENT_ID }); } - protected async updateModel(model: CrossModelRoot): Promise { - // If we were the ones that send the save request, we do not want to update the model again - if (this.saveUpdate) { - this.saveUpdate = false; - return; + protected updateModel = debounce((model: CrossModelRoot) => { + if (!deepEqual(this.syncedModel, model)) { + this.syncedModel = model; + this.modelService.update({ uri: this.getResourceUri().toString(), model, clientId: FORM_CLIENT_ID }); } + }, 200); - this.model = model; - await this.modelService.update(this.getResourceUri().toString(), this.model!); + protected modelUpdated(model: CrossModelRoot): void { + if (!deepEqual(this.syncedModel, model)) { + this.syncedModel = model; + this.update(); + } } override close(): void { - this.modelService.close(this.getResourceUri().toString()); + this.modelService.close({ uri: this.getResourceUri().toString(), clientId: FORM_CLIENT_ID }); super.close(); } @@ -103,7 +117,7 @@ export class FormEditorWidget extends ReactWidget implements NavigatableWidget, render(): React.ReactNode { const props = { - model: this.model, + model: this.syncedModel, updateModel: this.updateModel, getResourceUri: this.getResourceUri, formClient: this.formClient @@ -112,11 +126,26 @@ export class FormEditorWidget extends ReactWidget implements NavigatableWidget, return ; } + protected override onActivateRequest(msg: Message): void { + super.onActivateRequest(msg); + const focusInput = (): boolean => { + const inputs = this.node.getElementsByTagName('input'); + if (inputs.length > 0) { + inputs[0].focus(); + return true; + } + return false; + }; + if (!focusInput()) { + setTimeout(focusInput, 500); + } + } + getResourceUri(): URI { return new URI(this.options.uri); } createMoveToUri(resourceUri: URI): URI | undefined { - return undefined; + return resourceUri; } } diff --git a/packages/form-client/src/browser/react-components/App.tsx b/packages/form-client/src/browser/react-components/App.tsx index e3911c59..2375f8d0 100644 --- a/packages/form-client/src/browser/react-components/App.tsx +++ b/packages/form-client/src/browser/react-components/App.tsx @@ -2,13 +2,13 @@ * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ +import { ModelServiceClient } from '@crossbreeze/model-service/lib/common'; import { CrossModelRoot } from '@crossbreeze/protocol'; import URI from '@theia/core/lib/common/uri'; import * as React from '@theia/core/shared/react'; -import { ModelProvider, ModelReducer } from './ModelContext'; +import { AppState, ModelProvider, ModelReducer } from './ModelContext'; import { EntityForm } from './entity-components/EntityForm'; import _ = require('lodash'); -import { ModelServiceClient } from '@crossbreeze/model-service/lib/common'; interface AppProps { updateModel: (model: CrossModelRoot) => void; @@ -17,30 +17,27 @@ interface AppProps { formClient: ModelServiceClient; } -export function App(props: AppProps): React.ReactElement { - const [model, dispatch] = React.useReducer(ModelReducer, props.model as CrossModelRoot); +export function App({ model, updateModel }: AppProps): React.ReactElement { + const [appState, dispatch] = React.useReducer(ModelReducer, { model, reason: '' } as AppState); - // Subscribing to the updates made to the model by a different editor React.useEffect(() => { - props.formClient.onUpdate(document => { - if (document.uri === props.getResourceUri().toString()) { - dispatch({ type: 'model:update', model: document.model }); - } - }); - }, [props]); + // triggered when a new model is passed from the outside (widget) -> update internal state + dispatch({ type: 'model:update', model: model }); + }, [model]); - // This effect gets triggered when the model gets updated, it will pass the new model - // to the Form-widget and that will pass it to the server to update React.useEffect(() => { - props.updateModel(_.cloneDeep(model)); - }, [model, props]); + if (appState.reason !== 'model:update') { + // triggered when the internal model is updated, pass update to server + updateModel(_.cloneDeep(appState.model)); + } + }, [appState, updateModel]); let render = undefined; // Rendering logic - if (!model) { + if (!appState?.model) { render =
loading
; - } else if (model.entity) { + } else if (appState.model.entity) { render = ; } else { render = ( @@ -58,7 +55,7 @@ export function App(props: AppProps): React.ReactElement { return (
- + {render}
diff --git a/packages/form-client/src/browser/react-components/ModelContext.tsx b/packages/form-client/src/browser/react-components/ModelContext.tsx index 5ceb0630..0e3b1bee 100644 --- a/packages/form-client/src/browser/react-components/ModelContext.tsx +++ b/packages/form-client/src/browser/react-components/ModelContext.tsx @@ -1,8 +1,8 @@ /******************************************************************************** * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ -import * as React from '@theia/core/shared/react'; import { CrossModelRoot } from '@crossbreeze/protocol'; +import * as React from '@theia/core/shared/react'; import _ = require('lodash'); /** @@ -43,7 +43,12 @@ export function ModelProvider(props: ModelProviderProps): React.ReactElement { ); } -export function ModelReducer(model: CrossModelRoot, action: any): CrossModelRoot { +export interface AppState { + model: CrossModelRoot; + reason: string; +} + +export function ModelReducer({ model }: AppState, action: any): AppState { if (model === undefined) { throw Error('Model error: model.entity undefined'); } @@ -55,7 +60,7 @@ export function ModelReducer(model: CrossModelRoot, action: any): CrossModelRoot switch (action.type) { // Update the entire model case 'model:update': - return action.model; + return { model: action.model, reason: action.type }; // Change the name of the entity-model case 'entity:change-name': @@ -66,7 +71,7 @@ export function ModelReducer(model: CrossModelRoot, action: any): CrossModelRoot } model.entity.name = action.name; - return model; + return { model, reason: action.type }; // Change the name of the entity-model case 'entity:change-description': @@ -77,7 +82,7 @@ export function ModelReducer(model: CrossModelRoot, action: any): CrossModelRoot } model.entity.description = action.description; - return model; + return { model, reason: action.type }; // Change the datatype of one of entity attributes case 'entity:attribute:change-datatype': @@ -87,9 +92,9 @@ export function ModelReducer(model: CrossModelRoot, action: any): CrossModelRoot throw Error('action.id or dataType is undefined'); } - model.entity.attributes[action.id].value = action.dataType; + model.entity.attributes[action.id].datatype = action.dataType; - return model; + return { model, reason: action.type }; // Change the name of one of entity attributes case 'entity:attribute:change-name': @@ -101,7 +106,7 @@ export function ModelReducer(model: CrossModelRoot, action: any): CrossModelRoot model.entity.attributes[action.id].name = action.name; - return model; + return { model, reason: action.type }; default: { throw Error('Unknown ModelReducer action'); diff --git a/packages/form-client/src/browser/react-components/entity-components/tabs/EntityAttributesTab.tsx b/packages/form-client/src/browser/react-components/entity-components/tabs/EntityAttributesTab.tsx index b900b37a..6408ec72 100644 --- a/packages/form-client/src/browser/react-components/entity-components/tabs/EntityAttributesTab.tsx +++ b/packages/form-client/src/browser/react-components/entity-components/tabs/EntityAttributesTab.tsx @@ -17,7 +17,7 @@ import { } from '@mui/x-data-grid'; import { Checkbox, FormControl, MenuItem, Select, SelectChangeEvent } from '@mui/material'; import { ModelContext, ModelDispatchContext, ModelReducer } from '../../ModelContext'; -import { Attribute, CrossModelRoot } from '@crossbreeze/protocol'; +import { EntityAttribute, CrossModelRoot } from '@crossbreeze/protocol'; export function EntityAttributesTab(): React.ReactElement { // Context variables to handle model state. @@ -118,19 +118,19 @@ function CheckboxCell(params: GridCellParams): React.ReactElement { return ; } -function createRows(attributes: Array): GridRowsProp { +function createRows(attributes: Array): GridRowsProp { const rows: any = []; for (const key in attributes) { if (attributes.hasOwnProperty.call(attributes, key)) { - const item: Attribute = attributes[key]; + const item: EntityAttribute = attributes[key]; rows.push({ id: parseInt(key, 10), name: item.name, key: false, required: false, - value: item.value, + datatype: item.datatype, length: undefined, scale: undefined, precision: undefined, diff --git a/packages/form-client/src/browser/react-components/entity-components/tabs/EntityGeneralTab.tsx b/packages/form-client/src/browser/react-components/entity-components/tabs/EntityGeneralTab.tsx index fe17b941..85ea6401 100644 --- a/packages/form-client/src/browser/react-components/entity-components/tabs/EntityGeneralTab.tsx +++ b/packages/form-client/src/browser/react-components/entity-components/tabs/EntityGeneralTab.tsx @@ -1,9 +1,9 @@ /******************************************************************************** * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ +import { CrossModelRoot } from '@crossbreeze/protocol'; import * as React from '@theia/core/shared/react'; import { ModelContext, ModelDispatchContext, ModelReducer } from '../../ModelContext'; -import { CrossModelRoot } from '@crossbreeze/protocol'; interface GeneralTabProps {} @@ -32,7 +32,6 @@ export function GeneralTab(props: GeneralTabProps): React.ReactElement {
) => { diff --git a/packages/model-service/src/browser/model-service-client.ts b/packages/model-service/src/browser/model-service-client.ts index fafa7a1b..a1f545e4 100644 --- a/packages/model-service/src/browser/model-service-client.ts +++ b/packages/model-service/src/browser/model-service-client.ts @@ -2,26 +2,21 @@ * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ +import { CrossModelRoot, ModelUpdatedEvent } from '@crossbreeze/protocol'; import { Emitter } from '@theia/core'; import { injectable } from '@theia/core/shared/inversify'; import { ModelServiceClient } from '../common/model-service-rpc'; -import { CrossModelRoot } from '@crossbreeze/protocol'; - -export interface ModelDocument { - uri: string; - model: CrossModelRoot; -} @injectable() export class ModelServiceClientImpl implements ModelServiceClient { - protected onUpdateEmitter = new Emitter(); + protected onUpdateEmitter = new Emitter>(); onUpdate = this.onUpdateEmitter.event; async getName(): Promise { return 'ModelServiceClient'; } - async updateModel(uri: string, model: CrossModelRoot): Promise { - this.onUpdateEmitter.fire({ uri, model }); + async updateModel(event: ModelUpdatedEvent): Promise { + this.onUpdateEmitter.fire(event); } } diff --git a/packages/model-service/src/common/model-service-rpc.ts b/packages/model-service/src/common/model-service-rpc.ts index abadd45c..4a4ec8a3 100644 --- a/packages/model-service/src/common/model-service-rpc.ts +++ b/packages/model-service/src/common/model-service-rpc.ts @@ -2,9 +2,16 @@ * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ -import { CrossModelRoot, DiagramNodeEntity } from '@crossbreeze/protocol'; -import { JsonRpcServer, Event } from '@theia/core'; -import { ModelDocument } from '../browser'; +import { + CloseModelArgs, + CrossModelRoot, + DiagramNodeEntity, + ModelUpdatedEvent, + OpenModelArgs, + SaveModelArgs, + UpdateModelArgs +} from '@crossbreeze/protocol'; +import { Event, JsonRpcServer } from '@theia/core'; /** Path used to communicate between the Theia frontend and backend */ export const MODEL_SERVICE_PATH = '/services/model-service'; @@ -14,17 +21,17 @@ export const MODEL_SERVICE_PATH = '/services/model-service'; */ export const ModelService = Symbol('ModelService'); export interface ModelService extends JsonRpcServer { - open(uri: string): Promise; - close(uri: string): Promise; + open(args: OpenModelArgs): Promise; + close(args: CloseModelArgs): Promise; request(uri: string): Promise; requestDiagramNodeEntityModel(uri: string, id: string): Promise; - update(uri: string, model: CrossModelRoot): Promise; - save(uri: string, model: CrossModelRoot): Promise; + update(args: UpdateModelArgs): Promise; + save(args: SaveModelArgs): Promise; } export const ModelServiceClient = Symbol('ModelServiceClient'); export interface ModelServiceClient { getName(): Promise; - updateModel(uri: string, model: CrossModelRoot): Promise; - onUpdate: Event; + updateModel(args: ModelUpdatedEvent): Promise; + onUpdate: Event>; } diff --git a/packages/model-service/src/node/model-service.ts b/packages/model-service/src/node/model-service.ts index 8e6e0d57..e3811ea1 100644 --- a/packages/model-service/src/node/model-service.ts +++ b/packages/model-service/src/node/model-service.ts @@ -4,16 +4,21 @@ import { waitForTemporaryFileContent } from '@crossbreeze/core/lib/node'; import { CloseModel, + CloseModelArgs, CrossModelRoot, DiagramNodeEntity, MODELSERVER_PORT_FILE, OnSave, + OnUpdated, OpenModel, + OpenModelArgs, PORT_FOLDER, RequestModel, RequestModelDiagramNode, SaveModel, - UpdateModel + SaveModelArgs, + UpdateModel, + UpdateModelArgs } from '@crossbreeze/protocol'; import { URI } from '@theia/core'; import { Deferred } from '@theia/core/lib/common/promise-util'; @@ -55,45 +60,54 @@ export class ModelServiceImpl implements ModelService, BackendApplicationContrib } protected async connectToServer(port: number): Promise { + // Create the deferred object which exposes the Promise of the connection with the ModelServer. const connected = new Deferred(); + + // Create the socket, reader, writer and rpc-connection. const socket = new net.Socket(); const reader = new rpc.SocketMessageReader(socket); const writer = new rpc.SocketMessageWriter(socket); this.connection = rpc.createMessageConnection(reader, writer); - this.connection.onClose(() => connected.reject('No connection to ModelServer.')); - socket.on('close', () => connected.reject('No connection to ModelServer')); + // Configure connection promise results for the rpc connection. + this.connection.onClose(() => connected.reject('Connection with the ModelServer was closed.')); + this.connection.onError(() => connected.reject('Error occured with the connection to the ModelServer')); + + // Configure connection promise results for the socket. socket.on('ready', () => connected.resolve()); + socket.on('close', () => connected.reject('Socket from ModelService to ModelServer was closed.')); + socket.on('error', error => console.error('Error occurred with the ModelServer socket: %s; %s', error.name, error.message)); + + // Connect to the ModelServer on the given port. socket.connect({ port }); this.connection.listen(); - this.connection.onNotification(OnSave, (uri: string, model: CrossModelRoot) => { - this.client?.updateModel(uri, model); - }); + this.setUpListeners(); return connected.promise; } async waitForPort(): Promise { // the automatically assigned port is written by the server to a specific file location // we wait for that file to be available and read the port number out of it - // that way we can ensure that the server is ready to accept our connection + // that way we can ensure that the server is ready to accept our connection. const workspace = await this.workspaceServer.getMostRecentlyUsedWorkspace(); if (!workspace) { throw new Error('No workspace set.'); } const portFile = new URI(workspace).path.join(PORT_FOLDER, MODELSERVER_PORT_FILE).fsPath(); const port = await waitForTemporaryFileContent(portFile); + console.debug('Found port number in workspace: %d: ', port); return Number.parseInt(port, 10); } - async open(uri: string): Promise { + async open(args: OpenModelArgs): Promise { await this.initializeServer(); - await this.connection.sendRequest(OpenModel, uri); + return this.connection.sendRequest(OpenModel, args); } - async close(uri: string): Promise { + async close(args: CloseModelArgs): Promise { await this.initializeServer(); - await this.connection.sendRequest(CloseModel, uri); + await this.connection.sendRequest(CloseModel, args); } async request(uri: string): Promise { @@ -101,14 +115,14 @@ export class ModelServiceImpl implements ModelService, BackendApplicationContrib return this.connection.sendRequest(RequestModel, uri); } - async update(uri: string, model: CrossModelRoot): Promise { + async update(args: UpdateModelArgs): Promise { await this.initializeServer(); - return this.connection.sendRequest(UpdateModel, uri, model); + return this.connection.sendRequest(UpdateModel, args); } - async save(uri: string, model: CrossModelRoot): Promise { + async save(args: SaveModelArgs): Promise { await this.initializeServer(); - return this.connection.sendRequest(SaveModel, uri, model); + return this.connection.sendRequest(SaveModel, args); } dispose(): void { @@ -124,8 +138,11 @@ export class ModelServiceImpl implements ModelService, BackendApplicationContrib } setUpListeners(): void { - this.connection.onNotification(OnSave, (uri, model) => { - this.client?.updateModel(uri, model); + this.connection.onNotification(OnSave, event => { + this.client?.updateModel(event); + }); + this.connection.onNotification(OnUpdated, event => { + this.client?.updateModel(event); }); } diff --git a/packages/property-view/src/browser/model-data-service.ts b/packages/property-view/src/browser/model-data-service.ts new file mode 100644 index 00000000..c27b75d7 --- /dev/null +++ b/packages/property-view/src/browser/model-data-service.ts @@ -0,0 +1,67 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ + +import { ModelService } from '@crossbreeze/model-service/lib/common'; +import { CrossModelRoot, DiagramNodeEntity } from '@crossbreeze/protocol'; +import { GlspSelection } from '@eclipse-glsp/theia-integration'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { PropertyDataService } from '@theia/property-view/lib/browser/property-data-service'; + +export const PROPERTY_CLIENT_ID = 'property-view-client'; + +@injectable() +export class ModelDataService implements PropertyDataService { + id = 'model-property-data-service'; + label = 'ModelPropertyDataService'; + currentUri?: string; + + @inject(ModelService) protected modelService: ModelService; + + canHandleSelection(selection: GlspSelection | undefined): number { + const canHandle = GlspSelection.is(selection) ? 1 : 0; + + // Close the previous file if there is a new selection the property view can not handle + if (canHandle === 0 && this.currentUri) { + this.modelService.close({ uri: this.currentUri, clientId: PROPERTY_CLIENT_ID }); + } + + return canHandle; + } + + protected async closeCurrentModel(): Promise { + if (this.currentUri) { + return this.modelService.close({ uri: this.currentUri, clientId: PROPERTY_CLIENT_ID }); + } + this.currentUri = undefined; + } + + protected async openCurrentModel(): Promise { + if (this.currentUri) { + return this.modelService.open({ uri: this.currentUri, clientId: PROPERTY_CLIENT_ID }); + } + return undefined; + } + + protected async getSelectedEntity(selection: GlspSelection | undefined): Promise { + if (selection && GlspSelection.is(selection) && selection.sourceUri && selection.selectedElementsIDs.length !== 0) { + return this.modelService.requestDiagramNodeEntityModel(selection.sourceUri, selection.selectedElementsIDs[0]); + } + return undefined; + } + + async providePropertyData(selection: GlspSelection | undefined): Promise { + const entity = await this.getSelectedEntity(selection); + if (!entity) { + this.closeCurrentModel(); + return undefined; + } + const newUri = entity.uri; + if (newUri !== this.currentUri) { + await this.closeCurrentModel(); + } + this.currentUri = newUri; + await this.openCurrentModel(); + return entity; + } +} diff --git a/packages/property-view/src/browser/model-property-widget.tsx b/packages/property-view/src/browser/model-property-widget.tsx index c1b128e1..e68a7b75 100644 --- a/packages/property-view/src/browser/model-property-widget.tsx +++ b/packages/property-view/src/browser/model-property-widget.tsx @@ -8,11 +8,12 @@ import { PropertyDataService } from '@theia/property-view/lib/browser/property-d import { PropertyViewContentWidget } from '@theia/property-view/lib/browser/property-view-content-widget'; import { ModelService } from '@crossbreeze/model-service/lib/common'; -import { CrossModelRoot, UpdateClientOperation, isDiagramNodeEntity } from '@crossbreeze/protocol'; +import { CrossModelRoot, isDiagramNodeEntity } from '@crossbreeze/protocol'; import { IActionDispatcher } from '@eclipse-glsp/client'; import { GLSPDiagramWidget, GlspSelection } from '@eclipse-glsp/theia-integration'; import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell'; import { inject, injectable } from '@theia/core/shared/inversify'; +import { PROPERTY_CLIENT_ID } from './model-data-service'; import { App } from './react-components/App'; @injectable() @@ -60,10 +61,7 @@ export class ModelPropertyWidget extends ReactWidget implements PropertyViewCont if (this.model === undefined || this.uri === undefined) { throw new Error('Cannot save undefined model'); } - - const updated = await this.modelService.update(this.uri, this.model); - await this.modelService.save(this.uri, updated); - this.actionDispatcher?.dispatch(UpdateClientOperation.create()); + this.modelService.update({ uri: this.uri, model: this.model, clientId: PROPERTY_CLIENT_ID }); } protected async updateModel(model: CrossModelRoot): Promise { diff --git a/packages/property-view/src/browser/property-view-frontend-module.ts b/packages/property-view/src/browser/property-view-frontend-module.ts index ee77278c..6923feb5 100644 --- a/packages/property-view/src/browser/property-view-frontend-module.ts +++ b/packages/property-view/src/browser/property-view-frontend-module.ts @@ -3,12 +3,12 @@ ********************************************************************************/ import { ContainerModule } from '@theia/core/shared/inversify'; -import { PropertyViewWidgetProvider } from '@theia/property-view/lib/browser/property-view-widget-provider'; -import { ModelPropertyWidgetProvider } from './model-property-widget-provider'; import { PropertyDataService } from '@theia/property-view/lib/browser/property-data-service'; -import { ModelDataService } from '../common/model-data-service'; +import { PropertyViewWidgetProvider } from '@theia/property-view/lib/browser/property-view-widget-provider'; import '../../src/style/property-view.css'; +import { ModelDataService } from './model-data-service'; import { ModelPropertyWidget } from './model-property-widget'; +import { ModelPropertyWidgetProvider } from './model-property-widget-provider'; export default new ContainerModule((bind, _unbind, _isBound, rebind) => { // To make the property widget working diff --git a/packages/property-view/src/browser/react-components/ModelContext.tsx b/packages/property-view/src/browser/react-components/ModelContext.tsx index 5dcbece8..db7df2a9 100644 --- a/packages/property-view/src/browser/react-components/ModelContext.tsx +++ b/packages/property-view/src/browser/react-components/ModelContext.tsx @@ -65,7 +65,7 @@ export function ModelReducer(model: CrossModelRoot, action: any): CrossModelRoot throw Error('action.name undefined'); } - model.entity.name = action.name; + model.entity.name_val = action.name; return model; @@ -88,7 +88,7 @@ export function ModelReducer(model: CrossModelRoot, action: any): CrossModelRoot throw Error('action.id or dataType is undefined'); } - model.entity.attributes[action.id].value = action.dataType; + model.entity.attributes[action.id].datatype = action.dataType; return model; @@ -100,7 +100,7 @@ export function ModelReducer(model: CrossModelRoot, action: any): CrossModelRoot throw Error('action.id or name is undefined'); } - model.entity.attributes[action.id].name = action.name; + model.entity.attributes[action.id].name_val = action.name; return model; @@ -110,9 +110,9 @@ export function ModelReducer(model: CrossModelRoot, action: any): CrossModelRoot } model.entity.attributes.push({ - $type: 'Attribute', + $type: 'EntityAttribute', name: `empty_attribute${model.entity.attributes.length}`, - value: 'Float' + datatype: 'Float' }); return model; diff --git a/packages/property-view/src/browser/react-components/views/EntityPropertyAttributeGrid.tsx b/packages/property-view/src/browser/react-components/views/EntityPropertyAttributeGrid.tsx index 1e5d1071..71c532c1 100644 --- a/packages/property-view/src/browser/react-components/views/EntityPropertyAttributeGrid.tsx +++ b/packages/property-view/src/browser/react-components/views/EntityPropertyAttributeGrid.tsx @@ -1,7 +1,7 @@ /******************************************************************************** * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ -import { CrossModelRoot, Attribute } from '@crossbreeze/protocol'; +import { CrossModelRoot, EntityAttribute } from '@crossbreeze/protocol'; import * as React from '@theia/core/shared/react'; import { DataGrid, @@ -233,11 +233,11 @@ function CustomSelect(props: any): React.ReactElement { ); } -function createRows(attributes: Array): GridRowsProp { +function createRows(attributes: Array): GridRowsProp { const rows = attributes.map((attribute, index) => ({ id: index, - name: attribute.name, - value: attribute.value + name: attribute.name_val, + value: attribute.datatype })); return rows; diff --git a/packages/property-view/src/browser/react-components/views/EntityPropertyView.tsx b/packages/property-view/src/browser/react-components/views/EntityPropertyView.tsx index e9dc15c6..d8879fa7 100644 --- a/packages/property-view/src/browser/react-components/views/EntityPropertyView.tsx +++ b/packages/property-view/src/browser/react-components/views/EntityPropertyView.tsx @@ -56,7 +56,7 @@ function EntityPropertyGeneral(props: EntityPropertyGeneralProps): React.ReactEl ) => { dispatch({ type: 'entity:change-name', name: e.target.value ? e.target.value : '' }); }} diff --git a/packages/property-view/src/common/model-data-service.ts b/packages/property-view/src/common/model-data-service.ts deleted file mode 100644 index 89173aca..00000000 --- a/packages/property-view/src/common/model-data-service.ts +++ /dev/null @@ -1,49 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2023 CrossBreeze. - ********************************************************************************/ - -import { ModelService } from '@crossbreeze/model-service/lib/common'; -import { DiagramNodeEntity } from '@crossbreeze/protocol'; -import { GlspSelection } from '@eclipse-glsp/theia-integration'; -import { inject, injectable } from '@theia/core/shared/inversify'; -import { PropertyDataService } from '@theia/property-view/lib/browser/property-data-service'; -@injectable() -export class ModelDataService implements PropertyDataService { - id = 'model-property-data-service'; - label = 'ModelPropertyDataService'; - currentUri: string; - - @inject(ModelService) protected modelService: ModelService; - - canHandleSelection(selection: GlspSelection | undefined): number { - const canHandle = GlspSelection.is(selection) ? 1 : 0; - - // Close the previous file if there is a new selection the property view can not handle - if (canHandle === 0 && this.currentUri !== '') { - this.modelService.close(this.currentUri); - } - - return canHandle; - } - - async providePropertyData(selection: GlspSelection | undefined): Promise { - if (selection && GlspSelection.is(selection) && selection.sourceUri && selection.selectedElementsIDs.length !== 0) { - const entity: DiagramNodeEntity | undefined = await this.modelService.requestDiagramNodeEntityModel( - selection.sourceUri, - selection.selectedElementsIDs[0] - ); - - if (entity) { - if (this.currentUri && this.currentUri !== entity.uri) { - await this.modelService.close(this.currentUri); - this.currentUri = entity.uri; - await this.modelService.open(this.currentUri); - } - - return entity; - } - } - - return undefined; - } -} diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 02c75ecc..ba1102c2 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -28,7 +28,7 @@ }, "dependencies": { "@eclipse-glsp/protocol": "1.1.0-RC10", - "langium": "^1.1.0", + "langium": "~1.3.0", "vscode-jsonrpc": "^8.0.2" } } diff --git a/packages/protocol/src/model-service/protocol.ts b/packages/protocol/src/model-service/protocol.ts index 48350563..c2f4b6af 100644 --- a/packages/protocol/src/model-service/protocol.ts +++ b/packages/protocol/src/model-service/protocol.ts @@ -17,32 +17,30 @@ export function isCrossModelRoot(model?: any): model is CrossModelRoot { return !!model && model.$type === 'CrossModelRoot'; } -export interface Relationship { - readonly $type: 'Relationship'; - name: string; - properties: Array; - source: string; - target: string; - type: '1:1' | '1:n' | 'n:1' | 'n:m'; -} - -export interface Property { - readonly $type: 'Property'; - key: string; - value: number | string; -} - export interface Entity { readonly $type: 'Entity'; - name: string; - description: string; - attributes: Array; + attributes: Array; + description?: string; + name?: string; + name_val?: string; } -export interface Attribute { - readonly $type: 'Attribute'; - name: string; - value: number | string; +export interface EntityAttribute { + readonly $type: 'EntityAttribute'; + datatype?: string; + description?: string; + name?: string; + name_val?: string; +} + +export interface Relationship { + readonly $type: 'Relationship'; + child?: string; + description?: string; + name?: string; + name_val?: string; + parent?: string; + type?: string; } export interface DiagramNodeEntity { @@ -54,12 +52,44 @@ export function isDiagramNodeEntity(model?: any): model is DiagramNodeEntity { return !!model && model.uri && model.model && isCrossModelRoot(model.model); } -export const OpenModel = new rpc.RequestType1('server/open'); -export const CloseModel = new rpc.RequestType1('server/close'); +export interface ClientModelArgs { + uri: string; + clientId: string; +} + +export interface OpenModelArgs extends ClientModelArgs { + languageId?: string; +} + +export interface CloseModelArgs extends ClientModelArgs {} + +export interface UpdateModelArgs extends ClientModelArgs { + model: T | string; +} + +export interface SaveModelArgs extends ClientModelArgs { + model: T | string; +} + +export interface ModelUpdatedEvent { + uri: string; + model: T; + sourceClientId: string; +} + +export interface ModelSavedEvent { + uri: string; + model: T; + sourceClientId: string; +} + +export const OpenModel = new rpc.RequestType1('server/open'); +export const CloseModel = new rpc.RequestType1('server/close'); export const RequestModel = new rpc.RequestType1('server/request'); export const RequestModelDiagramNode = new rpc.RequestType2( 'server/requestModelDiagramNode' ); -export const UpdateModel = new rpc.RequestType2('server/update'); -export const SaveModel = new rpc.RequestType2('server/save'); -export const OnSave = new rpc.NotificationType2('server/onSave'); +export const UpdateModel = new rpc.RequestType1, CrossModelRoot, void>('server/update'); +export const SaveModel = new rpc.RequestType1, void, void>('server/save'); +export const OnSave = new rpc.NotificationType1>('server/onSave'); +export const OnUpdated = new rpc.NotificationType1>('server/onUpdated'); diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 02b3dcd1..f90a8416 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -4,5 +4,5 @@ "noEmit": true }, "exclude": ["**/node_modules", "**/.eslintrc.js"], - "include": ["packages/*/src", "applications/*/src", "applications/*/scripts", "extensions/*/src"] + "include": ["packages/*/src", "applications/*/src", "applications/*/scripts", "extensions/*/src", "extensions/*/test"] } diff --git a/yarn.lock b/yarn.lock index e020ae69..39a78a37 100644 --- a/yarn.lock +++ b/yarn.lock @@ -998,15 +998,6 @@ "@chevrotain/types" "10.4.2" lodash "4.17.21" -"@chevrotain/cst-dts-gen@10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz#922ebd8cc59d97241bb01b1b17561a5c1ae0124e" - integrity sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw== - dependencies: - "@chevrotain/gast" "10.5.0" - "@chevrotain/types" "10.5.0" - lodash "4.17.21" - "@chevrotain/gast@10.4.2": version "10.4.2" resolved "https://registry.yarnpkg.com/@chevrotain/gast/-/gast-10.4.2.tgz#236dc48e54cba16260c03bece25d5a3b6e2f5dab" @@ -1015,34 +1006,16 @@ "@chevrotain/types" "10.4.2" lodash "4.17.21" -"@chevrotain/gast@10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@chevrotain/gast/-/gast-10.5.0.tgz#e4e614bc46d17a8892742f38e56cd33f1f3ad162" - integrity sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A== - dependencies: - "@chevrotain/types" "10.5.0" - lodash "4.17.21" - "@chevrotain/types@10.4.2": version "10.4.2" resolved "https://registry.yarnpkg.com/@chevrotain/types/-/types-10.4.2.tgz#18be6b7a3226b121fccec08c2ba8433219a6813c" integrity sha512-QzSCjg6G4MvIoLeIgOiMR0IgzkGEQqrNJJIr3T5ETRa7l4Av4AMIiEctV99mvDr57iXwwk0/kr3RJxiU36Nevw== -"@chevrotain/types@10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@chevrotain/types/-/types-10.5.0.tgz#52a97d74a8cfbc197f054636d93ecd8912d33d21" - integrity sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A== - "@chevrotain/utils@10.4.2": version "10.4.2" resolved "https://registry.yarnpkg.com/@chevrotain/utils/-/utils-10.4.2.tgz#87735732184cc5a2f8aad2f3454082294ef3c924" integrity sha512-V34dacxWLwKcvcy32dx96ADJVdB7kOJLm7LyBkBQw5u5HC9WdEFw2G17zml+U3ivavGTrGPJHl8o9/UJm0PlUw== -"@chevrotain/utils@10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@chevrotain/utils/-/utils-10.5.0.tgz#0ee36f65b49b447fbac71b9e5af5c5c6c98ac057" - integrity sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ== - "@cnakazawa/watch@^1.0.3": version "1.0.4" resolved "https://registry.yarnpkg.com/@cnakazawa/watch/-/watch-1.0.4.tgz#f864ae85004d0fcab6f50be9141c4da368d1656a" @@ -5260,7 +5233,7 @@ chalk@^2.0.0, chalk@^2.1.0, chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" -chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.1, chalk@^4.1.2, chalk@~4.1.2: +chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.1, chalk@~4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== @@ -5320,18 +5293,6 @@ chevrotain-allstar@~0.1.4: dependencies: lodash "^4.17.21" -chevrotain@^10.4.1: - version "10.5.0" - resolved "https://registry.yarnpkg.com/chevrotain/-/chevrotain-10.5.0.tgz#9c1dc62ef0753bb562dbe521b5f72d041bad624e" - integrity sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A== - dependencies: - "@chevrotain/cst-dts-gen" "10.5.0" - "@chevrotain/gast" "10.5.0" - "@chevrotain/types" "10.5.0" - "@chevrotain/utils" "10.5.0" - lodash "4.17.21" - regexp-to-ast "0.5.0" - chevrotain@~10.4.2: version "10.4.2" resolved "https://registry.yarnpkg.com/chevrotain/-/chevrotain-10.4.2.tgz#9abeac6a60134931c0a0788b206400e5f7a3daba" @@ -5704,7 +5665,7 @@ commander@^7.0.0: resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== -commander@^8.0.0, commander@^8.3.0: +commander@^8.3.0: version "8.3.0" resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== @@ -10179,22 +10140,31 @@ kuler@^2.0.0: resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3" integrity sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A== -langium-cli@^1.1.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/langium-cli/-/langium-cli-1.2.1.tgz#d591bcd22307dd9ff025cfa2b5fc30b1fabfabce" - integrity sha512-sbGucMXqfDfLmF6bvFsyv7f4/y2dNivVKpXD5j//OxMrBsF+N8fATVqq8bopUvAxxvb0dmaV0xpIxYr7b7Efjg== +langium-cli@~1.3.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/langium-cli/-/langium-cli-1.3.1.tgz#d73777fbf866d429e242b4bf7676d1251aafa994" + integrity sha512-9faKpioKCjBD0Z4y165+wQlDFiDHOXYBlhPVgbV+neSnSB70belZLNfykAVa564360h7Br/5PogR5jW2n/tOKw== dependencies: chalk "~4.1.2" commander "~10.0.0" fs-extra "~11.1.0" jsonschema "~1.4.1" - langium "~1.2.0" + langium "~1.3.0" + langium-railroad "~1.3.0" lodash "~4.17.21" -langium@^1.1.0, langium@~1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/langium/-/langium-1.2.0.tgz#cf6ad6ca7d3e3b69203349ebb6a20ef071f57840" - integrity sha512-jFSptpFljYo9ZTHrq/GZflMUXiKo5KBNtsaIJtnIzDm9zC2FxsxejEFAtNL09262RVQt+zFeF/2iLAShFTGitw== +langium-railroad@~1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/langium-railroad/-/langium-railroad-1.3.0.tgz#ef2bb50ac26811332c11ea9a62b4e66f96c3f7f4" + integrity sha512-I3gx79iF+Qpn2UjzfHLf2GENAD9mPdSZHL3juAZLBsxznw4se7MBrJX32oPr/35DTjU9q99wFCQoCXu7mcf+Bg== + dependencies: + langium "~1.3.0" + railroad-diagrams "^1.0.0" + +langium@~1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/langium/-/langium-1.3.0.tgz#7d7c82466f2188f698f2583d9d841c8eaf679bc8" + integrity sha512-MjCEWu1q2TcuLURNjwA2StRgx6jGD+21fvzKDscSgD9lQC4Vmd4lkkgrKLakLrxcbJ57UtzUNfB6j/Yx9skVUw== dependencies: chevrotain "~10.4.2" chevrotain-allstar "~0.1.4" @@ -12835,6 +12805,11 @@ quick-lru@^5.1.1: resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== +railroad-diagrams@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz#eb7e6267548ddedfb899c1b90e57374559cddb7e" + integrity sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A== + randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"