@likec4/language-server 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/contrib/likec4.tmLanguage.json +1 -1
  2. package/package.json +23 -19
  3. package/src/Rpc.ts +1 -1
  4. package/src/ast.ts +34 -9
  5. package/src/{browser/index.ts → browser.ts} +4 -1
  6. package/src/generated/ast.ts +498 -152
  7. package/src/generated/grammar.ts +2 -2
  8. package/src/generated/module.ts +1 -1
  9. package/src/index.ts +1 -1
  10. package/src/like-c4.langium +116 -44
  11. package/src/logger.ts +76 -55
  12. package/src/lsp/DocumentLinkProvider.ts +1 -1
  13. package/src/lsp/DocumentSymbolProvider.ts +1 -1
  14. package/src/lsp/HoverProvider.ts +1 -1
  15. package/src/lsp/SemanticTokenProvider.ts +54 -26
  16. package/src/model/model-builder.ts +11 -8
  17. package/src/model/model-locator.ts +12 -25
  18. package/src/model/model-parser-where.ts +75 -0
  19. package/src/model/model-parser.ts +168 -68
  20. package/src/model-change/ModelChanges.ts +2 -3
  21. package/src/model-change/changeElementStyle.ts +4 -1
  22. package/src/model-change/changeViewLayout.ts +8 -8
  23. package/src/model-change/saveManualLayout.ts +4 -6
  24. package/src/model-graph/LikeC4ModelGraph.ts +50 -48
  25. package/src/model-graph/compute-view/__test__/fixture.ts +41 -16
  26. package/src/model-graph/compute-view/compute.ts +135 -69
  27. package/src/model-graph/compute-view/predicates.ts +232 -136
  28. package/src/model-graph/dynamic-view/__test__/fixture.ts +5 -1
  29. package/src/model-graph/dynamic-view/compute.ts +50 -41
  30. package/src/model-graph/utils/applyCustomElementProperties.ts +31 -29
  31. package/src/model-graph/utils/applyCustomRelationProperties.ts +52 -15
  32. package/src/model-graph/utils/elementExpressionToPredicate.ts +8 -3
  33. package/src/module.ts +4 -18
  34. package/src/{node/index.ts → node.ts} +1 -1
  35. package/src/protocol.ts +2 -2
  36. package/src/shared/NodeKindProvider.ts +4 -2
  37. package/src/test/setup.ts +13 -0
  38. package/src/test/testServices.ts +1 -1
  39. package/src/validation/dynamic-view-rule.ts +12 -12
  40. package/src/validation/index.ts +6 -6
  41. package/src/validation/relation.ts +1 -1
  42. package/src/validation/view-predicates/{custom-element-expr.ts → element-with.ts} +11 -10
  43. package/src/validation/view-predicates/expanded-element.ts +2 -10
  44. package/src/validation/view-predicates/incoming.ts +1 -1
  45. package/src/validation/view-predicates/index.ts +2 -2
  46. package/src/validation/view-predicates/outgoing.ts +1 -1
  47. package/src/validation/view-predicates/{custom-relation-expr.ts → relation-with.ts} +2 -2
  48. package/src/validation/view.ts +8 -9
  49. package/src/view-utils/manual-layout.ts +65 -72
  50. package/src/view-utils/resolve-relative-paths.ts +28 -17
  51. package/src/view-utils/view-hash.ts +33 -0
@@ -2,9 +2,9 @@ import { AstUtils, type ValidationCheck } from 'langium'
2
2
  import { ast } from '../../ast'
3
3
  import type { LikeC4Services } from '../../module'
4
4
 
