@platformos/platformos-language-server-common 0.0.12 → 0.0.13

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 (69) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/PropertyShapeInference.d.ts +5 -0
  3. package/dist/PropertyShapeInference.js +28 -0
  4. package/dist/PropertyShapeInference.js.map +1 -1
  5. package/dist/TypeSystem.js +106 -55
  6. package/dist/TypeSystem.js.map +1 -1
  7. package/dist/VariableShapeExtractor.js +2 -4
  8. package/dist/VariableShapeExtractor.js.map +1 -1
  9. package/dist/completions/CompletionsProvider.d.ts +1 -0
  10. package/dist/completions/CompletionsProvider.js +6 -0
  11. package/dist/completions/CompletionsProvider.js.map +1 -1
  12. package/dist/completions/providers/GraphQLFieldCompletionProvider.d.ts +12 -0
  13. package/dist/completions/providers/GraphQLFieldCompletionProvider.js +75 -0
  14. package/dist/completions/providers/GraphQLFieldCompletionProvider.js.map +1 -0
  15. package/dist/definitions/DefinitionProvider.d.ts +19 -1
  16. package/dist/definitions/DefinitionProvider.js +33 -1
  17. package/dist/definitions/DefinitionProvider.js.map +1 -1
  18. package/dist/definitions/providers/PageRouteDefinitionProvider.d.ts +51 -0
  19. package/dist/definitions/providers/PageRouteDefinitionProvider.js +204 -0
  20. package/dist/definitions/providers/PageRouteDefinitionProvider.js.map +1 -0
  21. package/dist/definitions/providers/RenderPartialDefinitionProvider.d.ts +14 -0
  22. package/dist/definitions/providers/RenderPartialDefinitionProvider.js +50 -0
  23. package/dist/definitions/providers/RenderPartialDefinitionProvider.js.map +1 -0
  24. package/dist/diagnostics/runChecks.d.ts +3 -1
  25. package/dist/diagnostics/runChecks.js +2 -1
  26. package/dist/diagnostics/runChecks.js.map +1 -1
  27. package/dist/documentLinks/DocumentLinksProvider.d.ts +3 -1
  28. package/dist/documentLinks/DocumentLinksProvider.js +16 -20
  29. package/dist/documentLinks/DocumentLinksProvider.js.map +1 -1
  30. package/dist/hover/HoverProvider.d.ts +1 -0
  31. package/dist/hover/HoverProvider.js +6 -0
  32. package/dist/hover/HoverProvider.js.map +1 -1
  33. package/dist/hover/providers/GraphQLFieldHoverProvider.d.ts +12 -0
  34. package/dist/hover/providers/GraphQLFieldHoverProvider.js +85 -0
  35. package/dist/hover/providers/GraphQLFieldHoverProvider.js.map +1 -0
  36. package/dist/server/AppGraphManager.js +3 -4
  37. package/dist/server/AppGraphManager.js.map +1 -1
  38. package/dist/server/CachedFileSystem.js +7 -2
  39. package/dist/server/CachedFileSystem.js.map +1 -1
  40. package/dist/server/startServer.js +71 -5
  41. package/dist/server/startServer.js.map +1 -1
  42. package/dist/tsconfig.tsbuildinfo +1 -1
  43. package/dist/utils/searchPaths.d.ts +21 -0
  44. package/dist/utils/searchPaths.js +34 -0
  45. package/dist/utils/searchPaths.js.map +1 -0
  46. package/package.json +6 -4
  47. package/src/PropertyShapeInference.spec.ts +44 -0
  48. package/src/PropertyShapeInference.ts +27 -0
  49. package/src/TypeSystem.spec.ts +59 -0
  50. package/src/TypeSystem.ts +119 -58
  51. package/src/VariableShapeExtractor.ts +2 -4
  52. package/src/completions/CompletionsProvider.ts +11 -0
  53. package/src/completions/providers/GraphQLFieldCompletionProvider.ts +78 -0
  54. package/src/definitions/DefinitionProvider.ts +52 -0
  55. package/src/definitions/providers/PageRouteDefinitionProvider.spec.ts +753 -0
  56. package/src/definitions/providers/PageRouteDefinitionProvider.ts +250 -0
  57. package/src/definitions/providers/RenderPartialDefinitionProvider.spec.ts +191 -0
  58. package/src/definitions/providers/RenderPartialDefinitionProvider.ts +70 -0
  59. package/src/diagnostics/runChecks.ts +4 -0
  60. package/src/documentLinks/DocumentLinksProvider.spec.ts +131 -10
  61. package/src/documentLinks/DocumentLinksProvider.ts +23 -32
  62. package/src/hover/HoverProvider.ts +11 -0
  63. package/src/hover/providers/GraphQLFieldHoverProvider.spec.ts +124 -0
  64. package/src/hover/providers/GraphQLFieldHoverProvider.ts +90 -0
  65. package/src/server/AppGraphManager.ts +3 -5
  66. package/src/server/CachedFileSystem.ts +11 -2
  67. package/src/server/startServer.ts +85 -5
  68. package/src/utils/searchPaths.spec.ts +101 -0
  69. package/src/utils/searchPaths.ts +32 -0
