selfies-js 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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -0
  3. package/package.json +65 -0
  4. package/src/alphabet.js +150 -0
  5. package/src/alphabet.test.js +82 -0
  6. package/src/chemistryValidator.js +236 -0
  7. package/src/cli.js +206 -0
  8. package/src/constraints.js +186 -0
  9. package/src/constraints.test.js +126 -0
  10. package/src/decoder.js +636 -0
  11. package/src/decoder.test.js +560 -0
  12. package/src/dsl/analyzer.js +170 -0
  13. package/src/dsl/analyzer.test.js +139 -0
  14. package/src/dsl/dsl.test.js +146 -0
  15. package/src/dsl/importer.js +238 -0
  16. package/src/dsl/index.js +32 -0
  17. package/src/dsl/lexer.js +264 -0
  18. package/src/dsl/lexer.test.js +115 -0
  19. package/src/dsl/parser.js +201 -0
  20. package/src/dsl/parser.test.js +148 -0
  21. package/src/dsl/resolver.js +136 -0
  22. package/src/dsl/resolver.test.js +99 -0
  23. package/src/dsl/symbolTable.js +56 -0
  24. package/src/dsl/symbolTable.test.js +68 -0
  25. package/src/dsl/valenceValidator.js +147 -0
  26. package/src/encoder.js +467 -0
  27. package/src/encoder.test.js +61 -0
  28. package/src/errors.js +79 -0
  29. package/src/errors.test.js +91 -0
  30. package/src/grammar_rules.js +146 -0
  31. package/src/index.js +70 -0
  32. package/src/parser.js +96 -0
  33. package/src/parser.test.js +96 -0
  34. package/src/properties/atoms.js +69 -0
  35. package/src/properties/atoms.test.js +116 -0
  36. package/src/properties/formula.js +111 -0
  37. package/src/properties/formula.test.js +95 -0
  38. package/src/properties/molecularWeight.js +80 -0
  39. package/src/properties/molecularWeight.test.js +84 -0
  40. package/src/properties/properties.test.js +77 -0
  41. package/src/renderers/README.md +127 -0
  42. package/src/renderers/svg.js +113 -0
  43. package/src/renderers/svg.test.js +42 -0
  44. package/src/syntax.js +641 -0
  45. package/src/syntax.test.js +363 -0
  46. package/src/tokenizer.js +99 -0
  47. package/src/tokenizer.test.js +55 -0
  48. package/src/validator.js +70 -0
  49. package/src/validator.test.js +44 -0
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Analyzer - Static analysis for DSL programs
3
+ *
4
+ * Detects forward references, circular dependencies, unused definitions,
5
+ * and other semantic issues.
6
+ */
7
+
8
+ /**
9
+ * Gets the names that a definition depends on
10
+ * @param {Object} program - Program object
11
+ * @param {string} name - Name of definition to analyze
12
+ * @returns {string[]} Array of dependency names
13
+ *
14
+ * Example:
15
+ * // [methyl] = [C]
16
+ * // [ethanol] = [methyl][C][O]
17
+ * getDependencies(program, 'ethanol') // => ['methyl']
18
+ * getDependencies(program, 'methyl') // => []
19
+ */
20
+ export function getDependencies(program, name) {
21
+ const definition = program.definitions.get(name)
22
+ if (!definition) {
23
+ return []
24
+ }
25
+
26
+ const dependencies = []
27
+ for (const token of definition.tokens) {
28
+ const tokenName = token.slice(1, -1) // Remove brackets
29
+ if (program.definitions.has(tokenName)) {
30
+ if (!dependencies.includes(tokenName)) {
31
+ dependencies.push(tokenName)
32
+ }
33
+ }
34
+ }
35
+
36
+ return dependencies
37
+ }
38
+
39
+ /**
40
+ * Gets the names that depend on a definition
41
+ * @param {Object} program - Program object
42
+ * @param {string} name - Name to find dependents of
43
+ * @returns {string[]} Array of dependent names
44
+ *
45
+ * Example:
46
+ * // [methyl] = [C]
47
+ * // [ethyl] = [methyl][C]
48
+ * // [ethanol] = [ethyl][O]
49
+ * getDependents(program, 'methyl') // => ['ethyl', 'ethanol']
50
+ */
51
+ export function getDependents(program, name) {
52
+ const dependents = []
53
+
54
+ // Build a set of all transitive dependents
55
+ const visited = new Set()
56
+
57
+ function findDirectDependents(targetName) {
58
+ for (const [defName, definition] of program.definitions) {
59
+ if (visited.has(defName)) continue
60
+
61
+ const deps = getDependencies(program, defName)
62
+ if (deps.includes(targetName)) {
63
+ visited.add(defName)
64
+ dependents.push(defName)
65
+ // Recursively find dependents of this dependent
66
+ findDirectDependents(defName)
67
+ }
68
+ }
69
+ }
70
+
71
+ findDirectDependents(name)
72
+ return dependents
73
+ }
74
+
75
+ /**
76
+ * Detects circular dependencies in a program
77
+ * @param {Object} program - Program object
78
+ * @returns {Object[]} Array of cycle diagnostics
79
+ *
80
+ * Each diagnostic contains:
81
+ * {
82
+ * message: string,
83
+ * severity: 'error',
84
+ * cycle: string[], // Names forming the cycle
85
+ * ...location info
86
+ * }
87
+ */
88
+ export function detectCycles(program) {
89
+ const diagnostics = []
90
+ const visited = new Set()
91
+ const visiting = new Set()
92
+ const cyclesFound = new Set()
93
+
94
+ function dfs(name, path = []) {
95
+ if (visiting.has(name)) {
96
+ // Found a cycle
97
+ const cycleStart = path.indexOf(name)
98
+ const cycle = [...path.slice(cycleStart), name]
99
+ const cycleKey = cycle.sort().join(',')
100
+
101
+ // Only report each unique cycle once
102
+ if (!cyclesFound.has(cycleKey)) {
103
+ cyclesFound.add(cycleKey)
104
+ const definition = program.definitions.get(name)
105
+ diagnostics.push({
106
+ message: `Circular dependency: ${cycle.join(' -> ')}`,
107
+ severity: 'error',
108
+ cycle,
109
+ line: definition?.line || 1,
110
+ column: 1,
111
+ range: definition?.range || [0, 0]
112
+ })
113
+ }
114
+ return
115
+ }
116
+
117
+ if (visited.has(name)) {
118
+ return
119
+ }
120
+
121
+ visiting.add(name)
122
+ path.push(name)
123
+
124
+ const deps = getDependencies(program, name)
125
+ for (const dep of deps) {
126
+ dfs(dep, [...path])
127
+ }
128
+
129
+ path.pop()
130
+ visiting.delete(name)
131
+ visited.add(name)
132
+ }
133
+
134
+ for (const name of program.definitions.keys()) {
135
+ if (!visited.has(name)) {
136
+ dfs(name)
137
+ }
138
+ }
139
+
140
+ return diagnostics
141
+ }
142
+
143
+ /**
144
+ * Finds unused definitions (not referenced by any other definition)
145
+ * @param {Object} program - Program object
146
+ * @returns {string[]} Array of unused definition names
147
+ */
148
+ export function findUnused(program) {
149
+ const referenced = new Set()
150
+
151
+ // Find all referenced names
152
+ for (const [name, definition] of program.definitions) {
153
+ for (const token of definition.tokens) {
154
+ const tokenName = token.slice(1, -1)
155
+ if (program.definitions.has(tokenName)) {
156
+ referenced.add(tokenName)
157
+ }
158
+ }
159
+ }
160
+
161
+ // Find definitions that are not referenced
162
+ const unused = []
163
+ for (const name of program.definitions.keys()) {
164
+ if (!referenced.has(name)) {
165
+ unused.push(name)
166
+ }
167
+ }
168
+
169
+ return unused
170
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Tests for analyzer
3
+ */
4
+
5
+ import { describe, test, expect } from 'bun:test'
6
+ import { parse } from './parser.js'
7
+ import { getDependencies, getDependents, detectCycles, findUnused } from './analyzer.js'
8
+
9
+ describe('getDependencies', () => {
10
+ test('gets direct dependencies', () => {
11
+ const source = '[methyl] = [C]\n[ethanol] = [methyl][C][O]'
12
+ const program = parse(source)
13
+ expect(getDependencies(program, 'ethanol')).toContain('methyl')
14
+ })
15
+
16
+ test('returns empty for primitive definitions', () => {
17
+ const program = parse('[methyl] = [C]')
18
+ expect(getDependencies(program, 'methyl')).toEqual([])
19
+ })
20
+
21
+ test('returns empty for undefined name', () => {
22
+ const program = parse('[methyl] = [C]')
23
+ expect(getDependencies(program, 'undefined')).toEqual([])
24
+ })
25
+
26
+ test('handles multiple dependencies', () => {
27
+ const source = '[a] = [C]\n[b] = [N]\n[c] = [a][b][O]'
28
+ const program = parse(source)
29
+ const deps = getDependencies(program, 'c')
30
+ expect(deps).toContain('a')
31
+ expect(deps).toContain('b')
32
+ expect(deps).toHaveLength(2)
33
+ })
34
+
35
+ test('deduplicates dependencies', () => {
36
+ const source = '[methyl] = [C]\n[ethyl] = [methyl][methyl]'
37
+ const program = parse(source)
38
+ const deps = getDependencies(program, 'ethyl')
39
+ expect(deps).toEqual(['methyl'])
40
+ })
41
+ })
42
+
43
+ describe('getDependents', () => {
44
+ test('gets dependents', () => {
45
+ const source = '[methyl] = [C]\n[ethyl] = [methyl][C]\n[ethanol] = [ethyl][O]'
46
+ const program = parse(source)
47
+ const dependents = getDependents(program, 'methyl')
48
+ expect(dependents).toContain('ethyl')
49
+ expect(dependents).toContain('ethanol')
50
+ })
51
+
52
+ test('returns empty for unused definitions', () => {
53
+ const source = '[methyl] = [C]\n[ethyl] = [C][C]'
54
+ const program = parse(source)
55
+ expect(getDependents(program, 'methyl')).toEqual([])
56
+ })
57
+
58
+ test('returns empty for undefined name', () => {
59
+ const program = parse('[methyl] = [C]')
60
+ expect(getDependents(program, 'undefined')).toEqual([])
61
+ })
62
+
63
+ test('includes transitive dependents', () => {
64
+ const source = '[a] = [C]\n[b] = [a]\n[c] = [b]'
65
+ const program = parse(source)
66
+ const dependents = getDependents(program, 'a')
67
+ expect(dependents).toContain('b')
68
+ expect(dependents).toContain('c')
69
+ })
70
+ })
71
+
72
+ describe('detectCycles', () => {
73
+ test('detects simple cycle', () => {
74
+ const source = '[a] = [b]\n[b] = [a]'
75
+ const program = parse(source)
76
+ const cycles = detectCycles(program)
77
+ expect(cycles.length).toBeGreaterThan(0)
78
+ expect(cycles[0].message).toContain('Circular dependency')
79
+ expect(cycles[0].cycle).toBeDefined()
80
+ })
81
+
82
+ test('detects self-reference', () => {
83
+ const source = '[a] = [a]'
84
+ const program = parse(source)
85
+ const cycles = detectCycles(program)
86
+ expect(cycles.length).toBeGreaterThan(0)
87
+ })
88
+
89
+ test('detects longer cycles', () => {
90
+ const source = '[a] = [b]\n[b] = [c]\n[c] = [a]'
91
+ const program = parse(source)
92
+ const cycles = detectCycles(program)
93
+ expect(cycles.length).toBeGreaterThan(0)
94
+ })
95
+
96
+ test('returns empty for acyclic graph', () => {
97
+ const source = '[methyl] = [C]\n[ethyl] = [methyl][C]'
98
+ const program = parse(source)
99
+ const cycles = detectCycles(program)
100
+ expect(cycles).toEqual([])
101
+ })
102
+
103
+ test('diagnostics include severity and location', () => {
104
+ const source = '[a] = [b]\n[b] = [a]'
105
+ const program = parse(source)
106
+ const cycles = detectCycles(program)
107
+ expect(cycles[0]).toMatchObject({
108
+ severity: 'error',
109
+ line: expect.any(Number),
110
+ column: expect.any(Number),
111
+ range: expect.any(Array)
112
+ })
113
+ })
114
+ })
115
+
116
+ describe('findUnused', () => {
117
+ test('finds unused definitions', () => {
118
+ const source = '[methyl] = [C]\n[ethyl] = [C][C]\n[ethanol] = [ethyl][O]'
119
+ const program = parse(source)
120
+ const unused = findUnused(program)
121
+ expect(unused).toContain('methyl')
122
+ expect(unused).toContain('ethanol')
123
+ expect(unused).not.toContain('ethyl')
124
+ })
125
+
126
+ test('returns empty when all used', () => {
127
+ const source = '[methyl] = [C]\n[ethyl] = [methyl][C]'
128
+ const program = parse(source)
129
+ const unused = findUnused(program)
130
+ expect(unused).toEqual(['ethyl'])
131
+ })
132
+
133
+ test('returns all for single definition', () => {
134
+ const source = '[methyl] = [C]'
135
+ const program = parse(source)
136
+ const unused = findUnused(program)
137
+ expect(unused).toEqual(['methyl'])
138
+ })
139
+ })
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Integration tests for DSL parsing and resolution
3
+ */
4
+
5
+ import { describe, test, expect } from 'bun:test'
6
+ import { parse } from './parser.js'
7
+ import { resolve, resolveAll } from './resolver.js'
8
+ import { getDependencies, getDependents } from './analyzer.js'
9
+
10
+ describe('DSL Integration', () => {
11
+ test('complete workflow: parse -> resolve', () => {
12
+ const source = `
13
+ # Simple definitions
14
+ [methyl] = [C]
15
+ [ethyl] = [C][C]
16
+ [hydroxyl] = [O]
17
+
18
+ # Composition
19
+ [ethanol] = [ethyl][hydroxyl]
20
+ `
21
+ const program = parse(source)
22
+ expect(program.errors).toEqual([])
23
+ expect(program.definitions.size).toBe(4)
24
+
25
+ expect(resolve(program, 'methyl')).toBe('[C]')
26
+ expect(resolve(program, 'ethyl')).toBe('[C][C]')
27
+ expect(resolve(program, 'hydroxyl')).toBe('[O]')
28
+ expect(resolve(program, 'ethanol')).toBe('[C][C][O]')
29
+ })
30
+
31
+ test('resolves with decode option', () => {
32
+ const source = `
33
+ [methyl] = [C]
34
+ [ethanol] = [methyl][C][O]
35
+ `
36
+ const program = parse(source)
37
+ expect(resolve(program, 'ethanol', { decode: true })).toBe('CCO')
38
+ })
39
+
40
+ test('handles complex molecule definitions', () => {
41
+ const source = `
42
+ [carbonyl] = [=O]
43
+ [methyl] = [C]
44
+ [acetone] = [methyl][C][carbonyl][methyl]
45
+ `
46
+ const program = parse(source)
47
+ expect(resolve(program, 'acetone')).toBe('[C][C][=O][C]')
48
+ })
49
+
50
+ test('resolveAll returns all definitions', () => {
51
+ const source = `
52
+ [methyl] = [C]
53
+ [ethyl] = [methyl][C]
54
+ [propyl] = [ethyl][C]
55
+ `
56
+ const program = parse(source)
57
+ const all = resolveAll(program)
58
+ expect(all.size).toBe(3)
59
+ expect(all.get('methyl')).toBe('[C]')
60
+ expect(all.get('ethyl')).toBe('[C][C]')
61
+ expect(all.get('propyl')).toBe('[C][C][C]')
62
+ })
63
+
64
+ test('dependency analysis', () => {
65
+ const source = `
66
+ [methyl] = [C]
67
+ [ethyl] = [methyl][C]
68
+ [ethanol] = [ethyl][O]
69
+ `
70
+ const program = parse(source)
71
+
72
+ expect(getDependencies(program, 'ethanol')).toEqual(['ethyl'])
73
+ expect(getDependencies(program, 'ethyl')).toEqual(['methyl'])
74
+ expect(getDependencies(program, 'methyl')).toEqual([])
75
+
76
+ const dependents = getDependents(program, 'methyl')
77
+ expect(dependents).toContain('ethyl')
78
+ expect(dependents).toContain('ethanol')
79
+ })
80
+
81
+ test('handles branching structures', () => {
82
+ const source = `
83
+ [methyl] = [C]
84
+ [isobutane] = [C][C][Branch1][C][methyl][methyl]
85
+ `
86
+ const program = parse(source)
87
+ expect(resolve(program, 'isobutane')).toBe('[C][C][Branch1][C][C][C]')
88
+ })
89
+
90
+ test('handles ring structures', () => {
91
+ const source = `
92
+ [benzene] = [C][=C][C][=C][C][=C][Ring1][=Branch1]
93
+ `
94
+ const program = parse(source)
95
+ expect(resolve(program, 'benzene')).toBe('[C][=C][C][=C][C][=C][Ring1][=Branch1]')
96
+ })
97
+
98
+ test('empty program', () => {
99
+ const program = parse('')
100
+ expect(program.definitions.size).toBe(0)
101
+ expect(program.errors).toEqual([])
102
+ })
103
+
104
+ test('program with only comments', () => {
105
+ const program = parse('# Just a comment\n# Another comment')
106
+ expect(program.definitions.size).toBe(0)
107
+ expect(program.errors).toEqual([])
108
+ })
109
+
110
+ test('error recovery allows parsing multiple definitions', () => {
111
+ const source = `
112
+ [valid] = [C]
113
+ [invalid] [C]
114
+ [another_valid] = [N]
115
+ `
116
+ const program = parse(source)
117
+ expect(program.definitions.has('valid')).toBe(true)
118
+ expect(program.definitions.has('another_valid')).toBe(true)
119
+ expect(program.errors.length).toBeGreaterThan(0)
120
+ })
121
+
122
+ test('real-world example: amino acid fragments', () => {
123
+ const source = `
124
+ # Basic building blocks
125
+ [amino] = [N]
126
+ [carboxyl] = [C][=O][O]
127
+ [methyl] = [C]
128
+ [ethyl] = [methyl][C]
129
+
130
+ # Amino acid backbone
131
+ [backbone] = [amino][C][carboxyl]
132
+
133
+ # Side chains
134
+ [alanine_side] = [methyl]
135
+ [valine_side] = [ethyl][Branch1][C][methyl][methyl]
136
+
137
+ # Complete amino acids
138
+ [alanine] = [amino][C][Branch1][C][alanine_side][carboxyl]
139
+ `
140
+ const program = parse(source)
141
+ expect(program.errors).toEqual([])
142
+
143
+ const alanine = resolve(program, 'alanine')
144
+ expect(alanine).toBe('[N][C][Branch1][C][C][C][=O][O]')
145
+ })
146
+ })
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Importer - Handles importing definitions from other .selfies files
3
+ *
4
+ * Supports import syntax like:
5
+ * import "./fragments.selfies" # Import all definitions
6
+ * import * from "./common.selfies" # Import all (alternative syntax)
7
+ * import [methyl, ethyl] from "./fragments.selfies" # Import specific definitions
8
+ */
9
+
10
+ import { readFileSync, existsSync } from 'fs'
11
+ import { resolve as resolvePath, dirname, join, isAbsolute } from 'path'
12
+ import { parse } from './parser.js'
13
+
14
+ /**
15
+ * Parses import statements from DSL source
16
+ * @param {string} source - DSL source code
17
+ * @param {string} currentFilePath - Path to current file (for relative imports)
18
+ * @returns {Object} {imports: Import[], sourceWithoutImports: string}
19
+ *
20
+ * Import structure:
21
+ * {
22
+ * names: string[] | '*', // Array of names or '*' for all
23
+ * filePath: string, // Resolved absolute path
24
+ * originalPath: string // Original path from import statement
25
+ * }
26
+ */
27
+ export function parseImports(source, currentFilePath) {
28
+ const imports = []
29
+ const lines = source.split('\n')
30
+ const nonImportLines = []
31
+
32
+ // Match: import [names] from "path" OR import * from "path" OR import "path"
33
+ const importWithNamesRegex = /^import\s+\[([^\]]+)\]\s+from\s+['"]([^'"]+)['"]/
34
+ const importAllFromRegex = /^import\s+\*\s+from\s+['"]([^'"]+)['"]/
35
+ const importSimpleRegex = /^import\s+['"]([^'"]+)['"]/
36
+
37
+ for (const line of lines) {
38
+ const trimmed = line.trim()
39
+
40
+ // Skip empty lines and comments for import matching
41
+ if (!trimmed.startsWith('import')) {
42
+ nonImportLines.push(line)
43
+ continue
44
+ }
45
+
46
+ let match
47
+ let names
48
+ let relativeFilePath
49
+
50
+ // Try matching different import patterns
51
+ if ((match = trimmed.match(importWithNamesRegex))) {
52
+ // import [name1, name2] from "path"
53
+ names = match[1].split(',').map(n => n.trim())
54
+ relativeFilePath = match[2]
55
+ } else if ((match = trimmed.match(importAllFromRegex))) {
56
+ // import * from "path"
57
+ names = '*'
58
+ relativeFilePath = match[1]
59
+ } else if ((match = trimmed.match(importSimpleRegex))) {
60
+ // import "path" (shorthand for import all)
61
+ names = '*'
62
+ relativeFilePath = match[1]
63
+ } else {
64
+ // Not a valid import line, keep it
65
+ nonImportLines.push(line)
66
+ continue
67
+ }
68
+
69
+ // Resolve file path relative to current file
70
+ const currentDir = currentFilePath ? dirname(resolvePath(currentFilePath)) : process.cwd()
71
+ const absoluteFilePath = isAbsolute(relativeFilePath)
72
+ ? relativeFilePath
73
+ : join(currentDir, relativeFilePath)
74
+
75
+ imports.push({
76
+ names,
77
+ filePath: absoluteFilePath,
78
+ originalPath: relativeFilePath
79
+ })
80
+ }
81
+
82
+ return {
83
+ imports,
84
+ sourceWithoutImports: nonImportLines.join('\n')
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Loads and merges imported definitions into a program
90
+ * Handles recursive imports with cycle detection
91
+ * @param {string} source - DSL source code
92
+ * @param {string} filePath - Path to current file
93
+ * @param {Set<string>} visited - Set of already visited file paths (for cycle detection)
94
+ * @returns {Object} Merged program with all imported definitions
95
+ */
96
+ export function loadWithImports(source, filePath, visited = new Set()) {
97
+ // Normalize the file path for cycle detection
98
+ const normalizedPath = filePath ? resolvePath(filePath) : null
99
+
100
+ // Check for circular imports
101
+ if (normalizedPath && visited.has(normalizedPath)) {
102
+ return {
103
+ definitions: new Map(),
104
+ errors: [{
105
+ message: `Circular import detected: ${filePath}`,
106
+ severity: 'error',
107
+ line: 1,
108
+ column: 1,
109
+ range: [0, 0]
110
+ }],
111
+ warnings: []
112
+ }
113
+ }
114
+
115
+ // Add current file to visited set
116
+ if (normalizedPath) {
117
+ visited.add(normalizedPath)
118
+ }
119
+
120
+ // Parse imports from source
121
+ const { imports, sourceWithoutImports } = parseImports(source, filePath)
122
+
123
+ // Parse the main file (without import statements)
124
+ const mainProgram = parse(sourceWithoutImports)
125
+
126
+ // Load each import and merge definitions
127
+ for (const importSpec of imports) {
128
+ // Check if file exists
129
+ if (!existsSync(importSpec.filePath)) {
130
+ mainProgram.errors.push({
131
+ message: `Import file not found: ${importSpec.originalPath} (resolved to ${importSpec.filePath})`,
132
+ severity: 'error',
133
+ line: 1,
134
+ column: 1,
135
+ range: [0, 0]
136
+ })
137
+ continue
138
+ }
139
+
140
+ try {
141
+ const importedSource = readFileSync(importSpec.filePath, 'utf-8')
142
+
143
+ // Recursively load with imports (pass visited set for cycle detection)
144
+ const importedProgram = loadWithImports(importedSource, importSpec.filePath, visited)
145
+
146
+ // Propagate errors from imported file
147
+ if (importedProgram.errors.length > 0) {
148
+ for (const error of importedProgram.errors) {
149
+ mainProgram.errors.push({
150
+ ...error,
151
+ message: `In ${importSpec.originalPath}: ${error.message}`
152
+ })
153
+ }
154
+ }
155
+
156
+ // Merge definitions based on import specification
157
+ if (importSpec.names === '*') {
158
+ // Import all definitions
159
+ for (const [name, definition] of importedProgram.definitions) {
160
+ if (!mainProgram.definitions.has(name)) {
161
+ // Mark definition as imported
162
+ mainProgram.definitions.set(name, {
163
+ ...definition,
164
+ importedFrom: importSpec.originalPath
165
+ })
166
+ }
167
+ }
168
+ } else {
169
+ // Import specific definitions
170
+ for (const name of importSpec.names) {
171
+ if (importedProgram.definitions.has(name)) {
172
+ if (!mainProgram.definitions.has(name)) {
173
+ mainProgram.definitions.set(name, {
174
+ ...importedProgram.definitions.get(name),
175
+ importedFrom: importSpec.originalPath
176
+ })
177
+ }
178
+ } else {
179
+ mainProgram.errors.push({
180
+ message: `Cannot import '${name}': not found in ${importSpec.originalPath}`,
181
+ severity: 'error',
182
+ line: 1,
183
+ column: 1,
184
+ range: [0, 0]
185
+ })
186
+ }
187
+ }
188
+ }
189
+ } catch (error) {
190
+ mainProgram.errors.push({
191
+ message: `Failed to import from ${importSpec.originalPath}: ${error.message}`,
192
+ severity: 'error',
193
+ line: 1,
194
+ column: 1,
195
+ range: [0, 0]
196
+ })
197
+ }
198
+ }
199
+
200
+ return mainProgram
201
+ }
202
+
203
+ /**
204
+ * Recursively loads a file with all its transitive imports
205
+ * @param {string} filePath - Path to .selfies file
206
+ * @returns {Object} Merged program with all definitions
207
+ */
208
+ export function loadFile(filePath) {
209
+ const absolutePath = resolvePath(filePath)
210
+
211
+ if (!existsSync(absolutePath)) {
212
+ return {
213
+ definitions: new Map(),
214
+ errors: [{
215
+ message: `File not found: ${filePath}`,
216
+ severity: 'error',
217
+ line: 1,
218
+ column: 1,
219
+ range: [0, 0]
220
+ }],
221
+ warnings: []
222
+ }
223
+ }
224
+
225
+ const source = readFileSync(absolutePath, 'utf-8')
226
+ return loadWithImports(source, absolutePath)
227
+ }
228
+
229
+ /**
230
+ * Gets the list of imports from a source file without loading them
231
+ * @param {string} source - DSL source code
232
+ * @param {string} currentFilePath - Path to current file
233
+ * @returns {Object[]} Array of import specifications
234
+ */
235
+ export function getImports(source, currentFilePath) {
236
+ const { imports } = parseImports(source, currentFilePath)
237
+ return imports
238
+ }