@hatchingpoint/point 0.0.10 → 0.0.12

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.
package/README.md CHANGED
@@ -13,7 +13,7 @@ point check examples/math.point
13
13
 
14
14
  Pair with the [Point Language](https://marketplace.visualstudio.com/items?itemName=hatchingpoint.point) extension in VS Code or Cursor.
15
15
 
16
- Point's public source language is semantic product logic. The compiler lowers that source into an internal typed core and emits TypeScript for existing JavaScript infrastructure.
16
+ Point's public source language is semantic product logic. The compiler lowers that source into an internal typed core and emits JavaScript by default for Bun and Node. Use `point build-ts` when you need TypeScript for existing typed JavaScript infrastructure.
17
17
 
18
18
  This package is the source of truth for Point. It exposes:
19
19
 
@@ -29,6 +29,6 @@ bun run check
29
29
  bun run build
30
30
  ```
31
31
 
32
- `bun run build` emits TypeScript into `generated/` for React, Vue, Bun, Node, and Vite projects.
32
+ `bun run build` emits JavaScript into `generated/` by default. Use `bun run build:ts` for TypeScript and `bun run build:ast` when debugging compiler output.
33
33
 
34
34
  When Point is extracted, this package can move into a standalone repo with the same package name and public entrypoints.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hatchingpoint/point",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Point language compiler and CLI.",
@@ -0,0 +1,155 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import type { PointCoreDiagnostic } from "./check.ts";
4
+ import { checkPointCore } from "./check.ts";
5
+ import { parsePointSource } from "./parser.ts";
6
+
7
+ const DEFAULT_DOCS_DIR = "docs/site";
8
+ const POINT_FENCE = /```point\r?\n([\s\S]*?)```/g;
9
+ const POINT_FILE_REF = /\b(?:[\w.-]+\/)*[\w.-]+\.point\b/g;
10
+
11
+ export interface DocsCheckItemResult {
12
+ kind: "snippet" | "file";
13
+ source: string;
14
+ label: string;
15
+ line?: number;
16
+ ok: boolean;
17
+ diagnostics: PointCoreDiagnostic[];
18
+ }
19
+
20
+ export interface DocsCheckResult {
21
+ ok: boolean;
22
+ checked: number;
23
+ items: DocsCheckItemResult[];
24
+ }
25
+
26
+ export async function discoverDocsSiteMarkdown(docsDir = DEFAULT_DOCS_DIR, cwd = process.cwd()): Promise<string[]> {
27
+ const root = resolve(cwd, docsDir);
28
+ const glob = new Bun.Glob("**/*.md");
29
+ const files: string[] = [];
30
+ for await (const file of glob.scan({ cwd: root, onlyFiles: true })) {
31
+ files.push(`${docsDir}/${file.replaceAll("\\", "/")}`);
32
+ }
33
+ return files.sort((a, b) => a.localeCompare(b));
34
+ }
35
+
36
+ export function extractPointSnippets(markdown: string, source: string): Array<{ label: string; code: string; line: number }> {
37
+ const snippets: Array<{ label: string; code: string; line: number }> = [];
38
+ let index = 0;
39
+ for (const match of markdown.matchAll(POINT_FENCE)) {
40
+ index += 1;
41
+ const code = match[1]?.replace(/\s+$/, "") ?? "";
42
+ const line = markdown.slice(0, match.index ?? 0).split(/\r?\n/).length;
43
+ snippets.push({ label: `${source} snippet ${index}`, code, line });
44
+ }
45
+ return snippets;
46
+ }
47
+
48
+ export function extractPointFileReferences(markdown: string, markdownPath: string, cwd = process.cwd()): string[] {
49
+ const references = new Set<string>();
50
+ for (const match of markdown.matchAll(POINT_FILE_REF)) {
51
+ const candidate = match[0]!;
52
+ if (candidate.endsWith(".point")) references.add(candidate.replaceAll("\\", "/"));
53
+ }
54
+ const resolved: string[] = [];
55
+ for (const reference of references) {
56
+ const absolute = resolvePointFileReference(reference, markdownPath, cwd);
57
+ if (absolute) resolved.push(absolute);
58
+ }
59
+ return resolved.sort((a, b) => a.localeCompare(b));
60
+ }
61
+
62
+ function resolvePointFileReference(reference: string, markdownPath: string, cwd: string): string | null {
63
+ const candidates = [
64
+ resolve(cwd, reference),
65
+ resolve(cwd, dirname(markdownPath), reference),
66
+ ];
67
+ for (const candidate of candidates) {
68
+ if (!existsSync(candidate)) continue;
69
+ return candidate.replace(resolve(cwd), "").replace(/^[/\\]/, "").replaceAll("\\", "/");
70
+ }
71
+ return null;
72
+ }
73
+
74
+ function checkPointSource(source: string, label: string, kind: "snippet" | "file", markdownSource: string, line?: number): DocsCheckItemResult {
75
+ try {
76
+ const program = parsePointSource(source);
77
+ const diagnostics = checkPointCore(program);
78
+ return { kind, source: markdownSource, label, line, ok: diagnostics.length === 0, diagnostics };
79
+ } catch (error) {
80
+ const message = error instanceof Error ? error.message : String(error);
81
+ return {
82
+ kind,
83
+ source: markdownSource,
84
+ label,
85
+ line,
86
+ ok: false,
87
+ diagnostics: [
88
+ {
89
+ code: "parse-error",
90
+ message,
91
+ path: label,
92
+ ref: `point://docs/${label}`,
93
+ severity: "error",
94
+ span: null,
95
+ repair: "Fix the Point syntax in this docs snippet or referenced file.",
96
+ },
97
+ ],
98
+ };
99
+ }
100
+ }
101
+
102
+ export async function checkDocs(options: { docsDir?: string; cwd?: string } = {}): Promise<DocsCheckResult> {
103
+ const docsDir = options.docsDir ?? DEFAULT_DOCS_DIR;
104
+ const cwd = options.cwd ?? process.cwd();
105
+ const markdownFiles = await discoverDocsSiteMarkdown(docsDir, cwd);
106
+ const items: DocsCheckItemResult[] = [];
107
+ const checkedFiles = new Set<string>();
108
+
109
+ for (const markdownPath of markdownFiles) {
110
+ const absoluteMarkdownPath = resolve(cwd, markdownPath);
111
+ const markdown = await Bun.file(absoluteMarkdownPath).text();
112
+
113
+ for (const snippet of extractPointSnippets(markdown, markdownPath)) {
114
+ items.push(checkPointSource(snippet.code, snippet.label, "snippet", markdownPath, snippet.line));
115
+ }
116
+
117
+ for (const filePath of extractPointFileReferences(markdown, markdownPath, cwd)) {
118
+ if (checkedFiles.has(filePath)) continue;
119
+ checkedFiles.add(filePath);
120
+ const source = await Bun.file(resolve(cwd, filePath)).text();
121
+ items.push(checkPointSource(source, filePath, "file", markdownPath));
122
+ }
123
+ }
124
+
125
+ return { ok: items.every((item) => item.ok), checked: items.length, items };
126
+ }
127
+
128
+ export async function runCheckDocs(options: { docsDir?: string; cwd?: string } = {}): Promise<DocsCheckResult> {
129
+ const result = await checkDocs(options);
130
+ if (result.ok) {
131
+ const snippets = result.items.filter((item) => item.kind === "snippet").length;
132
+ const files = result.items.filter((item) => item.kind === "file").length;
133
+ console.log(`Point docs check passed: ${snippets} snippet(s), ${files} file reference(s)`);
134
+ return result;
135
+ }
136
+
137
+ const failures = result.items.filter((item) => !item.ok);
138
+ console.error(
139
+ JSON.stringify(
140
+ {
141
+ ok: false,
142
+ failures: failures.map((item) => ({
143
+ kind: item.kind,
144
+ source: item.source,
145
+ label: item.label,
146
+ line: item.line,
147
+ diagnostics: item.diagnostics,
148
+ })),
149
+ },
150
+ null,
151
+ 2,
152
+ ),
153
+ );
154
+ process.exit(1);
155
+ }
package/src/core/cli.ts CHANGED
@@ -6,14 +6,18 @@ import { createPointCoreIndex, createPointCoreRepairPlan, explainPointCoreRef }
6
6
  import { createSemanticIndex, explainSemanticRef, mapPublicDiagnostics } from "../semantic/context.ts";
