@platformos/platformos-language-server-common 0.0.11 → 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 (98) hide show
  1. package/CHANGELOG.md +43 -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 +117 -60
  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/codeActions/CodeActionsProvider.d.ts +1 -1
  10. package/dist/codeActions/providers/GenerateDocBlockProvider.d.ts +10 -0
  11. package/dist/codeActions/providers/GenerateDocBlockProvider.js +39 -0
  12. package/dist/codeActions/providers/GenerateDocBlockProvider.js.map +1 -0
  13. package/dist/commands/providers/GenerateDocBlockCommandProvider.d.ts +19 -0
  14. package/dist/commands/providers/GenerateDocBlockCommandProvider.js +64 -0
  15. package/dist/commands/providers/GenerateDocBlockCommandProvider.js.map +1 -0
  16. package/dist/completions/CompletionsProvider.d.ts +1 -0
  17. package/dist/completions/CompletionsProvider.js +6 -0
  18. package/dist/completions/CompletionsProvider.js.map +1 -1
  19. package/dist/completions/params/LiquidCompletionParams.js +7 -2
  20. package/dist/completions/params/LiquidCompletionParams.js.map +1 -1
  21. package/dist/completions/providers/GraphQLFieldCompletionProvider.d.ts +12 -0
  22. package/dist/completions/providers/GraphQLFieldCompletionProvider.js +75 -0
  23. package/dist/completions/providers/GraphQLFieldCompletionProvider.js.map +1 -0
  24. package/dist/definitions/DefinitionProvider.d.ts +19 -1
  25. package/dist/definitions/DefinitionProvider.js +33 -1
  26. package/dist/definitions/DefinitionProvider.js.map +1 -1
  27. package/dist/definitions/providers/PageRouteDefinitionProvider.d.ts +51 -0
  28. package/dist/definitions/providers/PageRouteDefinitionProvider.js +204 -0
  29. package/dist/definitions/providers/PageRouteDefinitionProvider.js.map +1 -0
  30. package/dist/definitions/providers/RenderPartialDefinitionProvider.d.ts +14 -0
  31. package/dist/definitions/providers/RenderPartialDefinitionProvider.js +50 -0
  32. package/dist/definitions/providers/RenderPartialDefinitionProvider.js.map +1 -0
  33. package/dist/diagnostics/runChecks.d.ts +3 -1
  34. package/dist/diagnostics/runChecks.js +2 -1
  35. package/dist/diagnostics/runChecks.js.map +1 -1
  36. package/dist/documentLinks/DocumentLinksProvider.d.ts +3 -1
  37. package/dist/documentLinks/DocumentLinksProvider.js +16 -20
  38. package/dist/documentLinks/DocumentLinksProvider.js.map +1 -1
  39. package/dist/documents/DocumentManager.js +0 -4
  40. package/dist/documents/DocumentManager.js.map +1 -1
  41. package/dist/documents/types.d.ts +1 -20
  42. package/dist/documents/types.js.map +1 -1
  43. package/dist/hover/HoverProvider.d.ts +1 -0
  44. package/dist/hover/HoverProvider.js +6 -0
  45. package/dist/hover/HoverProvider.js.map +1 -1
  46. package/dist/hover/providers/GraphQLFieldHoverProvider.d.ts +12 -0
  47. package/dist/hover/providers/GraphQLFieldHoverProvider.js +85 -0
  48. package/dist/hover/providers/GraphQLFieldHoverProvider.js.map +1 -0
  49. package/dist/json/completions/providers/BlockTypeCompletionProvider.d.ts +4 -3
  50. package/dist/json/completions/providers/BlockTypeCompletionProvider.js +5 -10
  51. package/dist/json/completions/providers/BlockTypeCompletionProvider.js.map +1 -1
  52. package/dist/json/hover/providers/BlockSettingsHoverProvider.js +5 -10
  53. package/dist/json/hover/providers/BlockSettingsHoverProvider.js.map +1 -1
  54. package/dist/json/schemaSettings.d.ts +3 -3
  55. package/dist/json/schemaSettings.js.map +1 -1
  56. package/dist/renamed/handlers/BlockRenameHandler.d.ts +3 -3
  57. package/dist/renamed/handlers/BlockRenameHandler.js +86 -15
  58. package/dist/renamed/handlers/BlockRenameHandler.js.map +1 -1
  59. package/dist/renamed/handlers/SectionRenameHandler.d.ts +3 -3
  60. package/dist/renamed/handlers/SectionRenameHandler.js +3 -3
  61. package/dist/renamed/handlers/SectionRenameHandler.js.map +1 -1
  62. package/dist/server/AppGraphManager.js +3 -4
  63. package/dist/server/AppGraphManager.js.map +1 -1
  64. package/dist/server/CachedFileSystem.js +7 -2
  65. package/dist/server/CachedFileSystem.js.map +1 -1
  66. package/dist/server/startServer.js +71 -5
  67. package/dist/server/startServer.js.map +1 -1
  68. package/dist/tsconfig.tsbuildinfo +1 -1
  69. package/dist/utils/searchPaths.d.ts +21 -0
  70. package/dist/utils/searchPaths.js +34 -0
  71. package/dist/utils/searchPaths.js.map +1 -0
  72. package/package.json +6 -4
  73. package/src/PropertyShapeInference.spec.ts +44 -0
  74. package/src/PropertyShapeInference.ts +27 -0
  75. package/src/TypeSystem.spec.ts +59 -0
  76. package/src/TypeSystem.ts +132 -63
  77. package/src/VariableShapeExtractor.ts +2 -4
  78. package/src/completions/CompletionsProvider.ts +11 -0
  79. package/src/completions/params/LiquidCompletionParams.ts +7 -2
  80. package/src/completions/providers/GraphQLFieldCompletionProvider.ts +78 -0
  81. package/src/definitions/DefinitionProvider.ts +52 -0
  82. package/src/definitions/providers/PageRouteDefinitionProvider.spec.ts +753 -0
  83. package/src/definitions/providers/PageRouteDefinitionProvider.ts +250 -0
  84. package/src/definitions/providers/RenderPartialDefinitionProvider.spec.ts +191 -0
  85. package/src/definitions/providers/RenderPartialDefinitionProvider.ts +70 -0
  86. package/src/diagnostics/runChecks.ts +4 -0
  87. package/src/documentLinks/DocumentLinksProvider.spec.ts +131 -10
  88. package/src/documentLinks/DocumentLinksProvider.ts +23 -32
  89. package/src/documents/DocumentManager.ts +0 -4
  90. package/src/documents/types.ts +1 -21
  91. package/src/hover/HoverProvider.ts +11 -0
  92. package/src/hover/providers/GraphQLFieldHoverProvider.spec.ts +124 -0
  93. package/src/hover/providers/GraphQLFieldHoverProvider.ts +90 -0
  94. package/src/server/AppGraphManager.ts +3 -5
  95. package/src/server/CachedFileSystem.ts +11 -2
  96. package/src/server/startServer.ts +85 -5
  97. package/src/utils/searchPaths.spec.ts +101 -0
  98. 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.11",
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.10",
31
- "@platformos/platformos-check-common": "0.0.11",
32
- "@platformos/platformos-graph": "0.0.11",
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) {
@@ -868,12 +837,16 @@ async function buildSymbolsTable(
868
837
  }
869
838
  }
