semantifold 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +71 -0
  3. package/build/index.d.ts +54 -0
  4. package/build/index.d.ts.map +1 -0
  5. package/build/index.js +45 -0
  6. package/build/src/backends/identifiers.d.ts +19 -0
  7. package/build/src/backends/identifiers.d.ts.map +1 -0
  8. package/build/src/backends/identifiers.js +93 -0
  9. package/build/src/backends/index.d.ts +35 -0
  10. package/build/src/backends/index.d.ts.map +1 -0
  11. package/build/src/backends/index.js +149 -0
  12. package/build/src/backends/java.d.ts +8 -0
  13. package/build/src/backends/java.d.ts.map +1 -0
  14. package/build/src/backends/java.js +172 -0
  15. package/build/src/backends/javascript.d.ts +8 -0
  16. package/build/src/backends/javascript.d.ts.map +1 -0
  17. package/build/src/backends/javascript.js +173 -0
  18. package/build/src/backends/php.d.ts +8 -0
  19. package/build/src/backends/php.d.ts.map +1 -0
  20. package/build/src/backends/php.js +175 -0
  21. package/build/src/backends/ruby.d.ts +8 -0
  22. package/build/src/backends/ruby.d.ts.map +1 -0
  23. package/build/src/backends/ruby.js +155 -0
  24. package/build/src/backends/scalars.d.ts +15 -0
  25. package/build/src/backends/scalars.d.ts.map +1 -0
  26. package/build/src/backends/scalars.js +138 -0
  27. package/build/src/backends/shared.d.ts +26 -0
  28. package/build/src/backends/shared.d.ts.map +1 -0
  29. package/build/src/backends/shared.js +424 -0
  30. package/build/src/backends/typescript.d.ts +8 -0
  31. package/build/src/backends/typescript.d.ts.map +1 -0
  32. package/build/src/backends/typescript.js +160 -0
  33. package/build/src/backends/writer.d.ts +87 -0
  34. package/build/src/backends/writer.d.ts.map +1 -0
  35. package/build/src/backends/writer.js +207 -0
  36. package/build/src/diagnostic.d.ts +67 -0
  37. package/build/src/diagnostic.d.ts.map +1 -0
  38. package/build/src/diagnostic.js +96 -0
  39. package/build/src/frontends/index.d.ts +14 -0
  40. package/build/src/frontends/index.d.ts.map +1 -0
  41. package/build/src/frontends/index.js +36 -0
  42. package/build/src/frontends/java.d.ts +12 -0
  43. package/build/src/frontends/java.d.ts.map +1 -0
  44. package/build/src/frontends/java.js +630 -0
  45. package/build/src/frontends/javascript-typescript.d.ts +19 -0
  46. package/build/src/frontends/javascript-typescript.d.ts.map +1 -0
  47. package/build/src/frontends/javascript-typescript.js +633 -0
  48. package/build/src/frontends/php.d.ts +12 -0
  49. package/build/src/frontends/php.d.ts.map +1 -0
  50. package/build/src/frontends/php.js +523 -0
  51. package/build/src/frontends/ruby.d.ts +12 -0
  52. package/build/src/frontends/ruby.d.ts.map +1 -0
  53. package/build/src/frontends/ruby.js +515 -0
  54. package/build/src/frontends/scalars.d.ts +19 -0
  55. package/build/src/frontends/scalars.d.ts.map +1 -0
  56. package/build/src/frontends/scalars.js +71 -0
  57. package/build/src/mapping.d.ts +160 -0
  58. package/build/src/mapping.d.ts.map +1 -0
  59. package/build/src/mapping.js +1120 -0
  60. package/build/src/semantic/location.d.ts +61 -0
  61. package/build/src/semantic/location.d.ts.map +1 -0
  62. package/build/src/semantic/location.js +142 -0
  63. package/build/src/semantic/operators.d.ts +16 -0
  64. package/build/src/semantic/operators.d.ts.map +1 -0
  65. package/build/src/semantic/operators.js +24 -0
  66. package/build/src/semantic/provenance.d.ts +74 -0
  67. package/build/src/semantic/provenance.d.ts.map +1 -0
  68. package/build/src/semantic/provenance.js +577 -0
  69. package/build/src/semantic/scalars.d.ts +19 -0
  70. package/build/src/semantic/scalars.d.ts.map +1 -0
  71. package/build/src/semantic/scalars.js +40 -0
  72. package/build/src/semantic/types.d.ts +1003 -0
  73. package/build/src/semantic/types.d.ts.map +1 -0
  74. package/build/src/semantic/types.js +279 -0
  75. package/build/src/semantic/validate.d.ts +60 -0
  76. package/build/src/semantic/validate.d.ts.map +1 -0
  77. package/build/src/semantic/validate.js +472 -0
  78. package/docs/architecture.md +39 -0
  79. package/docs/coding-standards.md +7 -0
  80. package/docs/goals.md +7 -0
  81. package/docs/language-support.md +69 -0
  82. package/docs/plans/2026-09-02-initial-toolchain.md +54 -0
  83. package/docs/source-maps.md +134 -0
  84. package/docs/testing.md +25 -0
  85. package/index.js +63 -0
  86. package/package.json +69 -0
  87. package/src/backends/identifiers.js +101 -0
  88. package/src/backends/index.js +159 -0
  89. package/src/backends/java.js +182 -0
  90. package/src/backends/javascript.js +184 -0
  91. package/src/backends/php.js +184 -0
  92. package/src/backends/ruby.js +163 -0
  93. package/src/backends/scalars.js +120 -0
  94. package/src/backends/shared.js +456 -0
  95. package/src/backends/typescript.js +168 -0
  96. package/src/backends/writer.js +224 -0
  97. package/src/diagnostic.js +103 -0
  98. package/src/frontends/index.js +36 -0
  99. package/src/frontends/java.js +735 -0
  100. package/src/frontends/javascript-typescript.js +718 -0
  101. package/src/frontends/php.js +593 -0
  102. package/src/frontends/ruby.js +622 -0
  103. package/src/frontends/scalars.js +77 -0
  104. package/src/mapping.js +1260 -0
  105. package/src/semantic/location.js +154 -0
  106. package/src/semantic/operators.js +28 -0
  107. package/src/semantic/provenance.js +629 -0
  108. package/src/semantic/scalars.js +44 -0
  109. package/src/semantic/types.js +319 -0
  110. package/src/semantic/validate.js +529 -0
  111. package/tsconfig.json +16 -0