7
7
  import { emitPointCoreTypeScript } from "./emit-typescript.ts";
8
8
  import { emitPointCoreJavaScript } from "./emit-javascript.ts";
9
+ import { emitPointCorePython } from "./emit-python.ts";
9
10
  import { formatPointSource } from "./format.ts";
10
11
  import { isCacheHit, isIncrementalEnabled, readBuildCache, recordCacheEntry, writeBuildCache } from "./incremental.ts";
11
12
  import { parsePointSource } from "./parser.ts";
13
+ import { runCheckDocs } from "./check-docs.ts";
12
14
  import { runPointLspServer } from "../lsp/server.ts";
13
15
 
14
16
  const DEFAULT_INPUT = "examples/math.point";
15
17
  const DEFAULT_OUTPUT = "generated/math.ast.json";
18
+ const DEFAULT_JS_OUTPUT = "generated/math.js";
16
19
  const DEFAULT_TS_OUTPUT = "generated/math.ts";
20
+ const DEFAULT_PY_OUTPUT = "generated/math.py";
17
21
  const DEFAULT_PATTERNS = ["examples/**/*.point", "std/**/*.point", "compiler/**/*.point"];
18
22
  const GENERATED_DIR = "generated";
19
23
 
@@ -34,6 +38,11 @@ export async function main() {
34
38
  return;
35
39
  }
36
40
 
41
+ if (command === "check-docs") {
42
+ await runCheckDocs();
43
+ return;
44
+ }
45
+
37
46
  const inputPath = resolve(process.cwd(), input);
38
47
  const source = await Bun.file(inputPath).text();
39
48
  const program = parsePointSource(source);
@@ -101,15 +110,27 @@ export async function main() {
101
110
  return;
102
111
  }
103
112
 