5
- export const customRelationExprChecks = (
5
+ export const relationPredicateWithChecks = (
6
6
  _services: LikeC4Services
7
- ): ValidationCheck<ast.CustomRelationExpression> => {
7
+ ): ValidationCheck<ast.RelationPredicateWith> => {
8
8
  return (el, accept) => {
9
9
  const container = AstUtils.getContainerOfType(el, ast.isViewRulePredicate)
10
10
  if (ast.isExcludePredicate(container)) {
@@ -1,18 +1,17 @@
1
- import { CstUtils, type ValidationCheck } from 'langium'
1
+ import { type ValidationCheck } from 'langium'
2
2
  import { ast } from '../ast'
3
3
  import type { LikeC4Services } from '../module'
4
- import { deserializeFromComment, hasManualLayout } from '../view-utils/manual-layout'
5
4
 
6
5
  export const viewChecks = (services: LikeC4Services): ValidationCheck<ast.LikeC4View> => {
7
6
  const index = services.shared.workspace.IndexManager
8
7
  return (el, accept) => {
9
- const commentNode = CstUtils.findCommentNode(el.$cstNode, ['BLOCK_COMMENT'])
10
- if (commentNode && hasManualLayout(commentNode.text) && !deserializeFromComment(commentNode.text)) {
11
- accept('warning', `Malformed @likec4-generated (ignored)`, {
12
- node: el,
13
- range: commentNode.range
14
- })
15
- }
8
+ // const commentNode = CstUtils.findCommentNode(el.$cstNode, ['BLOCK_COMMENT'])
9
+ // if (commentNode && hasManualLayout(commentNode.text) && !deserializeFromComment(commentNode.text)) {
10
+ // accept('warning', `Malformed @likec4-generated (ignored)`, {
11
+ // node: el,
12
+ // range: commentNode.range
13
+ // })
14
+ // }
16
15
  if (!el.name) {
17
16
  return
18
17
  }
@@ -1,64 +1,65 @@
1
- import { invariant } from '@likec4/core'
2
- import type { EdgeId, Fqn, ViewManualLayout } from '@likec4/core/types'
3
- import JSON5 from 'json5'
4
- import { chunk, entries, hasAtLeast, mapToObj } from 'remeda'
1
+ import type { ViewManualLayout } from '@likec4/core/types'
2
+ import { decode, encode } from '@msgpack/msgpack'
3
+ import { fromBase64, toBase64 } from '@smithy/util-base64'
4
+ import { mapValues } from 'remeda'
5
5
 
6
- export namespace CompactViewManualLayout {
7
- export type Node = [
8
- id: Fqn,
9
- x: number,
10
- y: number,
11
- width: number,
12
- height: number
13
- ]
14
-
15
- export type Edge = [
16
- id: EdgeId,
17
- // flatten array of points, [x1, y1, x2, y2, ...]
18
- controlPoints: number[]
19
- ]
20
-
21
- // TODO replace with Zod/Valibot
22
- export function isCompactLayout(layout: any): layout is CompactViewManualLayout {
23
- return Array.isArray(layout) && hasAtLeast(layout, 3)
24
- && layout[0] === 1
25
- && Array.isArray(layout[1])
26
- && Array.isArray(layout[2])
27
- }
28
-
29
- export function pack(layout: ViewManualLayout): CompactViewManualLayout {
30
- return [
31
- 1,
32
- entries(layout.nodes).map(([id, { x, y, width, height }]) => [id as Fqn, x, y, width, height]),
33
- entries(layout.edges).map((
34
- [id, { controlPoints }]
35
- ) => [id as EdgeId, controlPoints.flatMap(({ x, y }) => [x, y])])
36
- ]
6
+ function pack({
7
+ nodes,
8
+ edges,
9
+ ...rest
10
+ }: ViewManualLayout) {
11
+ return {
12
+ ...rest,
13
+ nodes: mapValues(nodes, ({ x, y, width, height, isCompound, ...n }) => ({
14
+ ...n,
15
+ b: [x, y, width, height] as const,
16
+ c: isCompound
17
+ })),
18
+ edges: mapValues(edges, ({ points, controlPoints, labelBBox, dotpos, ...e }) => ({
19
+ ...!!controlPoints && { cp: controlPoints },
20
+ ...!!labelBBox && { l: labelBBox },
21
+ ...!!dotpos && { dp: dotpos },
22
+ ...e,
23
+ p: points
24
+ }))
37
25
  }
26
+ }
38
27
 
39
- export function unpack([_v, nodes, edges]: CompactViewManualLayout): ViewManualLayout {
40
- return {
41
- nodes: mapToObj(nodes, ([id, x, y, width, height]) => [id, { x, y, width, height }]),
42
- // edges: Object.fromEntries(edges.map(([id, controlPoints]) => [id, { controlPoints: mapWithFeedback(controlPoints, (x, y) => ({ x, y }), { x: 0, y: 0 }) }])
43
- edges: mapToObj(edges, ([id, controlPoints]) => {
44
- return [id, {
45
- controlPoints: chunk(controlPoints, 2).map(([x, y = 0]) => ({ x, y }))
46
- }]
47
- })
48
- }
28
+ function unpack({
29
+ nodes,
30
+ edges,
31
+ ...rest
32
+ }: ReturnType<typeof pack>): ViewManualLayout {
33
+ return {
34
+ ...rest,
35
+ nodes: mapValues(nodes, ({ b, c, ...n }) => ({
36
+ x: b[0],
37
+ y: b[1],
38
+ width: b[2],
39
+ height: b[3],
40
+ isCompound: c,
41
+ ...n
42
+ })),
43
+ edges: mapValues(edges, ({ p, cp, l, dp, ...e }) => ({
44
+ ...!!cp && { controlPoints: cp },
45
+ ...!!l && { labelBBox: l },
46
+ ...!!dp && { dotpos: dp },
47
+ ...e,
48
+ points: p
49
+ }))
49
50
  }
50
51
  }
51
52
 
52
- export type CompactViewManualLayout = [
53
- 1, // version
54
- nodes: Array<CompactViewManualLayout.Node>,
55
- edges: Array<CompactViewManualLayout.Edge>
56
- ]
57
-
58
53
  export function serializeToComment(layout: ViewManualLayout) {
59
- const compacted = CompactViewManualLayout.pack(layout)
60
- const encoded = btoa(JSON5.stringify(compacted))
61
- const lines = chunk(Array.from(encoded), 100).map(l => ' * ' + l.join(''))
54
+ const bytes = encode(pack(layout))
55
+ const base64 = toBase64(bytes)
56
+ const lines = [] as string[]
57
+ let offset = 0
58
+ const MAX_LINE_LENGTH = 200
59
+ while (offset < base64.length) {
60
+ lines.push(' * ' + base64.slice(offset, Math.min(offset + MAX_LINE_LENGTH, base64.length)))
61
+ offset += MAX_LINE_LENGTH
62
+ }
62
63
  lines.unshift(
63
64
  '/**',
64
65
  ' * @likec4-generated(v1)'
@@ -72,24 +73,16 @@ export function hasManualLayout(comment: string) {
72
73
  return comment.includes('@likec4-generated')
73
74
  }
74
75
 
75
- export function deserializeFromComment(comment: string): ViewManualLayout | undefined {
76
+ export function deserializeFromComment(comment: string): ViewManualLayout {
76
77
  if (!hasManualLayout(comment)) {
77
- console.error(`Not a likec4-generated comment: ${comment}`)
78
- return undefined
79
- }
80
- try {
81
- const b64 = comment
82
- .trim()
83
- .split('\n')
84
- .filter(l => !l.includes('**') && !l.includes('@likec4-') && !l.includes('*/'))
85
- .map(l => l.replaceAll('*', '').trim())
86
- .join('')
87
- const decodedb64 = atob(b64)
88
- const compacted = JSON5.parse(decodedb64)
89
- invariant(CompactViewManualLayout.isCompactLayout(compacted), 'Invalid compacted layout')
90
- return CompactViewManualLayout.unpack(compacted)
91
- } catch (e) {
92
- console.error(e)
93
- return undefined
78
+ throw new Error(`Not a likec4-generated comment: ${comment}`)
94
79
  }
80
+ const b64 = comment
81
+ .trim()
82
+ .split('\n')
83
+ .filter(l => !l.includes('**') && !l.includes('@likec4-') && !l.includes('*/'))
84
+ .map(l => l.replaceAll('*', '').trim())
85
+ .join('')
86
+ const decodedb64 = fromBase64(b64)
87
+ return unpack(decode(decodedb64) as any) as ViewManualLayout
95
88
  }
@@ -1,14 +1,20 @@
1
1
  import type { LikeC4View } from '@likec4/core'
2
2
  import { invariant } from '@likec4/core'
3
- import { hasAtLeast, unique, zip } from 'remeda'
3
+ import { filter, hasAtLeast, isTruthy, map, pipe, unique, zip } from 'remeda'
4
4
 
5
5
  function commonAncestorPath(views: LikeC4View[], sep = '/') {
6
- if (views.length <= 1) return ''
7
- const uniqURIs = unique(views.flatMap(({ docUri }) => (docUri ? [docUri] : [])))
6
+ const uniqURIs = pipe(
7
+ views,
8
+ map(v => v.docUri),
9
+ filter(isTruthy),
10
+ unique()
11
+ )
8
12
  if (uniqURIs.length === 0) return ''
9
- if (uniqURIs.length === 1) {
10
- invariant(hasAtLeast(uniqURIs, 1))
11
- return new URL(uniqURIs[0]).pathname
13
+ if (hasAtLeast(uniqURIs, 1) && uniqURIs.length === 1) {
14
+ const parts = new URL(uniqURIs[0]).pathname.split(sep)
15
+ if (parts.length <= 1) return sep
16
+ parts.pop() // remove filename
17
+ return parts.join(sep) + sep
12
18
  }
13
19
  invariant(hasAtLeast(uniqURIs, 2), 'Expected at least 2 unique URIs')
14
20
  const [baseUri, ...tail] = uniqURIs
@@ -31,7 +37,8 @@ function commonAncestorPath(views: LikeC4View[], sep = '/') {
31
37
  }
32
38
 
33
39
  export function resolveRelativePaths(views: LikeC4View[]): LikeC4View[] {
34
- const commonPrefix = commonAncestorPath(views)
40
+ const sep = '/'
41
+ const commonPrefix = commonAncestorPath(views, sep)
35
42
  return (
36
43
  views
37
44
  // For each view, compute the relative path to the common prefix
@@ -43,12 +50,19 @@ export function resolveRelativePaths(views: LikeC4View[]): LikeC4View[] {
43
50
  parts: []
44
51
  }
45
52
  }
46
- const path = new URL(view.docUri).pathname
47
- const parts = path.replace(commonPrefix, '').split('/')
48
- parts.pop() // remove filename
53
+ let path = new URL(view.docUri).pathname
54
+ if (commonPrefix.length > 0) {
55
+ invariant(
56
+ path.startsWith(commonPrefix),
57
+ `Expect path "${path}" to start with common prefix: "${commonPrefix}"`
58
+ )
59
+ path = path.slice(commonPrefix.length)
60
+ } else {
61
+ path = path.includes(sep) ? path.slice(path.lastIndexOf(sep) + 1) : path
62
+ }
49
63
  return {
50
64
  ...view,
51
- parts
65
+ parts: path.split(sep)
52
66
  }
53
67
  })
54
68
  // Sort views by path segments
@@ -72,13 +86,10 @@ export function resolveRelativePaths(views: LikeC4View[]): LikeC4View[] {
72
86
  })
73
87
  // Build relativePath from path segments
74
88
  .map(({ parts, ...view }) => {
75
- if (view.docUri) {
76
- return {
77
- ...view,
78
- relativePath: parts.join('/')
79
- }
89
+ return {
90
+ ...view,
91
+ relativePath: parts.join(sep)
80
92
  }
81
- return view
82
93
  })
83
94
  )
84
95
  }
@@ -0,0 +1,33 @@
1
+ import type { ComputedView } from '@likec4/core/types'
2
+ import objectHash from 'object-hash'
3
+ import { isString, pick } from 'remeda'
4
+ import type { SetOptional } from 'type-fest'
5
+
6
+ export function calcViewLayoutHash<V extends ComputedView>(view: SetOptional<V, 'hash'>): V {
7
+ const tohash = {
8
+ id: view.id,
9
+ __: view.__ ?? 'element',
10
+ autoLayout: view.autoLayout,
11
+ nodes: view.nodes
12
+ .map(pick(['id', 'title', 'description', 'technology', 'shape', 'icon', 'children']))
13
+ .toSorted((a, b) => a.id.localeCompare(b.id)),
14
+ edges: view.edges
15
+ .map(pick(['id', 'source', 'target', 'label', 'description', 'technology', 'dir', 'head', 'tail', 'line']))
16
+ .toSorted((a, b) => a.id.localeCompare(b.id))
17
+ }
18
+ view.hash = objectHash(tohash, {
19
+ ignoreUnknown: true,
20
+ respectType: false,
21
+ replacer(value) {
22
+ if (!isString(value)) {
23
+ return value
24
+ }
25
+ value = value.trim()
26
+ if (value.match('^(aws|tech|gcp|https|http)')) {
27
+ value = 'U' // hide urls
28
+ }
29
+ return value
30
+ }
31
+ })
32
+ return view as V
33
+ }