@@ -0,0 +1,593 @@
1
+ // @ts-check
2
+
3
+ import PhpParser from "php-parser"
4
+ import {parse as parseComment} from "comment-parser"
5
+ import {missingType, parseFailure, unsupportedSyntax} from "../diagnostic.js"
6
+ import {locationFromOffsets, moduleLocation} from "../semantic/location.js"
7
+ import {withAdaptedOperation} from "../semantic/operators.js"
8
+ import {withParserRanges} from "../semantic/provenance.js"
9
+ import {hasOnlyUnicodeScalars} from "../semantic/scalars.js"
10
+ import {requireSourceScalarType} from "./scalars.js"
11
+ const parser = new PhpParser.Engine({
12
+ ast: {withPositions: true},
13
+ parser: {extractDoc: true, suppressErrors: false}
14
+ })
15
+ /** @type {string | undefined} */
16
+ let cachedTokenSource
17
+ /** @type {{end: number, start: number, text: string}[]} */
18
+ let cachedTokenRanges = []
19
+ const phpBinaryOperations = new Map([
20
+ ["+", "PhpAdd"],
21
+ ["-", "Subtract"],
22
+ ["*", "Multiply"],
23
+ ["&&", "And"],
24
+ ["||", "Or"],
25
+ ["===", "Equal"],
26
+ ["!==", "NotEqual"],
27
+ ["<", "LessThan"],
28
+ ["<=", "LessThanOrEqual"],
29
+ [">", "GreaterThan"],
30
+ [">=", "GreaterThanOrEqual"],
31
+ [".", "StringConcat"]
32
+ ])
33
+
34
+ /**
35
+ * Returns a normalized PHP node location.
36
+ * @param {import("php-parser").Node} node - PHP parser node.
37
+ * @param {string} filename - Source filename.
38
+ * @param {string} source - Complete source.
39
+ * @returns {import("../semantic/types.js").SourceLocation} Source location.
40
+ */
41
+ function nodeLocation(node, filename, source) {
42
+ if (!node.loc) throw new Error(`PHP parser omitted source location for ${node.kind}.`)
43
+
44
+ return locationFromOffsets(filename, source, node.loc.start.offset, node.loc.end.offset)
45
+ }
46
+
47
+ /**
48
+ * Locates a comment-parser token inside a PHP parser-owned doc comment.
49
+ * @param {import("php-parser").CommentBlock} comment - PHP doc comment.
50
+ * @param {import("comment-parser").Spec} tag - Parsed doc tag.
51
+ * @param {"name" | "type"} field - Token field.
52
+ * @param {string} filename - Source filename.
53
+ * @param {string} source - Complete source.
54
+ * @returns {import("../semantic/types.js").SourceLocation} Exact token location.
55
+ */
56
+ function commentTagLocation(comment, tag, field, filename, source) {
57
+ const line = tag.source[0]
58
+
59
+ if (!comment.loc || !line) throw new Error(`Comment parser omitted the @${tag.tag} source token.`)
60
+
61
+ let lineStart = comment.loc.start.offset
62
+
63
+ for (let lineNumber = 0; lineNumber < line.number; lineNumber++) {
64
+ while (lineStart < source.length && source[lineStart] != "\r" && source[lineStart] != "\n") lineStart++
65
+ if (source[lineStart] == "\r" && source[lineStart + 1] == "\n") lineStart += 2
66
+ else lineStart++
67
+ }
68
+
69
+ const orderedFields = ["start", "delimiter", "postDelimiter", "tag", "postTag", "name", "postName", "type"]
70
+ const fieldIndex = orderedFields.indexOf(field)
71
+ const start = lineStart + orderedFields.slice(0, fieldIndex).reduce((length, key) =>
72
+ length + line.tokens[/** @type {keyof typeof line.tokens} */ (key)].length, 0)
73
+ const value = line.tokens[field]
74
+
75
+ return locationFromOffsets(filename, source, start, start + value.length)
76
+ }
77
+
78
+ /**
79
+ * Finds one parser token between parser-owned node boundaries.
80
+ * @param {string} tokenText - Exact token text.
81
+ * @param {number} startOffset - Inclusive search boundary.
82
+ * @param {number} endOffset - Exclusive search boundary.
83
+ * @param {string} filename - Source filename.
84
+ * @param {string} source - Complete source.
85
+ * @returns {import("../semantic/types.js").SourceLocation} Token location.
86
+ */
87
+ function tokenLocation(tokenText, startOffset, endOffset, filename, source) {
88
+ if (cachedTokenSource != source) {
89
+ let offset = 0
90
+
91
+ cachedTokenRanges = parser.tokenGetAll(source).map((token) => {
92
+ const text = typeof token == "string" ? token : token[1]
93
+ const range = {end: offset + text.length, start: offset, text}
94
+
95
+ offset = range.end
96
+
97
+ return range
98
+ })
99
+ cachedTokenSource = source
100
+ }
101
+
102
+ const token = cachedTokenRanges.find((candidate) => candidate.text == tokenText && candidate.start >= startOffset &&
103
+ candidate.end <= endOffset)
104
+
105
+ if (token) return locationFromOffsets(filename, source, token.start, token.end)
106
+
107
+ throw new Error(`PHP parser omitted token '${tokenText}' between ${startOffset} and ${endOffset}.`)
108
+ }
109
+
110
+ /**
111
+ * Converts a supported PHP expression.
112
+ * @param {import("php-parser").Expression} node - PHP expression.
113
+ * @param {string} filename - Source filename.
114
+ * @param {string} source - Complete source.
115
+ * @returns {import("../semantic/types.js").Expression} Semantic expression.
116
+ */
117
+ function convertExpression(node, filename, source) {
118
+ const location = nodeLocation(node, filename, source)
119
+
120
+ if (node.kind == "variable") {
121
+ const variable = /** @type {import("php-parser").Variable} */ (node)
122
+
123
+ if (typeof variable.name != "string") return unsupportedSyntax("php", "dynamic variable", location)
124
+
125
+ return withParserRanges({kind: /** @type {const} */ ("IdentifierExpression"), location, name: variable.name}, {name: location})
126
+ }
127
+
128
+ if (node.kind == "number") {
129
+ const literal = /** @type {import("php-parser").Number} */ (node)
130
+ const value = Number(literal.value)
131
+
132
+ if (!Number.isSafeInteger(value)) return unsupportedSyntax("php", "non-safe integer literal", location)
133
+
134
+ return withParserRanges({kind: /** @type {const} */ ("IntegerLiteral"), location, value}, {literal: location})
135
+ }
136
+
137
+ if (node.kind == "boolean") {
138
+ const literal = /** @type {import("php-parser").Boolean} */ (node)
139
+
140
+ if (typeof literal.value != "boolean") return unsupportedSyntax("php", "invalid boolean literal value", location)
141
+
142
+ return withParserRanges({kind: /** @type {const} */ ("BooleanLiteral"), location, value: literal.value}, {literal: location})
143
+ }
144
+
145
+ if (node.kind == "string") {
146
+ const literal = /** @type {import("php-parser").String} */ (node)
147
+
148
+ if (typeof literal.raw == "string" && literal.raw.startsWith("<<<")) {
149
+ const detail = literal.raw.startsWith("<<<'") ? "nowdoc string" : "heredoc string"
150
+
151
+ return unsupportedSyntax("php", detail, location)
152
+ }
153
+ if (typeof literal.raw == "string" && ["b\"", "B\"", "b'", "B'"].some((prefix) => literal.raw.startsWith(prefix))) {
154
+ return unsupportedSyntax("php", "binary string literal", location)
155
+ }
156
+ if (typeof literal.value != "string" || !hasOnlyUnicodeScalars(literal.value)) {
157
+ return unsupportedSyntax("php", "invalid Unicode string literal", location)
158
+ }
159
+
160
+ return withParserRanges({kind: /** @type {const} */ ("StringLiteral"), location, value: literal.value}, {literal: location})
161
+ }
162
+
163
+ if (node.kind == "encapsed") {
164
+ const literal = /** @type {import("php-parser").Encapsed} */ (node)
165
+ const detail = literal.type == "string" ? "interpolated string" : `${literal.type} string`
166
+
167
+ return unsupportedSyntax("php", detail, location)
168
+ }
169
+
170
+ if (node.kind == "nowdoc") return unsupportedSyntax("php", "nowdoc string", location)
171
+
172
+ if (node.kind == "unary") {
173
+ const unary = /** @type {import("php-parser").Unary} */ (node)
174
+
175
+ if (!["!", "-"].includes(unary.type)) return unsupportedSyntax("php", `unary ${unary.type}`, location)
176
+
177
+ const semantic = withAdaptedOperation(withParserRanges({
178
+ kind: "UnaryExpression",
179
+ location,
180
+ operand: convertExpression(unary.what, filename, source)
181
+ }, {
182
+ operator: tokenLocation(unary.type, unary.loc?.start.offset ?? 0, unary.what.loc?.start.offset ?? unary.loc?.end.offset ?? source.length, filename, source)
183
+ }), unary.type == "!" ? "Not" : "Negate")
184
+
185
+ return /** @type {import("../semantic/types.js").Expression} */ (/** @type {unknown} */ (semantic))
186
+ }
187
+
188
+ if (node.kind == "bin") {
189
+ const binary = /** @type {import("php-parser").Bin} */ (node)
190
+ if (["==", "!="].includes(binary.type)) return unsupportedSyntax("php", `coercive equality ${binary.type}`, location)
191
+ if (["and", "or"].includes(binary.type)) return unsupportedSyntax("php", `precedence-sensitive boolean ${binary.type}`, location)
192
+ if (!phpBinaryOperations.has(binary.type)) return unsupportedSyntax("php", `binary ${binary.type}`, location)
193
+
194
+ const semantic = withAdaptedOperation({
195
+ kind: /** @type {const} */ ("BinaryExpression"),
196
+ left: convertExpression(binary.left, filename, source),
197
+ location,
198
+ right: convertExpression(binary.right, filename, source)
199
+ }, /** @type {import("../semantic/operators.js").AdaptedOperation} */ (phpBinaryOperations.get(binary.type)))
200
+
201
+ return /** @type {import("../semantic/types.js").Expression} */ (/** @type {unknown} */ (withParserRanges(semantic, {
202
+ operator: tokenLocation(binary.type, binary.left.loc?.end.offset ?? 0, binary.right.loc?.start.offset ?? source.length, filename, source)
203
+ })))
204
+ }
205
+
206
+ if (node.kind == "call") {
207
+ const call = /** @type {import("php-parser").Call} */ (node)
208
+
209
+ if (call.what.kind != "name" && call.what.kind != "identifier") {
210
+ return unsupportedSyntax("php", "dynamic call", location)
211
+ }
212
+
213
+ const callee = /** @type {import("php-parser").Name | import("php-parser").Identifier} */ (call.what).name
214
+
215
+ return withParserRanges({
216
+ arguments: call.arguments.map((argument) => convertExpression(argument, filename, source)),
217
+ callee,
218
+ kind: "CallExpression",
219
+ location
220
+ }, {callee: nodeLocation(call.what, filename, source)})
221
+ }
222
+
223
+ return unsupportedSyntax("php", node.kind, location)
224
+ }
225
+
226
+ /**
227
+ * Converts one explicit PHP return.
228
+ * @param {import("php-parser").Node} node - PHP node.
229
+ * @param {string} filename - Source filename.
230
+ * @param {string} source - Complete source.
231
+ * @returns {import("../semantic/types.js").ReturnStatement} Semantic return.
232
+ */
233
+ function convertReturn(node, filename, source) {
234
+ const location = nodeLocation(node, filename, source)
235
+
236
+ if (node.kind != "return") return unsupportedSyntax("php", node.kind, location)
237
+
238
+ const returnNode = /** @type {import("php-parser").Return} */ (node)
239
+
240
+ if (!returnNode.expr) return unsupportedSyntax("php", "empty return", location)
241
+
242
+ return {expression: convertExpression(returnNode.expr, filename, source), kind: "ReturnStatement", location}
243
+ }
244
+
245
+ /**
246
+ * Reads one exact PHP local `@var` carrier and immutability marker.
247
+ * @param {import("php-parser").Node} node - Assignment-owning statement.
248
+ * @param {string} name - Local name.
249
+ * @param {string} filename - Source filename.
250
+ * @param {string} source - Complete source.
251
+ * @returns {{immutable: boolean, type: import("../semantic/types.js").TypeReference} | undefined} Metadata when present.
252
+ */
253
+ function localMetadata(node, name, filename, source) {
254
+ const comments = node.leadingComments ?? []
255
+ const comment = comments.at(-1)
256
+
257
+ if (!comment || comment.kind != "commentblock" || !comment.loc || !node.loc) return undefined
258
+
259
+ const gap = source.slice(comment.loc.end.offset, node.loc.start.offset)
260
+
261
+ if (!/^\s*$/u.test(gap) || (gap.match(/\n/gu)?.length ?? 0) > 1) return undefined
262
+
263
+ const block = parseComment(comment.value)[0]
264
+ const tags = block?.tags ?? []
265
+ const variables = tags.filter((tag) => tag.tag == "var")
266
+ const immutable = tags.filter((tag) => tag.tag == "semantifold-immutable")
267
+ const location = nodeLocation(node, filename, source)
268
+
269
+ if (variables.length == 0 && immutable.length == 0) return undefined
270
+
271
+ const exactVariable = variables.length == 1 && variables[0].type == "" && variables[0].description == `$${name}`
272
+ const exactImmutable = immutable.length <= 1 && immutable.every((tag) => tag.name == "" && tag.type == "" && tag.description == "")
273
+ const knownTags = tags.every((tag) => tag.tag == "var" || tag.tag == "semantifold-immutable")
274
+
275
+ if (!exactVariable || !exactImmutable || !knownTags) {
276
+ return unsupportedSyntax("php", "malformed local type metadata", location)
277
+ }
278
+
279
+ return {
280
+ immutable: immutable.length == 1,
281
+ type: requireSourceScalarType(
282
+ "php",
283
+ variables[0].name,
284
+ `Local '${name}'`,
285
+ location,
286
+ commentTagLocation(comment, variables[0], "name", filename, source)
287
+ )
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Converts one PHP local declaration or assignment.
293
+ * @param {import("php-parser").Node} node - PHP statement.
294
+ * @param {Set<string>} visible - Names visible during adaptation.
295
+ * @param {string} filename - Source filename.
296
+ * @param {string} source - Complete source.
297
+ * @returns {import("../semantic/types.js").LocalStatement} Semantic local statement.
298
+ */
299
+ function convertLocalStatement(node, visible, filename, source) {
300
+ const location = nodeLocation(node, filename, source)
301
+
302
+ if (node.kind != "expressionstatement") return unsupportedSyntax("php", node.kind, location)
303
+
304
+ const expression = /** @type {import("php-parser").ExpressionStatement} */ (node).expression
305
+
306
+ if (expression.kind != "assign") return unsupportedSyntax("php", expression.kind, nodeLocation(expression, filename, source))
307
+
308
+ const assignment = /** @type {import("php-parser").Assign} */ (expression)
309
+
310
+ if (assignment.operator != "=") return unsupportedSyntax("php", `assignment ${assignment.operator}`, location)
311
+ if (assignment.left.kind != "variable") return unsupportedSyntax("php", assignment.left.kind, nodeLocation(assignment.left, filename, source))
312
+
313
+ const variable = /** @type {import("php-parser").Variable} */ (assignment.left)
314
+ const targetLocation = nodeLocation(variable, filename, source)
315
+
316
+ if (typeof variable.name != "string" || variable.curly) return unsupportedSyntax("php", "dynamic variable", targetLocation)
317
+
318
+ const metadata = localMetadata(node, variable.name, filename, source)
319
+
320
+ if (metadata) {
321
+ visible.add(variable.name)
322
+ return withParserRanges({
323
+ initializer: convertExpression(assignment.right, filename, source),
324
+ kind: "LocalDeclaration",
325
+ location,
326
+ mutable: !metadata.immutable,
327
+ name: variable.name,
328
+ type: metadata.type
329
+ }, {
330
+ name: targetLocation,
331
+ operator: tokenLocation("=", assignment.left.loc?.end.offset ?? 0, assignment.right.loc?.start.offset ?? source.length, filename, source)
332
+ })
333
+ }
334
+
335
+ if (!visible.has(variable.name)) return missingType("php", `Local '${variable.name}'`, location)
336
+
337
+ const target = withParserRanges({kind: /** @type {const} */ ("IdentifierExpression"), location: targetLocation, name: variable.name}, {
338
+ name: targetLocation
339
+ })
340
+
341
+ return withParserRanges({
342
+ expression: convertExpression(assignment.right, filename, source),
343
+ kind: "AssignmentStatement",
344
+ location,
345
+ target
346
+ }, {
347
+ operator: tokenLocation("=", assignment.left.loc?.end.offset ?? 0, assignment.right.loc?.start.offset ?? source.length, filename, source)
348
+ })
349
+ }
350
+
351
+ /**
352
+ * Converts one exhaustive PHP statement.
353
+ * @param {import("php-parser").Node} node - PHP statement.
354
+ * @param {Set<string>} visible - Names visible during adaptation.
355
+ * @param {string} filename - Source filename.
356
+ * @param {string} source - Complete source.
357
+ * @returns {import("../semantic/types.js").Statement} Semantic statement.
358
+ */
359
+ function convertStatement(node, visible, filename, source) {
360
+ if (node.kind == "return") return convertReturn(node, filename, source)
361
+ if (node.kind == "if") return convertIf(/** @type {import("php-parser").If} */ (node), visible, filename, source)
362
+ if (node.kind == "echo") return convertPrint(/** @type {import("php-parser").Echo} */ (node), filename, source)
363
+ if (node.kind == "expressionstatement") return convertLocalStatement(node, visible, filename, source)
364
+
365
+ return unsupportedSyntax("php", node.kind, nodeLocation(node, filename, source))
366
+ }
367
+
368
+ /**
369
+ * Converts an ordered PHP block with its own adaptation scope.
370
+ * @param {import("php-parser").Block} node - PHP parser block.
371
+ * @param {Set<string>} visible - Names visible on block entry.
372
+ * @param {string} filename - Source filename.
373
+ * @param {string} source - Complete source.
374
+ * @returns {import("../semantic/types.js").Block} Semantic block.
375
+ */
376
+ function convertBlock(node, visible, filename, source) {
377
+ return {
378
+ kind: "Block",
379
+ location: nodeLocation(node, filename, source),
380
+ statements: node.children.map((statement) => convertStatement(statement, visible, filename, source))
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Requires an exact PHP scalar declaration type.
386
+ * @param {import("php-parser").Node | null} sourceType - PHP type node.
387
+ * @param {string} subject - Typed subject.
388
+ * @param {import("../semantic/types.js").SourceLocation} location - Source location.
389
+ * @param {string} filename - Source filename.
390
+ * @param {string} source - Complete source.
391
+ * @returns {import("../semantic/types.js").TypeReference} Semantic type.
392
+ */
393
+ function convertType(sourceType, subject, location, filename, source) {
394
+ if (!sourceType) return requireSourceScalarType("php", undefined, subject, location)
395
+ if (sourceType.kind != "typereference" && sourceType.kind != "name" && sourceType.kind != "identifier") {
396
+ return unsupportedSyntax("php", "unsupported scalar type", nodeLocation(sourceType, filename, source))
397
+ }
398
+
399
+ const typeName = /** @type {import("php-parser").TypeReference | import("php-parser").Name | import("php-parser").Identifier} */ (sourceType).name
400
+
401
+ return requireSourceScalarType("php", typeName, subject, location, nodeLocation(sourceType, filename, source))
402
+ }
403
+
404
+ /**
405
+ * Converts a PHP function.
406
+ * @param {import("php-parser").Function} node - PHP function node.
407
+ * @param {string} filename - Source filename.
408
+ * @param {string} source - Complete source.
409
+ * @returns {import("../semantic/types.js").FunctionDeclaration} Semantic function.
410
+ */
411
+ function convertFunction(node, filename, source) {
412
+ const location = nodeLocation(node, filename, source)
413
+ const name = typeof node.name == "string" ? node.name : node.name.name
414
+
415
+ if (!node.body) {
416
+ return unsupportedSyntax("php", "function body", location)
417
+ }
418
+
419
+ const parameters = node.arguments.map((parameter) => {
420
+ const parameterLocation = nodeLocation(parameter, filename, source)
421
+ const parameterName = typeof parameter.name == "string" ? parameter.name : parameter.name.name
422
+
423
+ if (parameter.nullable) return unsupportedSyntax("php", "unsupported scalar type", parameterLocation)
424
+
425
+ const parameterNode = typeof parameter.name == "string" ? parameter : parameter.name
426
+ const semanticParameter = {
427
+ kind: /** @type {const} */ ("Parameter"),
428
+ location: parameterLocation,
429
+ name: parameterName,
430
+ type: convertType(parameter.type, `Parameter '${parameterName}'`, parameterLocation, filename, source)
431
+ }
432
+
433
+ return withParserRanges(semanticParameter, {name: nodeLocation(parameterNode, filename, source)})
434
+ })
435
+ if (node.nullable) return unsupportedSyntax("php", "unsupported scalar type", location)
436
+ const visible = new Set(parameters.map((parameter) => parameter.name))
437
+ const body = convertBlock(node.body, visible, filename, source)
438
+
439
+ const nameNode = typeof node.name == "string" ? node : node.name
440
+
441
+ return withParserRanges({
442
+ body,
443
+ kind: "FunctionDeclaration",
444
+ location,
445
+ name,
446
+ parameters,
447
+ returnType: convertType(node.type, `Function '${name}' return`, location, filename, source)
448
+ }, {name: nodeLocation(nameNode, filename, source)})
449
+ }
450
+
451
+ /**
452
+ * Converts the existing PHP if/else terminal with restricted branch prefixes.
453
+ * @param {import("php-parser").If} node - PHP if node.
454
+ * @param {Set<string>} visible - Enclosing visible names.
455
+ * @param {string} filename - Source filename.
456
+ * @param {string} source - Complete source.
457
+ * @returns {import("../semantic/types.js").IfStatement} Semantic branch.
458
+ */
459
+ function convertIf(node, visible, filename, source) {
460
+ const location = nodeLocation(node, filename, source)
461
+
462
+ if (node.shortForm) return unsupportedSyntax("php", "alternative if syntax", location)
463
+ if (node.body.kind != "block") return unsupportedSyntax("php", "if without block consequent", location)
464
+
465
+ const consequentVisible = new Set(visible)
466
+ let alternate
467
+
468
+ if (node.alternate) {
469
+ const alternateVisible = new Set(visible)
470
+
471
+ if (node.alternate.kind == "block") {
472
+ alternate = convertBlock(/** @type {import("php-parser").Block} */ (node.alternate), alternateVisible, filename, source)
473
+ } else if (node.alternate.kind == "if") {
474
+ const nested = /** @type {import("php-parser").If} */ (node.alternate)
475
+
476
+ alternate = {
477
+ kind: /** @type {const} */ ("Block"),
478
+ location: nodeLocation(nested, filename, source),
479
+ statements: [convertIf(nested, alternateVisible, filename, source)]
480
+ }
481
+ } else return unsupportedSyntax("php", `if alternate ${node.alternate.kind}`, nodeLocation(node.alternate, filename, source))
482
+ }
483
+
484
+ return {
485
+ ...(alternate ? {alternate} : {}),
486
+ condition: convertExpression(node.test, filename, source),
487
+ consequent: convertBlock(node.body, consequentVisible, filename, source),
488
+ kind: "IfStatement",
489
+ location
490
+ }
491
+ }
492
+
493
+ /**
494
+ * Converts a PHP echo entry point.
495
+ * @param {import("php-parser").Echo} node - PHP echo node.
496
+ * @param {string} filename - Source filename.
497
+ * @param {string} source - Complete source.
498
+ * @returns {import("../semantic/types.js").PrintStatement} Print statement.
499
+ */
500
+ function convertPrint(node, filename, source) {
501
+ const location = nodeLocation(node, filename, source)
502
+
503
+ if (node.expressions.length != 2 || node.expressions[1].kind != "name" ||
504
+ /** @type {import("php-parser").Name} */ (node.expressions[1]).name != "PHP_EOL") {
505
+ return unsupportedSyntax("php", "echo without one expression and PHP_EOL", location)
506
+ }
507
+
508
+ return {expression: convertExpression(node.expressions[0], filename, source), kind: "PrintStatement", location}
509
+ }
510
+
511
+ /**
512
+ * Parses PHP into the shared semantic module.
513
+ * @param {object} input - Parser input.
514
+ * @param {string} input.filename - Source filename.
515
+ * @param {string} input.source - Source text.
516
+ * @returns {import("../semantic/types.js").SemanticModule} Semantic module.
517
+ */
518
+ export function parsePhp({filename, source}) {
519
+ const program = parsePhpProgram(filename, source)
520
+ validateDeclareNodes(program.children, filename, source)
521
+ const functions = program.children.filter((node) => node.kind == "function")
522
+ .map((node) => convertFunction(/** @type {import("php-parser").Function} */ (node), filename, source))
523
+ const executableNodes = program.children.filter((node) => node.kind != "function" && node.kind != "declare" && node.kind != "noop")
524
+ const location = moduleLocation(filename, source)
525
+
526
+ if (functions.length == 0) return unsupportedSyntax("php", "module without a function", location)
527
+ const first = executableNodes[0]
528
+ const last = executableNodes.at(-1) ?? first
529
+ const entryLocation = first
530
+ ? locationFromOffsets(filename, source, first.loc?.start.offset ?? 0, last?.loc?.end.offset ?? source.length)
531
+ : location
532
+ const entryVisible = new Set()
533
+ const entryBlock = {
534
+ kind: /** @type {const} */ ("Block"),
535
+ location: entryLocation,
536
+ statements: executableNodes.map((node) => convertStatement(node, entryVisible, filename, source))
537
+ }
538
+
539
+ return {
540
+ entryPoint: {
541
+ body: entryBlock,
542
+ kind: "EntryPoint",
543
+ location: entryLocation
544
+ },
545
+ functions,
546
+ kind: "Module",
547
+ location
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Accepts only an optional single `declare(strict_types=1)` directive.
553
+ * @param {import("php-parser").Node[]} nodes - Top-level PHP nodes.
554
+ * @param {string} filename - Source filename.
555
+ * @param {string} source - Complete source.
556
+ * @returns {void}
557
+ */
558
+ function validateDeclareNodes(nodes, filename, source) {
559
+ const declarations = nodes.filter((node) => node.kind == "declare")
560
+
561
+ if (declarations.length > 1) {
562
+ unsupportedSyntax("php", "multiple declare directives", nodeLocation(declarations[1], filename, source))
563
+ }
564
+
565
+ for (const declarationNode of declarations) {
566
+ const declaration = /** @type {import("php-parser").Declare} */ (declarationNode)
567
+ const directive = declaration.directives[0]
568
+ const key = directive?.key
569
+ const value = directive?.value
570
+ const numericValue = value && typeof value == "object" && value.kind == "number"
571
+ ? /** @type {import("php-parser").Number} */ (value)
572
+ : undefined
573
+ const valueIsOne = numericValue?.loc && source.slice(numericValue.loc.start.offset, numericValue.loc.end.offset) == "1"
574
+ const valid = declaration.mode == "none" && declaration.children.length == 0 && declaration.directives.length == 1 &&
575
+ key.kind == "identifier" && key.name == "strict_types" && valueIsOne
576
+
577
+ if (!valid) unsupportedSyntax("php", "declare other than strict_types=1", nodeLocation(declaration, filename, source))
578
+ }
579
+ }
580
+
581
+ /**
582
+ * Invokes php-parser and normalizes syntax failures.
583
+ * @param {string} filename - Source filename.
584
+ * @param {string} source - Source text.
585
+ * @returns {import("php-parser").Program} PHP program.
586
+ */
587
+ function parsePhpProgram(filename, source) {
588
+ try {
589
+ return parser.parseCode(source, filename)
590
+ } catch (error) {
591
+ return parseFailure("php", error)
592
+ }
593
+ }