@@ -0,0 +1,21 @@
1
+ import { AbstractFileSystem } from '@platformos/platformos-common';
2
+ import { URI } from 'vscode-uri';
3
+ /**
4
+ * Cached loader for theme_search_paths from app/config.yml.
5
+ *
6
+ * Caches per root URI so that repeated calls (document links, definitions,
7
+ * checks) within the same editor session don't re-read and re-parse the
8
+ * config file on every invocation.
9
+ *
10
+ * Call `invalidate()` when app/config.yml changes on disk — the file watcher
11
+ * in startServer.ts handles this. This is the only reliable invalidation
12
+ * point because config.yml changes may come from external tools (vim, CLI)
13
+ * that don't trigger editor-level change events.
14
+ */
15
+ export declare class SearchPathsLoader {
16
+ private fs;
17
+ private cache;
18
+ constructor(fs: AbstractFileSystem);
19
+ get(rootUri: URI): Promise<string[] | null>;
20
+ invalidate(): void;
21
+ }
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SearchPathsLoader = void 0;
4
+ const platformos_common_1 = require("@platformos/platformos-common");
5
+ /**
6
+ * Cached loader for theme_search_paths from app/config.yml.
7
+ *
8
+ * Caches per root URI so that repeated calls (document links, definitions,
9
+ * checks) within the same editor session don't re-read and re-parse the
10
+ * config file on every invocation.
11
+ *
12
+ * Call `invalidate()` when app/config.yml changes on disk — the file watcher
13
+ * in startServer.ts handles this. This is the only reliable invalidation
14
+ * point because config.yml changes may come from external tools (vim, CLI)
15
+ * that don't trigger editor-level change events.
16
+ */
17
+ class SearchPathsLoader {
18
+ constructor(fs) {
19
+ this.fs = fs;
20
+ this.cache = new Map();
21
+ }
22
+ get(rootUri) {
23
+ const key = rootUri.toString();
24
+ if (!this.cache.has(key)) {
25
+ this.cache.set(key, (0, platformos_common_1.loadSearchPaths)(this.fs, rootUri));
26
+ }
27
+ return this.cache.get(key);
28
+ }
29
+ invalidate() {
30
+ this.cache.clear();
31
+ }
32
+ }
33
+ exports.SearchPathsLoader = SearchPathsLoader;
34
+ //# sourceMappingURL=searchPaths.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"searchPaths.js","sourceRoot":"","sources":["../../src/utils/searchPaths.ts"],"names":[],"mappings":";;;AAAA,qEAAoF;AAGpF;;;;;;;;;;;GAWG;AACH,MAAa,iBAAiB;IAG5B,YAAoB,EAAsB;QAAtB,OAAE,GAAF,EAAE,CAAoB;QAFlC,UAAK,GAAG,IAAI,GAAG,EAAoC,CAAC;IAEf,CAAC;IAE9C,GAAG,CAAC,OAAY;QACd,MAAM,GAAG,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC;QAC/B,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;YACzB,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,IAAA,mCAAe,EAAC,IAAI,CAAC,EAAE,EAAE,OAAO,CAAC,CAAC,CAAC;QACzD,CAAC;QACD,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAE,CAAC;IAC9B,CAAC;IAED,UAAU;QACR,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC;IACrB,CAAC;CACF;AAhBD,8CAgBC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformos/platformos-language-server-common",
3
- "version": "0.0.12",
3
+ "version": "0.0.13",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "author": "platformOS",
@@ -27,10 +27,12 @@
27
27
  "type-check": "tsc --noEmit -p ./tsconfig.json"
28
28
  },