104
- if (command === "build") {
113
+ if (command === "build" || command === "build-js") {
114
+ if (diagnostics.length > 0) {
115
+ console.error(JSON.stringify({ ok: false, diagnostics }, null, 2));
116
+ process.exit(1);
117
+ }
118
+ const outputPath = resolve(process.cwd(), output === DEFAULT_OUTPUT ? DEFAULT_JS_OUTPUT : output);
119
+ await Bun.$`mkdir -p ${dirname(outputPath)}`.quiet();
120
+ await Bun.write(outputPath, emitPointCoreJavaScript(program));
121
+ console.log(`Point core JavaScript build wrote ${outputPath.replaceAll("\\", "/")}`);
122
+ return;
123
+ }
124
+
125
+ if (command === "build-ast") {
105
126
  if (diagnostics.length > 0) {
106
127
  console.error(JSON.stringify({ ok: false, diagnostics }, null, 2));
107
128
  process.exit(1);
108
129
  }
109
- const outputPath = resolve(process.cwd(), output);
130
+ const outputPath = resolve(process.cwd(), output === DEFAULT_OUTPUT ? DEFAULT_OUTPUT : output);
110
131
  await Bun.$`mkdir -p ${dirname(outputPath)}`.quiet();
111
132
  await Bun.write(outputPath, `${JSON.stringify(program, null, 2)}\n`);
112
- console.log(`Point core build wrote ${output}`);
133
+ console.log(`Point core AST build wrote ${outputPath.replaceAll("\\", "/")}`);
113
134
  return;
114
135
  }
115
136
 
@@ -125,15 +146,15 @@ export async function main() {
125
146
  return;
126
147
  }
127
148
 
128
- if (command === "build-js") {
149
+ if (command === "build-py") {
129
150
  if (diagnostics.length > 0) {
130
151
  console.error(JSON.stringify({ ok: false, diagnostics }, null, 2));
131
152
  process.exit(1);
132
153
  }
133
- const outputPath = resolve(process.cwd(), output === DEFAULT_OUTPUT ? DEFAULT_TS_OUTPUT.replace(/\.ts$/, ".js") : output);
154
+ const outputPath = resolve(process.cwd(), output === DEFAULT_OUTPUT ? DEFAULT_PY_OUTPUT : output);
134
155
  await Bun.$`mkdir -p ${dirname(outputPath)}`.quiet();
135
- await Bun.write(outputPath, emitPointCoreJavaScript(program));
136
- console.log(`Point core JavaScript build wrote ${outputPath.replaceAll("\\", "/")}`);
156
+ await Bun.write(outputPath, emitPointCorePython(program));
157
+ console.log(`Point core Python build wrote ${outputPath.replaceAll("\\", "/")}`);
137
158
  return;
138
159
  }
139
160
 
@@ -142,8 +163,8 @@ export async function main() {
142
163
  console.error(JSON.stringify({ ok: false, diagnostics }, null, 2));
143
164
  process.exit(1);
144
165
  }
145
- const runOutput = resolve(tmpdir(), `point-run-${Date.now()}.ts`);
146
- await Bun.write(runOutput, emitPointCoreTypeScript(program));
166
+ const runOutput = resolve(tmpdir(), `point-run-${Date.now()}.js`);
167
+ await Bun.write(runOutput, emitPointCoreJavaScript(program));
147
168
  let entryName: string | null = null;
148
169
  try {
149
170
  const mod = await import(pathToFileUrl(runOutput));
@@ -223,7 +244,7 @@ async function runProjectCommand(command: string) {
223
244
  return;
224
245
  }
225
246
 
226
- if (command === "build-all") {
247
+ if (command === "build-all" || command === "build-js-all") {
227
248
  const diagnostics = orderedResults.flatMap((result) =>
228
249
  checkPointCore(programWithDependencyDeclarations(result, graph)).map((diagnostic) => ({ ...diagnostic, file: result.input })),
229
250
  );
@@ -232,16 +253,16 @@ async function runProjectCommand(command: string) {
232
253
  process.exit(1);
233
254
  }
234
255
  for (const result of orderedResults) {
235
- const output = outputFor(result.input);
256
+ const output = jsOutputFor(result.input);
236
257
  const outputPath = resolve(process.cwd(), output);
237
258
  await Bun.$`mkdir -p ${dirname(outputPath)}`.quiet();
238
- await Bun.write(outputPath, `${JSON.stringify(result.program, null, 2)}\n`);
259
+ await Bun.write(outputPath, emitPointCoreJavaScript(programWithTypeScriptImports(result, graph)));
239
260
  }
240
- console.log(`Point core build wrote ${results.length} files`);
261
+ console.log(`Point core JavaScript build wrote ${results.length} files`);
241
262
  return;
242
263
  }
243
264
 
244
- if (command === "build-ts-all") {
265
+ if (command === "build-ast-all") {
245
266
  const diagnostics = orderedResults.flatMap((result) =>
246
267
  checkPointCore(programWithDependencyDeclarations(result, graph)).map((diagnostic) => ({ ...diagnostic, file: result.input })),
247
268
  );
@@ -250,16 +271,16 @@ async function runProjectCommand(command: string) {
250
271
  process.exit(1);
251
272
  }
252
273
  for (const result of orderedResults) {
253
- const output = tsOutputFor(result.input);
274
+ const output = astOutputFor(result.input);
254
275
  const outputPath = resolve(process.cwd(), output);
255
276
  await Bun.$`mkdir -p ${dirname(outputPath)}`.quiet();
256
- await Bun.write(outputPath, emitPointCoreTypeScript(programWithTypeScriptImports(result, graph)));
277
+ await Bun.write(outputPath, `${JSON.stringify(result.program, null, 2)}\n`);
257
278
  }
258
- console.log(`Point core TypeScript build wrote ${results.length} files`);
279
+ console.log(`Point core AST build wrote ${results.length} files`);
259
280
  return;
260
281
  }
261
282
 
262
- if (command === "build-js-all") {
283
+ if (command === "build-ts-all") {
263
284
  const diagnostics = orderedResults.flatMap((result) =>
264
285
  checkPointCore(programWithDependencyDeclarations(result, graph)).map((diagnostic) => ({ ...diagnostic, file: result.input })),
265
286
  );
@@ -268,12 +289,12 @@ async function runProjectCommand(command: string) {
268
289
  process.exit(1);
269
290
  }
270
291
  for (const result of orderedResults) {
271
- const output = jsOutputFor(result.input);
292
+ const output = tsOutputFor(result.input);
272
293
  const outputPath = resolve(process.cwd(), output);
273
294
  await Bun.$`mkdir -p ${dirname(outputPath)}`.quiet();
274
- await Bun.write(outputPath, emitPointCoreJavaScript(programWithTypeScriptImports(result, graph)));
295
+ await Bun.write(outputPath, emitPointCoreTypeScript(programWithTypeScriptImports(result, graph)));
275
296
  }
276
- console.log(`Point core JavaScript build wrote ${results.length} files`);
297
+ console.log(`Point core TypeScript build wrote ${results.length} files`);
277
298
  return;
278
299
  }
279
300
 
@@ -358,8 +379,8 @@ async function runPointTests(program: PointCoreProgram, input: string): Promise<
358
379
  (declaration.semantic?.name.startsWith("test") || declaration.name.startsWith("test")),
359
380
  );
360
381
  if (tests.length === 0) return { file: input, ok: true, tests: [] };
361
- const testOutput = resolve(tmpdir(), `point-test-${Date.now()}-${Math.random().toString(16).slice(2)}.ts`);
362
- await Bun.write(testOutput, emitPointCoreTypeScript(program));
382
+ const testOutput = resolve(tmpdir(), `point-test-${Date.now()}-${Math.random().toString(16).slice(2)}.js`);
383
+ await Bun.write(testOutput, emitPointCoreJavaScript(program));
363
384
  const mod = await import(pathToFileUrl(testOutput));
364
385
  const results = [];
365
386
  for (const test of tests) {
@@ -498,6 +519,15 @@ function jsOutputFor(input: string): string {
498
519
  return `${GENERATED_DIR}/${name}.js`;
499
520
  }
500
521
 
522
+ function astOutputFor(input: string): string {
523
+ return outputFor(input);
524
+ }
525
+
526
+ function pyOutputFor(input: string): string {
527
+ const name = outputBaseName(input);
528
+ return `${GENERATED_DIR}/${name}.py`;
529
+ }
530
+
501
531
  function outputBaseName(input: string): string {
502
532
  return normalizeInput(input).split("/").pop()?.replace(/\.point$/, "") ?? "program";
503
533
  }
@@ -8,6 +8,8 @@ import type {
8
8
  PointCoreTypeDeclaration,
9
9
  PointCoreValueDeclaration,
10
10
  } from "./ast.ts";
11
+ import type { PointSemanticRouteDeclaration } from "../semantic/ast.ts";
12
+ import { semanticFunctionName } from "../semantic/naming.ts";
11
13
 
12
14
  const BINARY_OPERATORS: Record<string, string> = {
13
15
  and: "&&",
@@ -16,13 +18,22 @@ const BINARY_OPERATORS: Record<string, string> = {
16
18
 
17
19
  /** Emit JavaScript from a core AST program (no type syntax). Production path: parsePointSource → check → emit. */
18
20
  export function emitPointCoreJavaScript(program: PointCoreProgram): string {
21
+ const routes = program.semanticSource?.declarations.filter((declaration): declaration is PointSemanticRouteDeclaration => declaration.kind === "route") ?? [];
22
+ const routeServeCommand = program.declarations.find((declaration) => declaration.kind === "function" && isRouteServeCommand(declaration));
19
23
  const lines: string[] = [];
20
24
  lines.push("// Generated by Point. Do not edit directly.");
21
25
  if (program.module) lines.push(`// Point module: ${program.module}`);
22
26
  lines.push("");
23
27
  for (const declaration of program.declarations) {
28
+ if (declaration.kind === "function" && declaration.semantic?.kind === "command" && routes.length > 0 && isRouteServeCommand(declaration)) {
29
+ lines.push(...emitRouteServeCommand(declaration), "");
30
+ continue;
31
+ }
24
32
  lines.push(...emitDeclaration(declaration), "");
25
33
  }
34
+ if (routes.length > 0 && routeServeCommand) {
35
+ lines.push(...emitRouteServerRuntime(routes), "");
36
+ }
26
37
  return `${trimTrailingBlankLines(lines).join("\n")}\n`;
27
38
  }
28
39
 
@@ -122,3 +133,54 @@ function trimTrailingBlankLines(lines: string[]): string[] {
122
133
  while (lines.at(-1) === "") lines.pop();
123
134
  return lines;
124
135
  }
136
+
137
+ function isRouteServeCommand(declaration: PointCoreFunctionDeclaration): boolean {
138
+ return declaration.semantic?.name === "serve store readiness" || declaration.name === "serveStoreReadinessCommand";
139
+ }
140
+
141
+ function emitRouteServeCommand(declaration: PointCoreFunctionDeclaration): string[] {
142
+ const asyncPrefix = declaration.semantic?.kind === "command" ? "async " : "";
143
+ return [
144
+ `export ${asyncPrefix}function ${declaration.name}(${declaration.params.map(emitParam).join(", ")}) {`,
145
+ " const port = Number(process.env.PORT ?? 3456);",
146
+ " const server = Bun.serve({ port, fetch: createPointRouteFetchHandler() });",
147
+ " console.log(`Store readiness listening on http://localhost:${server.port}`);",
148
+ " await new Promise(() => {});",
149
+ "}",
150
+ ];
151
+ }
152
+
153
+ function emitRouteServerRuntime(routes: PointSemanticRouteDeclaration[]): string[] {
154
+ const matchers = routes.map((route) => {
155
+ const handlerName = semanticFunctionName(route.name, "route", "route");
156
+ const pattern = route.path.replace(/:[A-Za-z][A-Za-z0-9_]*/g, "([^/]+)").replace(/\//g, "\\/");
157
+ const argExpressions = route.inputs.map((_input, index) => `match[${index + 1}]`);
158
+ return {
159
+ method: route.method.toUpperCase(),
160
+ pattern,
161
+ handlerName,
162
+ argExpressions,
163
+ };
164
+ });
165
+ const matchLines = matchers.flatMap((matcher) => [
166
+ ` if (req.method === ${JSON.stringify(matcher.method)} && new RegExp(${JSON.stringify(`^${matcher.pattern}$`)}).test(url.pathname)) {`,
167
+ ` const match = url.pathname.match(new RegExp(${JSON.stringify(`^${matcher.pattern}$`)}));`,
168
+ ` const body = ${matcher.handlerName}(${matcher.argExpressions.join(", ")});`,
169
+ ' return new Response(typeof body === "string" ? body : body, { headers: { "content-type": "application/json" } });',
170
+ " }",
171
+ ]);
172
+ return [
173
+ "export function createPointRouteFetchHandler() {",
174
+ " return async (req) => {",
175
+ " const url = new URL(req.url);",
176
+ ...matchLines,
177
+ ' return new Response(JSON.stringify({ error: "Not found" }), { status: 404, headers: { "content-type": "application/json" } });',
178
+ " };",
179
+ "}",
180
+ "",
181
+ "export function startRoutesServer() {",
182
+ " const port = Number(process.env.PORT ?? 3456);",
183
+ " return Bun.serve({ port, fetch: createPointRouteFetchHandler() });",
184
+ "}",
185
+ ];
186
+ }
@@ -0,0 +1,173 @@
1
+ import type {
2
+ PointCoreDeclaration,
3
+ PointCoreExpression,
4
+ PointCoreFunctionDeclaration,
5
+ PointCoreParameter,
6
+ PointCorePrimitiveType,
7
+ PointCoreProgram,
8
+ PointCoreStatement,
9
+ PointCoreTypeDeclaration,
10
+ PointCoreTypeExpression,
11
+ PointCoreValueDeclaration,
12
+ } from "./ast.ts";
13
+
14
+ const BINARY_OPERATORS: Record<string, string> = {
15
+ and: "and",
16
+ or: "or",
17
+ };
18
+
19
+ const UNSUPPORTED_SEMANTIC_KINDS = new Set(["view", "route", "action", "workflow", "command"]);
20
+
21
+ /** Emit Python from a core AST program (pure logic modules). Production path: parsePointSource → check → emit. */
22
+ export function emitPointCorePython(program: PointCoreProgram): string {
23
+ const lines: string[] = [];
24
+ lines.push("# Generated by Point. Do not edit directly.");
25
+ if (program.module) lines.push(`# Point module: ${program.module}`);
26
+ lines.push("");
27
+ lines.push("from __future__ import annotations");
28
+ lines.push("");
29
+ if (program.declarations.some((declaration) => declaration.kind === "type")) {
30
+ lines.push("from typing import TypedDict");
31
+ lines.push("");
32
+ }
33
+ for (const declaration of program.declarations) {
34
+ const emitted = emitDeclaration(declaration);
35
+ if (emitted.length > 0) lines.push(...emitted, "");
36
+ }
37
+ return `${trimTrailingBlankLines(lines).join("\n")}\n`;
38
+ }
39
+
40
+ function emitDeclaration(declaration: PointCoreDeclaration): string[] {
41
+ if (declaration.kind === "import") {
42
+ const moduleName = declaration.from.replace(/^\.\//, "").replace(/-/g, "_");
43
+ return [`from ${toPythonModuleName(moduleName)} import ${declaration.names.join(", ")}`];
44
+ }
45
+ if (declaration.kind === "external") {
46
+ const moduleName = declaration.from.replace(/^\.\//, "").replace(/-/g, "_");
47
+ const imported = declaration.importName ?? declaration.name;
48
+ return [`from ${toPythonModuleName(moduleName)} import ${imported} as ${declaration.name}`];
49
+ }
50
+ if (declaration.kind === "type") return emitType(declaration);
51
+ if (declaration.kind === "value") return [emitValue(declaration)];
52
+ if (declaration.semantic && UNSUPPORTED_SEMANTIC_KINDS.has(declaration.semantic.kind)) {
53
+ return [`# Point: ${declaration.semantic.kind} blocks are not supported in Python emit yet`];
54
+ }
55
+ return emitFunction(declaration);
56
+ }
57
+
58
+ function emitType(declaration: PointCoreTypeDeclaration): string[] {
59
+ return [
60
+ `class ${declaration.name}(TypedDict):`,
61
+ ...declaration.fields.map((field) => ` ${field.name}: ${emitTypeExpression(field.type)}`),
62
+ ];
63
+ }
64
+
65
+ function emitFunction(declaration: PointCoreFunctionDeclaration): string[] {
66
+ const asyncPrefix = declaration.semantic?.kind === "action" || declaration.semantic?.kind === "workflow" || declaration.semantic?.kind === "command" ? "async " : "";
67
+ return [
68
+ `${asyncPrefix}def ${declaration.name}(${declaration.params.map(emitParam).join(", ")}) -> ${emitReturnType(declaration)}:`,
69
+ ...indentLines(declaration.body.flatMap((statement) => emitStatement(statement, declaration.semantic?.kind))),
70
+ ];
71
+ }
72
+
73
+ function emitReturnType(declaration: PointCoreFunctionDeclaration): string {
74
+ if (declaration.semantic?.kind === "action" || declaration.semantic?.kind === "workflow" || declaration.semantic?.kind === "command") {
75
+ return emitTypeExpression(declaration.returnType);
76
+ }
77
+ return emitTypeExpression(declaration.returnType);
78
+ }
79
+
80
+ function emitStatement(statement: PointCoreStatement, semanticKind?: string): string[] {
81
+ if (statement.kind === "return") {
82
+ if (semanticKind === "view") return ["# Point: view return values are not supported in Python emit yet", "return \"\""];
83
+ return [statement.value ? `return ${emitExpression(statement.value)}` : "return"];
84
+ }
85
+ if (statement.kind === "value") return [emitValue(statement)];
86
+ if (statement.kind === "assignment") return [`${statement.name} ${statement.operator} ${emitExpression(statement.value)}`];
87
+ if (statement.kind === "if") {
88
+ const lines = [`if ${emitCondition(statement.condition)}:`, ...indentLines(statement.thenBody.flatMap((child) => emitStatement(child, semanticKind)))];
89
+ if (statement.elseBody.length > 0) {
90
+ lines.push("else:", ...indentLines(statement.elseBody.flatMap((child) => emitStatement(child, semanticKind))));
91
+ }
92
+ return lines;
93
+ }
94
+ if (statement.kind === "for") {
95
+ return [`for ${statement.itemName} in ${emitExpression(statement.iterable)}:`, ...indentLines(statement.body.flatMap((child) => emitStatement(child, semanticKind)))];
96
+ }
97
+ return [emitExpression(statement.value)];
98
+ }
99
+
100
+ function emitValue(declaration: PointCoreValueDeclaration): string {
101
+ return `${declaration.name}: ${emitTypeExpression(declaration.type)} = ${emitExpression(declaration.value)}`;
102
+ }
103
+
104
+ function emitParam(param: PointCoreParameter): string {
105
+ return `${param.name}: ${emitTypeExpression(param.type)}`;
106
+ }
107
+
108
+ function emitTypeExpression(type: PointCoreTypeExpression): string {
109
+ if (type.name === "List") return `list[${type.args[0] ? emitTypeExpression(type.args[0]) : "object"}]`;
110
+ if (type.name === "Maybe") return `${type.args[0] ? emitTypeExpression(type.args[0]) : "object"} | None`;
111
+ if (type.name === "Or") return type.args.map(emitTypeExpression).join(" | ");
112
+ if (type.name === "Error") return "dict[str, str]";
113
+ if (isPrimitiveType(type.name)) return emitPrimitiveType(type.name);
114
+ return type.name;
115
+ }
116
+
117
+ function emitPrimitiveType(type: PointCorePrimitiveType): string {
118
+ if (type === "Text") return "str";
119
+ if (type === "Int") return "int";
120
+ if (type === "Float") return "float";
121
+ if (type === "Bool") return "bool";
122
+ return "None";
123
+ }
124
+
125
+ function emitExpression(expression: PointCoreExpression): string {
126
+ if (expression.kind === "literal") return emitLiteral(expression.value);
127
+ if (expression.kind === "identifier") return expression.name;
128
+ if (expression.kind === "list") return `[${expression.items.map(emitExpression).join(", ")}]`;
129
+ if (expression.kind === "record") {
130
+ return `{${expression.fields.map((field) => `"${field.name}": ${emitExpression(field.value)}`).join(", ")}}`;
131
+ }
132
+ if (expression.kind === "await") return `await ${emitExpression(expression.value)}`;
133
+ if (expression.kind === "property") return `${emitExpression(expression.target)}[${JSON.stringify(expression.name)}]`;
134
+ if (expression.kind === "call") {
135
+ if (expression.callee === "Error") {
136
+ const message = expression.args[0] ? emitExpression(expression.args[0]) : '""';
137
+ return `{"message": ${message}}`;
138
+ }
139
+ return `${expression.callee}(${expression.args.map(emitExpression).join(", ")})`;
140
+ }
141
+ const operator = BINARY_OPERATORS[expression.operator] ?? expression.operator;
142
+ return `(${emitExpression(expression.left)} ${operator} ${emitExpression(expression.right)})`;
143
+ }
144
+
145
+ function emitLiteral(value: unknown): string {
146
+ if (value === null) return "None";
147
+ if (value === true) return "True";
148
+ if (value === false) return "False";
149
+ if (typeof value === "string") return JSON.stringify(value);
150
+ return String(value);
151
+ }
152
+
153
+ function emitCondition(expression: PointCoreExpression): string {
154
+ const emitted = emitExpression(expression);
155
+ return emitted.startsWith("(") && emitted.endsWith(")") ? emitted.slice(1, -1) : emitted;
156
+ }
157
+
158
+ function toPythonModuleName(from: string): string {
159
+ return from.replace(/\.ts$/, "").replace(/\.js$/, "").replace(/\.py$/, "");
160
+ }
161
+
162
+ function isPrimitiveType(type: string): type is PointCorePrimitiveType {
163
+ return type === "Text" || type === "Int" || type === "Float" || type === "Bool" || type === "Void";
164
+ }
165
+
166
+ function indentLines(lines: string[]): string[] {
167
+ return lines.map((line) => ` ${line}`);
168
+ }
169
+
170
+ function trimTrailingBlankLines(lines: string[]): string[] {
171
+ while (lines.at(-1) === "") lines.pop();
172
+ return lines;
173
+ }
@@ -68,8 +68,11 @@ function emitFunction(declaration: PointCoreFunctionDeclaration): string[] {
68
68
 
69
69
  function emitStatement(statement: PointCoreStatement, semanticKind?: string): string[] {
70
70
  if (statement.kind === "return") {
71
- if (semanticKind === "view" && statement.value?.kind === "literal" && typeof statement.value.value === "string") {
72
- return [`return <>${escapeJsxText(statement.value.value)}</>;`];
71
+ if (semanticKind === "view" && statement.value) {
72
+ if (statement.value.kind === "literal" && typeof statement.value.value === "string") {
73
+ return [`return <>${escapeJsxText(statement.value.value)}</>;`];
74
+ }
75
+ return [`return <>{${emitExpression(statement.value)}}</>;`];
73
76
  }
74
77
  return [statement.value ? `return ${emitExpression(statement.value)};` : "return;"];
75
78
  }
package/src/core/index.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  export * from "./ast.ts";
2
2
  export * from "./check.ts";
3
3
  export { findRunEntryName } from "./cli.ts";
4
+ export * from "./check-docs.ts";
4
5
  export * from "./context.ts";
5
6
  export * from "./emit-typescript.ts";
6
7
  export * from "./emit-javascript.ts";
8
+ export * from "./emit-python.ts";
7
9
  export * from "./incremental.ts";
8
10
  export * from "./serialize.ts";
9
11
  export * from "../semantic/index.ts";
@@ -31,6 +31,61 @@ export interface LspHover {
31
31
  contents: string;
32
32
  }
33
33
 
34
+ export interface LspCompletionItem {
35
+ label: string;
36
+ kind: number;
37
+ detail?: string;
38
+ }
39
+
40
+ const BLOCK_KEYWORDS = [
41
+ "module",
42
+ "use",
43
+ "record",
44
+ "calculation",
45
+ "rule",
46
+ "label",
47
+ "external",
48
+ "action",
49
+ "policy",
50
+ "view",
51
+ "route",
52
+ "workflow",
53
+ "command",
54
+ ];
55
+
56
+ const STATEMENT_KEYWORDS = [
57
+ "input",
58
+ "output",
59
+ "return",
60
+ "when",
61
+ "otherwise",
62
+ "add",
63
+ "subtract",
64
+ "set",
65
+ "for",
66
+ "each",
67
+ "in",
68
+ "starts",
69
+ "at",
70
+ "as",
71
+ "to",
72
+ "from",
73
+ "is",
74
+ "render",
75
+ "method",
76
+ "path",
77
+ "step",
78
+ "await",
79
+ "touches",
80
+ "and",
81
+ "or",
82
+ "none",
83
+ "true",
84
+ "false",
85
+ ];
86
+
87
+ const TYPE_KEYWORDS = ["Text", "Int", "Float", "Bool", "Void", "List", "Maybe"];
88
+
34
89
  export interface PointDocumentAnalysis {
35
90
  diagnostics: LspDiagnostic[];
36
91
  symbols: PointSemanticSymbol[];
@@ -128,6 +183,79 @@ export function formatPointDocument(source: string): string {
128
183
  }
129
184
  }
130
185
 
186
+ export function completionsForPosition(source: string, line: number, column: number): LspCompletionItem[] {
187
+ const analysis = analyzePointSource(source);
188
+ const lines = source.split(/\r?\n/);
189
+ const lineText = lines[line - 1] ?? "";
190
+ const before = lineText.slice(0, Math.max(0, column - 1));
191
+ const items: LspCompletionItem[] = [];
192
+ const seen = new Set<string>();
193
+ const add = (label: string, kind: number, detail?: string) => {
194
+ if (seen.has(label)) return;
195
+ seen.add(label);
196
+ items.push({ label, kind, detail });
197
+ };
198
+
199
+ if (/^\s*$/.test(before)) {
200
+ for (const keyword of BLOCK_KEYWORDS) add(keyword, 14);
201
+ }
202
+
203
+ for (const keyword of [...STATEMENT_KEYWORDS, ...TYPE_KEYWORDS]) add(keyword, 14);
204
+ for (const symbol of analysis.symbols) {
205
+ const kind =
206
+ symbol.kind === "field" ? 5 : symbol.kind === "record" ? 7 : symbol.kind === "param" ? 6 : 3;
207
+ add(symbol.name, kind, symbol.kind);
208
+ }
209
+
210
+ return items;
211
+ }
212
+
213
+ export function renameSymbolInDocument(
214
+ source: string,
215
+ line: number,
216
+ column: number,
217
+ newName: string,
218
+ ): { range: LspRange; newText: string } | null {
219
+ const analysis = analyzePointSource(source);
220
+ const symbol = symbolAtPoint(analysis.symbols, line, column);
221
+ if (!symbol?.span || !newName.trim()) return null;
222
+ const oldName = symbol.name;
223
+ if (oldName === newName) {
224
+ return { range: pointSpanToLspRange(symbol.span), newText: oldName };
225
+ }
226
+ const escaped = oldName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
227
+ const updated = source.replace(new RegExp(escaped, "g"), newName);
228
+ if (updated === source) return null;
229
+ return {
230
+ range: fullDocumentRange(source),
231
+ newText: updated,
232
+ };
233
+ }
234
+
235
+ export function prepareRenameAtPosition(
236
+ source: string,
237
+ line: number,
238
+ column: number,
239
+ ): { range: LspRange; placeholder: string } | null {
240
+ const analysis = analyzePointSource(source);
241
+ const symbol = symbolAtPoint(analysis.symbols, line, column);
242
+ if (!symbol?.span) return null;
243
+ return {
244
+ range: pointSpanToLspRange(symbol.span),
245
+ placeholder: symbol.name,
246
+ };
247
+ }
248
+
249
+ function fullDocumentRange(text: string): LspRange {
250
+ const lines = text.split(/\r?\n/);
251
+ const lastLine = Math.max(0, lines.length - 1);
252
+ const lastCharacter = lines[lastLine]?.length ?? 0;
253
+ return {
254
+ start: { line: 0, character: 0 },
255
+ end: { line: lastLine, character: lastCharacter },
256
+ };
257
+ }
258
+
131
259
  function toLspDiagnostic(diagnostic: PointCoreDiagnostic): LspDiagnostic {
132
260
  const range = diagnostic.span
133
261
  ? pointSpanToLspRange(diagnostic.span)
package/src/lsp/server.ts CHANGED
@@ -1,18 +1,32 @@
1
1
  import {
2
2
  analyzePointSource,
3
+ completionsForPosition,
3
4
  definitionForPosition,
4
5
  formatPointDocument,
5
6
  hoverForPosition,
6
7
  lspPositionToPoint,
7
8
  outlineSymbols,
9
+ prepareRenameAtPosition,
10
+ renameSymbolInDocument,
8
11
  type LspRange,
9
12
  } from "./analyze.ts";
13
+ import { readFileSync } from "node:fs";
14
+ import { join } from "node:path";
10
15
  import { LspReader, writeMessage, type JsonRpcMessage } from "./protocol.ts";
11
16
 
12
17
  type DocumentState = { version: number; text: string };
13
18
 
14
19
  const documents = new Map<string, DocumentState>();
15
20
 
21
+ function lspVersion(): string {
22
+ try {
23
+ const pkgPath = join(import.meta.dir, "../../package.json");
24
+ return (JSON.parse(readFileSync(pkgPath, "utf8")) as { version: string }).version;
25
+ } catch {
26
+ return "0.0.0";
27
+ }
28
+ }
29
+
16
30
  export async function runPointLspServer(): Promise<void> {
17
31
  const reader = new LspReader();
18
32
  for await (const message of reader.messages()) {
@@ -29,8 +43,10 @@ async function handleMessage(message: JsonRpcMessage): Promise<void> {
29
43
  definitionProvider: true,
30
44
  hoverProvider: true,
31
45
  documentFormattingProvider: true,
46
+ completionProvider: { triggerCharacters: [".", " "] },
47
+ renameProvider: { prepareProvider: true },
32
48
  },
33
- serverInfo: { name: "point-lsp", version: "0.0.9" },
49
+ serverInfo: { name: "point-lsp", version: lspVersion() },
34
50
  });
35
51
  return;
36
52
  }
@@ -130,6 +146,65 @@ async function handleMessage(message: JsonRpcMessage): Promise<void> {
130
146
  return;
131
147
  }
132
148
 
149
+ if (message.method === "textDocument/completion") {
150
+ const params = message.params as {
151
+ textDocument: { uri: string };
152
+ position: { line: number; character: number };
153
+ };
154
+ const document = documents.get(params.textDocument.uri);
155
+ if (!document) {
156
+ respond(message.id, { isIncomplete: false, items: [] });
157
+ return;
158
+ }
159
+ const point = lspPositionToPoint(params.position.line, params.position.character);
160
+ const items = completionsForPosition(document.text, point.line, point.column);
161
+ respond(message.id, { isIncomplete: false, items });
162
+ return;
163
+ }
164
+
165
+ if (message.method === "textDocument/prepareRename") {
166
+ const params = message.params as {
167
+ textDocument: { uri: string };
168
+ position: { line: number; character: number };
169
+ };
170
+ const document = documents.get(params.textDocument.uri);
171
+ if (!document) {
172
+ respond(message.id, null);
173
+ return;
174
+ }
175
+ const point = lspPositionToPoint(params.position.line, params.position.character);
176
+ const prepared = prepareRenameAtPosition(document.text, point.line, point.column);
177
+ respond(message.id, prepared);
178
+ return;
179
+ }
180
+
181
+ if (message.method === "textDocument/rename") {
182
+ const params = message.params as {
183
+ textDocument: { uri: string };
184
+ position: { line: number; character: number };
185
+ newName: string;
186
+ };
187
+ const document = documents.get(params.textDocument.uri);
188
+ if (!document) {
189
+ respond(message.id, null);
190
+ return;
191
+ }
192
+ const point = lspPositionToPoint(params.position.line, params.position.character);
193
+ const edit = renameSymbolInDocument(document.text, point.line, point.column, params.newName);
194
+ if (!edit) {
195
+ respond(message.id, null);
196
+ return;
197
+ }
198
+ documents.set(params.textDocument.uri, { version: document.version, text: edit.newText });
199
+ publishDiagnostics(params.textDocument.uri);
200
+ respond(message.id, {
201
+ changes: {
202
+ [params.textDocument.uri]: [edit],
203
+ },
204
+ });
205
+ return;
206
+ }
207
+
133
208
  if (message.id !== undefined) {
134
209
  respond(message.id, null);
135
210
  }
@@ -138,9 +138,8 @@ function parsePrimaryExpression(source: string, context: PointSemanticExpression
138
138
  return parseRecordExpression(trimmed, context);
139
139
  }
140
140
  if (trimmed.startsWith('"')) {
141
- const end = trimmed.indexOf('"', 1);
142
- const value = JSON.parse(trimmed.slice(0, end + 1));
143
- return { expression: { kind: "literal", value }, consumed: trimmed.slice(end + 1) };
141
+ const literal = parseJsonStringLiteral(trimmed);
142
+ return { expression: { kind: "literal", value: literal.value }, consumed: literal.consumed };
144
143
  }
145
144
  if (/^(true|false|null|none)\b/.test(trimmed)) {
146
145
  const match = trimmed.match(/^(true|false|null|none)\b/);
@@ -183,6 +182,24 @@ function parsePrimaryExpression(source: string, context: PointSemanticExpression
183
182
  throw new Error(`Unable to parse semantic expression: ${source}`);
184
183
  }
185
184
 
185
+ function parseJsonStringLiteral(source: string): { value: string; consumed: string } {
186
+ if (!source.startsWith('"')) throw new Error(`Expected string literal: ${source}`);
187
+ let index = 1;
188
+ while (index < source.length) {
189
+ const char = source[index];
190
+ if (char === "\\") {
191
+ index += 2;
192
+ continue;
193
+ }
194
+ if (char === '"') {
195
+ const value = JSON.parse(source.slice(0, index + 1)) as string;
196
+ return { value, consumed: source.slice(index + 1) };
197
+ }
198
+ index += 1;
199
+ }
200
+ throw new Error(`Unterminated string literal: ${source}`);
201
+ }
202
+
186
203
  function parseListExpression(source: string, context: PointSemanticExpressionContext): { expression: PointSemanticExpression; consumed: string } {
187
204
  let rest = source.trim().slice(1).trimStart();
188
205
  const items: PointSemanticExpression[] = [];