870
839
 
840
+ // For hash/array targets like function hash['key'] = 'partial', skip type tracking
841
+ const fnVarName = markup.name.name;
842
+ if (!fnVarName || markup.name.lookups.length > 0) return;
843
+
871
844
  // Track shape for potential hash_assign merging
872
845
  if (returnType && isShapeType(returnType)) {
873
- if (!variableShapes.has(markup.name)) {
874
- variableShapes.set(markup.name, []);
846
+ if (!variableShapes.has(fnVarName)) {
847
+ variableShapes.set(fnVarName, []);
875
848
  }
876
- variableShapes.get(markup.name)!.push({
849
+ variableShapes.get(fnVarName)!.push({
877
850
  shape: returnType.shape,
878
851
  rangeEnd: node.position.end,
879
852
  });
@@ -881,7 +854,7 @@ async function buildSymbolsTable(
881
854
 
882
855
  // Always add function variable to symbolsTable, using Untyped as fallback
883
856
  return {
884
- identifier: markup.name,
857
+ identifier: fnVarName,
885
858
  type: returnType ?? Untyped,
886
859
  range: [node.position.end],
887
860
  };
@@ -1568,7 +1541,11 @@ async function inferFunctionReturnType(
1568
1541
 
1569
1542
  await visit<SourceCodeType.LiquidHtml, void>(partialAst, {
1570
1543
  async LiquidTag(node) {
1571
- if (node.name === NamedTags.return && typeof node.markup !== 'string') {
1544
+ if (
1545
+ node.name === NamedTags.return &&
1546
+ node.markup !== null &&
1547
+ typeof node.markup !== 'string'
1548
+ ) {
1572
1549
  // markup is LiquidVariable - infer its type
1573
1550
  const type = inferType(node.markup, partialSymbolsTable, objectMap, filtersMap);
1574
1551
  // Flatten union types into individual types
@@ -1738,6 +1715,98 @@ function mergeNestedPropertyIntoShape(
1738
1715
  return { kind: 'object', properties: newProperties };
1739
1716
  }
1740
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
+
1741
1810
  /**
1742
1811
  * Find the last applicable shape for a variable at a given position
1743
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 [];
@@ -228,7 +228,9 @@ function findCurrentNode(
228
228
  (isInLiquidLiquidTagContext(finder) && isCovered(cursor, current.blockStartPosition))
229
229
  ) {
230
230
  if (hasNonNullProperty(current, 'markup') && typeof current.markup !== 'string') {
231
- finder.current = Array.isArray(current.markup) ? current.markup.at(-1) : current.markup;
231
+ finder.current = Array.isArray(current.markup)
232
+ ? current.markup.at(-1)
233
+ : (current.markup ?? undefined);
232
234
  } else {
233
235
  // Exits the loop and the node is the thing to complete
234
236
  // (presumably name or something else)
@@ -244,7 +246,9 @@ function findCurrentNode(
244
246
 
245
247
  case NodeTypes.LiquidBranch:
246
248
  if (isCovered(cursor, current.blockStartPosition) && typeof current.markup !== 'string') {
247
- finder.current = Array.isArray(current.markup) ? current.markup.at(-1) : current.markup;
249
+ finder.current = Array.isArray(current.markup)
250
+ ? current.markup.at(-1)
251
+ : (current.markup ?? undefined);
248
252
  } else if (hasNonEmptyArrayProperty(current, 'children')) {
249
253
  finder.current = last(current.children);
250
254
  } else {
@@ -280,6 +284,7 @@ function findCurrentNode(
280
284
  case NodeTypes.YAMLFrontmatter:
281
285
  case NodeTypes.HtmlDoctype:
282
286
  case NodeTypes.HtmlComment:
287
+ case NodeTypes.HtmlProcessingInstruction:
283
288
  case NodeTypes.RawMarkup: {
284
289
  break;
285
290
  }
@@ -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
+ }