29
29
  "dependencies": {
30
- "@platformos/liquid-html-parser": "^0.0.11",
31
- "@platformos/platformos-check-common": "0.0.12",
32
- "@platformos/platformos-graph": "0.0.12",
30
+ "@platformos/liquid-html-parser": "^0.0.12",
31
+ "@platformos/platformos-check-common": "0.0.13",
32
+ "@platformos/platformos-graph": "0.0.13",
33
33
  "@vscode/web-custom-data": "^0.4.6",
34
+ "graphql": "^16.12.0",
35
+ "graphql-language-service": "^5.2.2",
34
36
  "vscode-json-languageservice": "^5.7.1",
35
37
  "vscode-languageserver": "^9.0.1",
36
38
  "vscode-css-languageservice": "^6.3.9",
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { shapeToJSONPlaceholder } from './PropertyShapeInference';
3
+ import type { PropertyShape } from './PropertyShapeInference';
4
+
5
+ describe('shapeToJSONPlaceholder', () => {
6
+ it('returns "null" for undefined', () => {
7
+ expect(shapeToJSONPlaceholder(undefined)).toBe('null');
8
+ });
9
+
10
+ it('returns "" for string primitive', () => {
11
+ const shape: PropertyShape = { kind: 'primitive', primitiveType: 'string' };
12
+ expect(shapeToJSONPlaceholder(shape)).toBe('""');
13
+ });
14
+
15
+ it('returns "0" for number primitive', () => {
16
+ const shape: PropertyShape = { kind: 'primitive', primitiveType: 'number' };
17
+ expect(shapeToJSONPlaceholder(shape)).toBe('0');
18
+ });
19
+
20
+ it('returns "true" for boolean primitive', () => {
21
+ const shape: PropertyShape = { kind: 'primitive', primitiveType: 'boolean' };
22
+ expect(shapeToJSONPlaceholder(shape)).toBe('true');
23
+ });
24
+
25
+ it('returns "null" for null primitive', () => {
26
+ const shape: PropertyShape = { kind: 'primitive', primitiveType: 'null' };
27
+ expect(shapeToJSONPlaceholder(shape)).toBe('null');
28
+ });
29
+
30
+ it('returns "null" for untyped primitive', () => {
31
+ const shape: PropertyShape = { kind: 'primitive' };
32
+ expect(shapeToJSONPlaceholder(shape)).toBe('null');
33
+ });
34
+
35
+ it('returns "[]" for array shape', () => {
36
+ const shape: PropertyShape = { kind: 'array' };
37
+ expect(shapeToJSONPlaceholder(shape)).toBe('[]');
38
+ });
39
+
40
+ it('returns "{}" for object shape', () => {
41
+ const shape: PropertyShape = { kind: 'object', properties: new Map() };
42
+ expect(shapeToJSONPlaceholder(shape)).toBe('{}');
43
+ });
44
+ });
@@ -472,3 +472,30 @@ export function mergePropertyIntoShape(
472
472
  newProperties.set(key, valueShape);
473
473
  return { kind: 'object', properties: newProperties };
474
474
  }
475
+
476
+ /**
477
+ * Map a PropertyShape to a JSON literal placeholder string suitable for
478
+ * substituting a {{ expr | json }} expression during static analysis.
479
+ */
480
+ export function shapeToJSONPlaceholder(shape: PropertyShape | undefined): string {
481
+ if (!shape) return 'null';
482
+ switch (shape.kind) {
483
+ case 'primitive':
484
+ switch (shape.primitiveType) {
485
+ case 'string':
486
+ return '""';
487
+ case 'number':
488
+ return '0';
489
+ case 'boolean':
490
+ return 'true';
491
+ default:
492
+ return 'null';
493
+ }
494
+ case 'array':
495
+ return '[]';
496
+ case 'object':
497
+ return '{}';
498
+ default:
499
+ return 'null';
500
+ }
501
+ }
@@ -901,4 +901,63 @@ query {
901
901
  }
902
902
  });
903
903
  });
