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.
- package/LICENSE +21 -0
- package/README.md +274 -0
- package/package.json +65 -0
- package/src/alphabet.js +150 -0
- package/src/alphabet.test.js +82 -0
- package/src/chemistryValidator.js +236 -0
- package/src/cli.js +206 -0
- package/src/constraints.js +186 -0
- package/src/constraints.test.js +126 -0
- package/src/decoder.js +636 -0
- package/src/decoder.test.js +560 -0
- package/src/dsl/analyzer.js +170 -0
- package/src/dsl/analyzer.test.js +139 -0
- package/src/dsl/dsl.test.js +146 -0
- package/src/dsl/importer.js +238 -0
- package/src/dsl/index.js +32 -0
- package/src/dsl/lexer.js +264 -0
- package/src/dsl/lexer.test.js +115 -0
- package/src/dsl/parser.js +201 -0
- package/src/dsl/parser.test.js +148 -0
- package/src/dsl/resolver.js +136 -0
- package/src/dsl/resolver.test.js +99 -0
- package/src/dsl/symbolTable.js +56 -0
- package/src/dsl/symbolTable.test.js +68 -0
- package/src/dsl/valenceValidator.js +147 -0
- package/src/encoder.js +467 -0
- package/src/encoder.test.js +61 -0
- package/src/errors.js +79 -0
- package/src/errors.test.js +91 -0
- package/src/grammar_rules.js +146 -0
- package/src/index.js +70 -0
- package/src/parser.js +96 -0
- package/src/parser.test.js +96 -0
- package/src/properties/atoms.js +69 -0
- package/src/properties/atoms.test.js +116 -0
- package/src/properties/formula.js +111 -0
- package/src/properties/formula.test.js +95 -0
- package/src/properties/molecularWeight.js +80 -0
- package/src/properties/molecularWeight.test.js +84 -0
- package/src/properties/properties.test.js +77 -0
- package/src/renderers/README.md +127 -0
- package/src/renderers/svg.js +113 -0
- package/src/renderers/svg.test.js +42 -0
- package/src/syntax.js +641 -0
- package/src/syntax.test.js +363 -0
- package/src/tokenizer.js +99 -0
- package/src/tokenizer.test.js +55 -0
- package/src/validator.js +70 -0
- 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
|
+
}
|