Neovim configuration for file and module navigation in CDS models
In this post I describe how I extended my Lua-based Neovim configuration for CDS filetypes to improve navigation to referenced files and modules.
CDS modelling across files and modules
In the context of CDS models, one of the best practices is to embrace aspects and "factor out separate concerns into separate files". In addition to files the CDS compiler supports Node.js packages (modules).
An example of both can be found in the sample facet. Let's create a project based on this sample facet and explore.
; cd /tmp && cds init --add sample sample && cd sample && tree -L 3
creating new CAP project in ./sample
adding nodejs
adding sample
successfully created project – continue with cd sample
find samples on https://github.com/SAP-samples/cloud-cap-samples
learn about next steps at https://cap.cloud.sap
.
├── README.md
├── app
│ ├── _i18n
│ │ ├── i18n.properties
│ │ └── i18n_de.properties
│ ├── admin-books
│ │ ├── fiori-service.cds
│ │ └── webapp
│ ├── appconfig
│ │ └── fioriSandboxConfig.json
│ ├── browse
│ │ ├── fiori-service.cds
│ │ └── webapp
│ ├── common.cds
│ ├── index.html
│ └── services.cds
├── db
│ ├── data
│ │ ├── sap.capire.bookshop-Authors.csv
│ │ ├── sap.capire.bookshop-Books.csv
│ │ ├── sap.capire.bookshop-Books_texts.csv
│ │ └── sap.capire.bookshop-Genres.csv
│ └── schema.cds
├── eslint.config.mjs
├── package.json
└── srv
├── admin-service.cds
├── admin-service.js
├── cat-service.cds
└── cat-service.js
11 directories, 20 files
Examining the 'using' references
In app/admin-books/fiori-service.cds
there's this:
using { AdminService } from '../../srv/admin-service.cds';
using { sap.capire.bookshop } from '../../db/schema';
...
So AdminService
is being imported from ../../srv/admin-service.cds
, which looks like this:
using { sap.capire.bookshop as my } from '../db/schema';
service AdminService @(requires:'admin') {
entity Books as projection on my.Books;
entity Authors as projection on my.Authors;
}
In turn, the entities here are from the sap.capire.bookshop
namespace which is imported from ../db/schema
, which looks like this:
using { Currency, managed, sap } from '@sap/cds/common';
namespace sap.capire.bookshop;
entity Books : managed {
...
}
And Currency
, managed
and sap
are imported from the module @sap/cds/common
, a file (common.cds
) within the @sap/cds
module, which (at this point) is available in the globally installed @sap/cds-dk
module location (as we haven't performed a project-local npm install
yet), and looks like this (heavily redacted):
type Currency : Association to sap.common.Currencies;
...
aspect managed {
createdAt : Timestamp @cds.on.insert : $now;
createdBy : User @cds.on.insert : $user;
modifiedAt : Timestamp @cds.on.insert : $now @cds.on.update : $now;
modifiedBy : User @cds.on.insert : $user @cds.on.update : $user;
}
...
Visualising the navigation path
Here's what these relations (and navigations) look like in "diagram" form (thanks to ASCIIFlow):
+-----------------------------+
|app/admin/fiori-service.cds |
+-----------------------------+
|using ... from |
|'../../srv/admin-service.cds'|
| | 1|
+-------------|---------------+ +-----------------------------+
+-------------------|srv/admin-service.cds |
+-----------------------------+
|using ... from |
|'../db/schema.cds' |
| | 2|
+-----------------------------+ +------------|----------------+
|db/schema.cds |----------------+
+-----------------------------+
|using ... from |
|'@sap/cds/common' |
| | 3|
+-------------|---------------+ +-----------------------------+
+-------------------|@sap/cds/common |
+-----------------------------+
|type Currency ... |
|aspect managed { ... } |
| 4|
+-----------------------------+
As mentioned earlier, the
@sap/cds/common
resource is a file calledcommon.cds
within the@sap/cds
module.
What's notable is that these relations are expressed differently each time:
- the first points to
../../srv/admin-service.cds
which has an explicit.cds
extension - the second points to
../db/schema
which has no extension - the third points to a Node.js module based resource
@sap/cds/common
, again with no extension specified
Here's a quick demo of how that navigation path can be followed in VS Code, with the SAP CDS Language Support extension:
Making CDS model navigation work in Neovim
With the @sap/cds-lsp
language server in play, plus the Tree-sitter queries for CDS, I have a good experience in Neovim already (see A modern and clean Neovim setup for CAP Node.js - configuration and diagnostics).
However, using the standard gf mechanism left me wanting, due to the types of navigation target and the vagaries of how they are expressed. With a little configuration though, (which is still experimental at this stage, as I'm still learning) I've improved the situation.
I added some ftplugin configuration specific to CDS files, in an after/ftplugin/cds.lua
file within my Neovim config, and it looks like this:
-- Settings to be able to navigate to cds resources in Node.js modules
-- Given a path p, add it to the 'path' if it exists
local addpath = function(p)
if vim.uv.fs_stat(p) then vim.opt.path:append(p) end
end
-- Auto add .cds extension to files if necessary when nav with gf
vim.opt.suffixesadd = '.cds'
-- Ensure that the literal @ symbol is treated as part of a filename
-- (required as the CAP module names are in the @sap namespace)
vim.opt.isfname:append '@-@'
-- The standard module location
local moduledir = '/node_modules'
-- If a project-local npm install has been executed then projpath
-- will reflect the project-local node_modules dir
local projpath = vim.fs.root(0, 'package.json') .. moduledir
-- We can also add the CAP global based node_modules dir,
-- based on the location of the 'cds' executable
local cdsdkpath = vim.fs.dirname(vim.fn.exepath('cds'))
.. '/../lib/node_modules/@sap/cds-dk'
.. moduledir
-- Add them if they exist
addpath(projpath)
addpath(cdsdkpath)
Here's a quick summary:
I have a simple function addpath
which will add a (path) value to path
option, which the help describes as "a list of directories which will be searched when using gf
... and other commands". The function uses uv.fs_stat to ensure the directory actually exists before adding it.
The suffixesadd
option (via vim.opt.suffixesadd) is also related to the use of gf
and is a "comma-separated list of suffixes, which are used when searching for a file for the gf
, [I
, etc. commands". So here I add .cds
to this option for the case where an extension isn't given - like in this case:
using { sap.capire.bookshop as my } from '../db/schema';
The extension is omitted here as a sort of CAP best practice, and could in fact be
.csn
, the compiled machine-readable equivalent of CDL (the human-readable language used in CDS model files). But I'll cross that bridge when I come to it.
That's it with regards to handling navigation to other CDS model files. But navigation to Node.js modules requires a bit more fettling.
The CAP modules are all in the sap
namespace. Namespaces, or scopes, are prefixed with the @
symbol (as in @sap/cds
, for example). When modules are installed, in the node_modules/
directory, the @
-prefixed namespace forms part of the directory structure. So in this sample
project, after running npm install
, here's where @sap/cds/common
is to be found:
node_modules/
├── @cap-js
│ ├── cds-types
│ │ ├── ...
│ │ └── scripts
│ ├── db-service
│ │ ├── ...
│ │ └── package.json
│ └── sqlite
│ ├── ...
│ └── package.json
└── @sap
└── cds
├── CHANGELOG.md
├── LICENSE
├── README.md
├── _i18n
├── app
├── bin
---> ├── common.cds
├── eslint.config.mjs
├── lib
├── libx
├── package.json
├── server.js
├── srv
└── tasks
That means I need to add the literal @
symbol to the isfname option, which denotes the characters included in filenames. If we look at the default value for isfname
(with :set isfname?
) we see this:
@,48-57,/,.,-,_,+,,,#,$,%,~,=
But @
here represents "alpha characters", and to have the actual @
symbol included, one needs to add @-@
.
Once that is done, it's just a question of determining:
- the CAP Node.js project's root directory (with
vim.fs.root(0, 'package.json')
) - the location of the globally installed
@sap/cds-dk
module, based on where thecds
executable can be found
Then, /node_modules
is appended to each, and they're both passed to the addpath
function defined earlier.
Once either or both these paths are in the path
option (depending on whether they exist or not), gf
based navigation can proceed successfully!
Wrapping up
This was a quick configuration hack in an area of Neovim (well, Vim, I guess) that I hadn't previously much experience in. So it may need some more tweaking. But for now, it works well, as shown here:
In VS Code each newly navigated-to resource was opened in a separate tab by default. In Neovim they're opened in the same buffer, so to return to the previous resource in the "jump list" one can use
ctrl-o
.