904
+
905
+ describe('parse_json block with {{ expr | json }} children', () => {
906
+ it('infers object shape from static JSON in block form (baseline)', async () => {
907
+ // Existing behaviour: static JSON works fine
908
+ const ast = toLiquidHtmlAST(
909
+ `{% parse_json data %}{"id": 1, "name": "hello"}{% endparse_json %}{{ data }}`,
910
+ );
911
+ const output = ast.children[1];
912
+ assert(isLiquidVariableOutput(output));
913
+ const inferredType = await typeSystem.inferType(output.markup, ast, 'file:///file.liquid');
914
+ assert(typeof inferredType !== 'string' && inferredType.kind === 'shape');
915
+ expect(inferredType.shape.properties?.has('id')).toBe(true);
916
+ expect(inferredType.shape.properties?.has('name')).toBe(true);
917
+ });
918
+
919
+ it('infers object shape when values are {{ expr | json }} — unresolvable falls back to null', async () => {
920
+ // object.unknown_var is not in the docset, so shape is unresolvable → null placeholder
921
+ const ast = toLiquidHtmlAST(
922
+ `{% parse_json data %}\n{"id": {{ object.unknown_var | json }}, "name": {{ object.unknown_var2 | json }}}\n{% endparse_json %}{{ data }}`,
923
+ );
924
+ const output = ast.children[1];
925
+ assert(isLiquidVariableOutput(output));
926
+ const inferredType = await typeSystem.inferType(output.markup, ast, 'file:///file.liquid');
927
+ // Keys must be discovered even when types are unknown
928
+ assert(typeof inferredType !== 'string' && inferredType.kind === 'shape');
929
+ expect(inferredType.shape.properties?.has('id')).toBe(true);
930
+ expect(inferredType.shape.properties?.has('name')).toBe(true);
931
+ });
932
+
933
+ it('infers correct value types when the expression resolves via the type system', async () => {
934
+ // user is established as a ShapeType by the first parse_json block
935
+ const ast = toLiquidHtmlAST(
936
+ `{% parse_json user %}{"name": "John", "age": 30}{% endparse_json %}\n{% parse_json data %}\n{"username": {{ user.name | json }}, "years": {{ user.age | json }}}\n{% endparse_json %}{{ data }}`,
937
+ );
938
+ const output = ast.children[2];
939
+ assert(isLiquidVariableOutput(output));
940
+ const inferredType = await typeSystem.inferType(output.markup, ast, 'file:///file.liquid');
941
+ assert(typeof inferredType !== 'string' && inferredType.kind === 'shape');
942
+ const usernameShape = inferredType.shape.properties?.get('username');
943
+ expect(usernameShape?.kind).toBe('primitive');
944
+ expect(usernameShape?.primitiveType).toBe('string');
945
+ const yearsShape = inferredType.shape.properties?.get('years');
946
+ expect(yearsShape?.kind).toBe('primitive');
947
+ expect(yearsShape?.primitiveType).toBe('number');
948
+ });
949
+
950
+ it('falls back gracefully when no | json filter present on LiquidVariableOutput', async () => {
951
+ // Without | json, fall back to null for that key; key still discoverable via TextNode context
952
+ const ast = toLiquidHtmlAST(
953
+ `{% parse_json data %}\n{"title": "{{ object.title }}"}\n{% endparse_json %}{{ data }}`,
954
+ );
955
+ const output = ast.children[1];
956
+ assert(isLiquidVariableOutput(output));
957
+ const inferredType = await typeSystem.inferType(output.markup, ast, 'file:///file.liquid');
958
+ // key must still be discovered (the TextNodes provide `"title": "` and `"`)
959
+ assert(typeof inferredType !== 'string' && inferredType.kind === 'shape');
960
+ expect(inferredType.shape.properties?.has('title')).toBe(true);
961
+ });
962
+ });
904
963
  });
package/src/TypeSystem.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  LiquidTagIncrement,
12
12
  LiquidVariable,
13
13
  LiquidVariableLookup,
14
+ LiquidVariableOutput,
14
15
  NamedTags,
15
16
  NodeTypes,
16
17
  TextNode,
@@ -28,8 +29,6 @@ import {
28
29
  ReturnType,
29
30
  SourceCodeType,
30
31
  PlatformOSDocset,
31
- isError,
32
- parseJSON,
33
32
  path,
34
33
  BasicParamTypes,
35
34
  getValidParamTypes,
@@ -42,10 +41,9 @@ import {
42
41
  inferShapeFromJSONString,
43
42
  inferShapeFromJsonLiteral,
44
43
  inferShapeFromGraphQL,
45
- lookupPropertyPath,
46
44
  mergeShapes,
47
45
  shapeToTypeString,
48
- shapeToDetailString,
46
+ shapeToJSONPlaceholder,
49
47
  } from './PropertyShapeInference';
50
48
  import { AbstractFileSystem, DocumentsLocator } from '@platformos/platformos-common';
51
49
  import { URI } from 'vscode-uri';
@@ -396,58 +394,12 @@ async function buildSymbolsTable(
396
394
  let valueShape: PropertyShape | undefined;
397
395
 
398
396
  // Resolver for variable references inside JSON literals
399
- const resolveExpr = (expr: LiquidExpression): PropertyShape | undefined => {
400
- if (expr.type !== NodeTypes.VariableLookup || !expr.name) return undefined;
401
-
402
- // Look up the variable's shape from already-processed assignments
403
- const shapes = variableShapes.get(expr.name);
404
- const shapeEntry = shapes
405
- ? findLastApplicableShape(shapes, node.position.start)
406
- : undefined;
407
- let shape = shapeEntry?.shape;
408
-
409
- // Fall back to seedSymbolsTable
410
- if (!shape && seedSymbolsTable[expr.name]) {
411
- for (const tr of seedSymbolsTable[expr.name]) {
412
- if (tr.range[0] < node.position.start) {
413
- if (typeof tr.type !== 'string' && tr.type.kind === 'shape') {
414
- shape = tr.type.shape;
415
- } else if (typeof tr.type === 'string' || tr.type.kind === 'array') {
416
- shape = resolvedTypeToShape(tr.type);
417
- }
418
- }
419
- }
420
- }
421
-
422
- if (!shape) return undefined;
423
-
424
- // Resolve lookups (e.g. a.x or a["key"])
425
- for (const lookup of expr.lookups) {
426
- if (shape.kind === 'object' && lookup.type === NodeTypes.String && shape.properties) {
427
- const prop = shape.properties.get(lookup.value);
428
- if (prop) {
429
- shape = prop;
430
- } else {
431
- return undefined;
432
- }
433
- } else if (shape.kind === 'array') {
434
- if (
435
- lookup.type === NodeTypes.Number ||
436
- (lookup.type === NodeTypes.String &&
437
- (lookup.value === 'first' || lookup.value === 'last'))
438
- ) {
439
- shape = shape.itemShape;
440
- if (!shape) return undefined;
441
- } else {
442
- return undefined;
443
- }
444
- } else {
445
- return undefined;
446
- }
447
- }
448
-
449
- return shape;
450
- };
397
+ const resolveExpr = resolveExpressionShape(
398
+ node.position.start,
399
+ variableShapes,
400
+ seedSymbolsTable,
401
+ objectMap,
402
+ );
451
403
 
452
404
  // Case 1: JSON literal expression
453
405
  if (
@@ -649,9 +601,26 @@ async function buildSymbolsTable(
649
601
  else if (isLiquidTagParseJson(node)) {
650
602
  const variableName = node.markup.name;
651
603
  if (variableName && node.children) {
604
+ const resolveExprAtTag = resolveExpressionShape(
605
+ node.position.start,
606
+ variableShapes,
607
+ seedSymbolsTable,
608
+ objectMap,
609
+ );
652
610
  const textContent = node.children
653
- .filter((c): c is TextNode => c.type === NodeTypes.TextNode)
654
- .map((c) => c.value)
611
+ .map((child) => {
612
+ if (child.type === NodeTypes.TextNode) return (child as TextNode).value;
613
+ if (child.type === NodeTypes.LiquidVariableOutput) {
614
+ const variable = (child as LiquidVariableOutput).markup;
615
+ if (typeof variable === 'string') return 'null';
616
+ const lastFilter = variable.filters?.at(-1);
617
+ if (lastFilter?.name === 'json') {
618
+ return shapeToJSONPlaceholder(resolveExprAtTag(variable.expression));
619
+ }
620
+ return 'null';
621
+ }
622
+ return 'null';
623
+ })
655
624
  .join('');
656
625
  const shape = inferShapeFromJSONString(textContent);
657
626
  if (shape) {
@@ -1746,6 +1715,98 @@ function mergeNestedPropertyIntoShape(
1746
1715
  return { kind: 'object', properties: newProperties };
1747
1716
  }
1748
1717
 
1718
+ /**
1719
+ * Resolve a LiquidExpression to a PropertyShape using accumulated variable shapes,
1720
+ * the seed symbols table, and optionally the docset object map.
1721
+ *
1722
+ * Returns a resolver function that takes a LiquidExpression and returns a PropertyShape
1723
+ * or undefined.
1724
+ */
1725
+ function resolveExpressionShape(
1726
+ atPosition: number,
1727
+ variableShapes: Map<string, { shape: PropertyShape; rangeEnd: number }[]>,
1728
+ seedSymbolsTable: Record<string, TypeRange[]>,
1729
+ objectMap?: Record<string, ObjectEntry>,
1730
+ ): (expr: LiquidExpression | ComplexLiquidExpression) => PropertyShape | undefined {
1731
+ return (expr) => {
1732
+ if (expr.type !== NodeTypes.VariableLookup || !expr.name) return undefined;
1733
+
1734
+ // Look up the variable's shape from already-processed assignments
1735
+ const shapes = variableShapes.get(expr.name);
1736
+ const shapeEntry = shapes ? findLastApplicableShape(shapes, atPosition) : undefined;
1737
+ let shape: PropertyShape | undefined = shapeEntry?.shape;
1738
+
1739
+ // Track the current PseudoType (docset object name) for objectMap-based property resolution
1740
+ let pseudoType: string | undefined;
1741
+
1742
+ // Fall back to seedSymbolsTable
1743
+ if (!shape && !pseudoType && seedSymbolsTable[expr.name]) {
1744
+ for (const tr of seedSymbolsTable[expr.name]) {
1745
+ if (tr.range[0] < atPosition) {
1746
+ if (typeof tr.type !== 'string' && tr.type.kind === 'shape') {
1747
+ shape = tr.type.shape;
1748
+ } else if (typeof tr.type === 'string') {
1749
+ pseudoType = tr.type;
1750
+ shape = resolvedTypeToShape(tr.type);
1751
+ } else if (tr.type.kind === 'array') {
1752
+ shape = resolvedTypeToShape(tr.type);
1753
+ }
1754
+ }
1755
+ }
1756
+ }
1757
+
1758
+ if (!shape && !pseudoType) return undefined;
1759
+
1760
+ // Resolve lookups (e.g. a.x or a["key"])
1761
+ for (const lookup of expr.lookups) {
1762
+ // PseudoType resolution via objectMap (e.g. context.current_user where context is a docset type)
1763
+ if (pseudoType !== undefined && objectMap) {
1764
+ if (lookup.type !== NodeTypes.String) return undefined;
1765
+ const propType = inferPseudoTypePropertyType(pseudoType, lookup, objectMap);
1766
+ // inferPseudoTypePropertyType returns PseudoType | ArrayType (string or { kind: 'array' })
1767
+ if (typeof propType === 'string') {
1768
+ pseudoType = propType;
1769
+ shape = resolvedTypeToShape(propType);
1770
+ } else if (isArrayType(propType)) {
1771
+ pseudoType = undefined;
1772
+ shape = resolvedTypeToShape(propType);
1773
+ } else {
1774
+ return undefined;
1775
+ }
1776
+ continue;
1777
+ }
1778
+
1779
+ if (!shape) return undefined;
1780
+
1781
+ if (shape.kind === 'object' && lookup.type === NodeTypes.String && shape.properties) {
1782
+ const prop = shape.properties.get(lookup.value);
1783
+ if (prop) {
1784
+ pseudoType = undefined;
1785
+ shape = prop;
1786
+ } else {
1787
+ return undefined;
1788
+ }
1789
+ } else if (shape.kind === 'array') {
1790
+ if (
1791
+ lookup.type === NodeTypes.Number ||
1792
+ (lookup.type === NodeTypes.String &&
1793
+ (lookup.value === 'first' || lookup.value === 'last'))
1794
+ ) {
1795
+ pseudoType = undefined;
1796
+ shape = shape.itemShape;
1797
+ if (!shape) return undefined;
1798
+ } else {
1799
+ return undefined;
1800
+ }
1801
+ } else {
1802
+ return undefined;
1803
+ }
1804
+ }
1805
+
1806
+ return shape;
1807
+ };
1808
+ }
1809
+
1749
1810
  /**
1750
1811
  * Find the last applicable shape for a variable at a given position
1751
1812
  */
@@ -107,8 +107,7 @@ export async function extractVariableShapes(
107
107
  closeShapeRange(variableName, node.position.end);
108
108
 
109
109
  const textContent = node.children
110
- .filter((c): c is TextNode => c.type === NodeTypes.TextNode)
111
- .map((c) => c.value)
110
+ .map((c) => (c.type === NodeTypes.TextNode ? (c as TextNode).value : 'null'))
112
111
  .join('');
113
112
  const shape = inferShapeFromJSONString(textContent);
114
113
  if (shape) {
@@ -129,8 +128,7 @@ export async function extractVariableShapes(
129
128
 
130
129
  if (node.children) {
131
130
  const textContent = node.children
132
- .filter((c): c is TextNode => c.type === NodeTypes.TextNode)
133
- .map((c) => c.value)
131
+ .map((c) => (c.type === NodeTypes.TextNode ? (c as TextNode).value : 'null'))
134
132
  .join('');
135
133
  const shape = inferShapeFromGraphQL(textContent, graphqlSchema);
136
134
  if (shape) {
@@ -27,6 +27,7 @@ import {
27
27
  TranslationCompletionProvider,
28
28
  } from './providers';
29
29
  import { GetPartialNamesForURI } from './providers/PartialCompletionProvider';
30
+ import { GraphQLFieldCompletionProvider } from './providers/GraphQLFieldCompletionProvider';
30
31
 
31
32
  export interface CompletionProviderDependencies {
32
33
  documentManager: DocumentManager;
@@ -47,6 +48,7 @@ export interface CompletionProviderDependencies {
47
48
 
48
49
  export class CompletionsProvider {
49
50
  private providers: Provider[] = [];
51
+ private graphqlFieldCompletionProvider: GraphQLFieldCompletionProvider;
50
52
  readonly documentManager: DocumentManager;
51
53
  readonly platformosDocset: PlatformOSDocset;
52
54
  readonly log: (message: string) => void;
@@ -66,6 +68,10 @@ export class CompletionsProvider {
66
68
  this.platformosDocset = platformosDocset;
67
69
  this.log = log;
68
70
  const typeSystem = new TypeSystem(platformosDocset, fs, documentsLocator, findAppRootURI);
71
+ this.graphqlFieldCompletionProvider = new GraphQLFieldCompletionProvider(
72
+ platformosDocset,
73
+ documentManager,
74
+ );
69
75
 
70
76
  this.providers = [
71
77
  new HtmlTagCompletionProvider(),
@@ -88,6 +94,11 @@ export class CompletionsProvider {
88
94
  const uri = params.textDocument.uri;
89
95
  const document = this.documentManager.get(uri);
90
96
 
97
+ // GraphQL files get dedicated completion support
98
+ if (document?.type === SourceCodeType.GraphQL) {
99
+ return this.graphqlFieldCompletionProvider.completions(params);
100
+ }
101
+
91
102
  // Supports only Liquid resources
92
103
  if (document?.type !== SourceCodeType.LiquidHtml) {
93
104
  return [];
@@ -0,0 +1,78 @@
1
+ import { CompletionItem, CompletionItemKind, CompletionParams } from 'vscode-languageserver';
2
+ import { DocumentManager } from '../../documents';
3
+ import { PlatformOSDocset } from '@platformos/platformos-check-common';
4
+
5
+ /**
6
+ * graphql-language-service and graphql must be loaded dynamically to avoid
7
+ * the "Cannot use GraphQLList from another module or realm" error that occurs
8
+ * when vitest transforms the graphql ESM module into separate instances.
9
+ */
10
+ let _glsMod: typeof import('graphql-language-service') | undefined;
11
+ function getGLS(): typeof import('graphql-language-service') {
12
+ if (!_glsMod) _glsMod = require('graphql-language-service');
13
+ return _glsMod!;
14
+ }
15
+
16
+ let _graphqlMod: typeof import('graphql') | undefined;
17
+ function getGraphQL(): typeof import('graphql') {
18
+ if (!_graphqlMod) _graphqlMod = require('graphql');
19
+ return _graphqlMod!;
20
+ }
21
+
22
+ export class GraphQLFieldCompletionProvider {
23
+ private schemaCache: any;
24
+ private schemaLoaded = false;
25
+
26
+ constructor(
27
+ private platformosDocset: PlatformOSDocset,
28
+ private documentManager: DocumentManager,
29
+ ) {}
30
+
31
+ private async getSchema(): Promise<any> {
32
+ if (!this.schemaLoaded) {
33
+ const sdl = await this.platformosDocset.graphQL();
34
+ if (sdl) {
35
+ try {
36
+ this.schemaCache = getGraphQL().buildSchema(sdl);
37
+ } catch {
38
+ // Invalid schema SDL
39
+ }
40
+ }
41
+ this.schemaLoaded = true;
42
+ }
43
+ return this.schemaCache;
44
+ }
45
+
46
+ async completions(params: CompletionParams): Promise<CompletionItem[]> {
47
+ const uri = params.textDocument.uri;
48
+ const document = this.documentManager.get(uri);
49
+ if (!document) return [];
50
+
51
+ const schema = await this.getSchema();
52
+ if (!schema) return [];
53
+
54
+ const content = document.textDocument.getText();
55
+ const gls = getGLS();
56
+ const position = new gls.Position(params.position.line, params.position.character);
57
+
58
+ try {
59
+ const suggestions = gls.getAutocompleteSuggestions(schema, content, position);
60
+
61
+ return suggestions.map((suggestion) => ({
62
+ label: suggestion.label,
63
+ kind: toCompletionItemKind(suggestion.kind),
64
+ detail: suggestion.detail ?? undefined,
65
+ documentation: suggestion.documentation ?? undefined,
66
+ }));
67
+ } catch {
68
+ return [];
69
+ }
70
+ }
71
+ }
72
+
73
+ function toCompletionItemKind(kind: number | undefined): CompletionItemKind {
74
+ if (kind !== undefined && kind >= 1 && kind <= 25) {
75
+ return kind as CompletionItemKind;
76
+ }
77
+ return CompletionItemKind.Field;
78
+ }
@@ -1,20 +1,72 @@
1
1
  import { findCurrentNode, SourceCodeType } from '@platformos/platformos-check-common';
2
+ import { AbstractFileSystem, DocumentsLocator, RouteTable } from '@platformos/platformos-common';
2
3
  import { DefinitionLink, DefinitionParams } from 'vscode-languageserver';
3
4
 
4
5
  import { AugmentedJsonSourceCode, DocumentManager } from '../documents';
6
+ import { SearchPathsLoader } from '../utils/searchPaths';
5
7
  import { BaseDefinitionProvider } from './BaseDefinitionProvider';
8
+ import { PageRouteDefinitionProvider } from './providers/PageRouteDefinitionProvider';
9
+ import { RenderPartialDefinitionProvider } from './providers/RenderPartialDefinitionProvider';
6
10
  import { TranslationStringDefinitionProvider } from './providers/TranslationStringDefinitionProvider';
7
11
 
8
12
  export class DefinitionProvider {
9
13
  private providers: BaseDefinitionProvider[];
14
+ private pageRouteProvider?: PageRouteDefinitionProvider;
10
15
 
11
16
  constructor(
12
17
  private documentManager: DocumentManager,
13
18
  getDefaultLocaleSourceCode: (uri: string) => Promise<AugmentedJsonSourceCode | null>,
19
+ fs?: AbstractFileSystem,
20
+ findAppRootURI?: (uri: string) => Promise<string | null>,
21
+ documentsLocator?: DocumentsLocator,
22
+ searchPathsCache?: SearchPathsLoader,
14
23
  ) {
15
24
  this.providers = [
16
25
  new TranslationStringDefinitionProvider(documentManager, getDefaultLocaleSourceCode),
17
26
  ];
27
+
28
+ if (fs && findAppRootURI) {
29
+ this.pageRouteProvider = new PageRouteDefinitionProvider(documentManager, fs, findAppRootURI);
30
+ this.providers.push(this.pageRouteProvider);
31
+
32
+ if (documentsLocator && searchPathsCache) {
33
+ this.providers.push(
34
+ new RenderPartialDefinitionProvider(
35
+ documentManager,
36
+ documentsLocator,
37
+ searchPathsCache,
38
+ findAppRootURI,
39
+ ),
40
+ );
41
+ }
42
+ }
43
+ }
44
+
45
+ /** Notify the route table that a page file was created or changed. */
46
+ onPageFileChanged(uri: string, content: string): void {
47
+ this.pageRouteProvider?.onPageFileChanged(uri, content);
48
+ }
49
+
50
+ /** Notify the route table that a page file was deleted. */
51
+ onPageFileDeleted(uri: string): void {
52
+ this.pageRouteProvider?.onPageFileDeleted(uri);
53
+ }
54
+
55
+ /**
56
+ * Invalidate the route table so it will be fully rebuilt on next use.
57
+ * Call after bulk filesystem changes (e.g., git checkout, branch switch).
58
+ */
59
+ invalidateRouteTable(): void {
60
+ this.pageRouteProvider?.invalidate();
61
+ }
62
+
63
+ /**
64
+ * Returns the shared RouteTable, or undefined if route support is not configured.
65
+ * When undefined (no fs/findAppRootURI), the check pipeline will build a fresh
66
+ * RouteTable per run via makeGetRouteTable in context-utils.ts.
67
+ */
68
+ getRouteTable(): RouteTable | undefined {
69
+ return this.pageRouteProvider?.getRouteTable();
18
70
  }
19
71
 
20
72
  async definitions(params: DefinitionParams): Promise<DefinitionLink[] | null> {