├── examples └── workspace │ ├── lona.json │ ├── Shadows.md │ ├── TextStyles.md │ ├── Colors.md │ └── README.md ├── src ├── plugins │ ├── __tests__ │ │ └── fixtures │ │ │ ├── declaration │ │ │ ├── import.md │ │ │ ├── function.md │ │ │ ├── variable.md │ │ │ ├── enumeration.md │ │ │ ├── enumerationWithLabels.md │ │ │ ├── record.md │ │ │ └── namespace.md │ │ │ ├── literal │ │ │ ├── array.md │ │ │ ├── boolean.md │ │ │ ├── number.md │ │ │ ├── string.md │ │ │ └── color.md │ │ │ ├── statement │ │ │ ├── return.md │ │ │ ├── loop.md │ │ │ └── branch.md │ │ │ └── expression │ │ │ ├── assignment.md │ │ │ ├── identifier.md │ │ │ ├── member.md │ │ │ └── functionCall.md │ ├── swift │ │ ├── convert │ │ │ ├── LogicGenerationContext.ts │ │ │ └── nativeType.ts │ │ ├── __tests__ │ │ │ ├── mocks │ │ │ │ └── SwiftLanguage.logic │ │ │ ├── typeGeneration.ts │ │ │ ├── swift.test.ts │ │ │ └── __snapshots__ │ │ │ │ └── typeGeneration.ts.snap │ │ └── index.ts │ ├── js │ │ ├── format.ts │ │ ├── utils.ts │ │ ├── astUtils.ts │ │ ├── __tests__ │ │ │ ├── js.ts │ │ │ └── __snapshots__ │ │ │ │ └── js.ts.snap │ │ ├── index.ts │ │ └── jsAst.ts │ ├── index.ts │ ├── documentation │ │ ├── utils.ts │ │ ├── documentationAst.ts │ │ ├── __tests__ │ │ │ ├── documentation.ts │ │ │ └── __snapshots__ │ │ │ │ └── documentation.ts.snap │ │ ├── index.ts │ │ └── convert.ts │ └── tokens │ │ ├── convert.ts │ │ ├── tokensAst.ts │ │ ├── __tests__ │ │ ├── tokens.ts │ │ └── __snapshots__ │ │ │ └── tokens.ts.snap │ │ ├── index.ts │ │ └── tokenValue.ts ├── index.ts ├── utils │ ├── sequence.ts │ ├── workspace.ts │ ├── typeHelpers.ts │ ├── reporter.ts │ ├── uuid.ts │ ├── config.ts │ ├── printer.ts │ └── plugin.ts ├── logic │ ├── nodes │ │ ├── ReturnStatement.ts │ │ ├── NamespaceDeclaration.ts │ │ ├── LiteralExpression.ts │ │ ├── typeAnnotations.ts │ │ ├── interfaces.ts │ │ ├── FunctionParameter.ts │ │ ├── IdentifierExpression.ts │ │ ├── MemberExpression.ts │ │ ├── VariableDeclaration.ts │ │ ├── literals.ts │ │ ├── createNode.ts │ │ ├── EnumerationDeclaration.ts │ │ ├── FunctionCallExpression.ts │ │ └── RecordDeclaration.ts │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ └── implementations.ts.snap │ │ ├── unify.ts │ │ ├── implementations.ts │ │ ├── namespace.ts │ │ └── evaluation.ts │ ├── nodePath.ts │ ├── implementations.ts │ ├── traversal.ts │ ├── multiMap.ts │ ├── scopeStack.ts │ ├── runtime │ │ ├── memory.ts │ │ └── value.ts │ ├── namespaceVisitor.ts │ ├── evaluationVisitor.ts │ ├── staticType.ts │ ├── namespace.ts │ ├── scope.ts │ ├── scopeVisitor.ts │ ├── module.ts │ ├── evaluation.ts │ ├── typeUnifier.ts │ ├── ast.ts │ └── typeChecker.ts ├── config.ts ├── helpers.ts ├── convert.ts └── bin.ts ├── .gitignore ├── static ├── swift │ ├── Color.swift │ ├── Image.swift │ ├── Font.swift │ ├── Shadow.swift │ └── TextStyle.swift └── logic │ ├── Shadow.logic │ ├── Color.logic │ ├── LonaDevice.logic │ ├── Prelude.logic │ ├── Element.logic │ └── TextStyle.logic ├── tsconfig.json ├── .github └── workflows │ └── build-and-test.yaml ├── README.md ├── package.json └── docs └── architecture.md /examples/workspace/lona.json: -------------------------------------------------------------------------------- 1 | { 2 | 3 | } -------------------------------------------------------------------------------- /src/plugins/__tests__/fixtures/declaration/import.md: -------------------------------------------------------------------------------- 1 | ```tokens 2 | import other 3 | ``` 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './config' 2 | export * from './convert' 3 | export * from './helpers' 4 | -------------------------------------------------------------------------------- /src/plugins/__tests__/fixtures/declaration/function.md: -------------------------------------------------------------------------------- 1 | ```tokens 2 | func addA(a: T, b: T = 0) -> T {} 3 | ``` 4 | -------------------------------------------------------------------------------- /src/plugins/__tests__/fixtures/declaration/variable.md: -------------------------------------------------------------------------------- 1 | ```tokens 2 | let x: Array = [ 3 | 42 4 | ] 5 | ``` 6 | -------------------------------------------------------------------------------- /src/plugins/__tests__/fixtures/literal/array.md: -------------------------------------------------------------------------------- 1 | ```tokens 2 | let x: Array = [ 3 | 42, 4 | 35 5 | ] 6 | ``` 7 | -------------------------------------------------------------------------------- /src/plugins/__tests__/fixtures/statement/return.md: -------------------------------------------------------------------------------- 1 | ```tokens 2 | func returnStatement() -> Number { 3 | return 0 4 | } 5 | ``` 6 | -------------------------------------------------------------------------------- /examples/workspace/Shadows.md: -------------------------------------------------------------------------------- 1 | ```tokens 2 | let small: Shadow = Shadow(x: 0, y: 2, blur: 2, radius: 0, color: primary) 3 | ``` 4 | 5 | -------------------------------------------------------------------------------- /src/plugins/__tests__/fixtures/expression/assignment.md: -------------------------------------------------------------------------------- 1 | ```tokens 2 | func assignment(a: Number) -> Number { 3 | a = 10 4 | } 5 | ``` 6 | -------------------------------------------------------------------------------- /src/plugins/__tests__/fixtures/literal/boolean.md: -------------------------------------------------------------------------------- 1 | ```tokens 2 | let trueBoolean: Boolean = true 3 | let falseBoolean: Boolean = false 4 | ``` 5 | -------------------------------------------------------------------------------- /src/plugins/__tests__/fixtures/declaration/enumeration.md: -------------------------------------------------------------------------------- 1 | ```tokens 2 | enum Foo { 3 | case value(Wrapped) 4 | case none() 5 | } 6 | ``` 7 | -------------------------------------------------------------------------------- /src/plugins/__tests__/fixtures/declaration/enumerationWithLabels.md: -------------------------------------------------------------------------------- 1 | ```tokens 2 | enum Foo { 3 | case bar(label1: Number, label2: String) 4 | } 5 | ``` 6 | -------------------------------------------------------------------------------- /src/plugins/__tests__/fixtures/literal/number.md: -------------------------------------------------------------------------------- 1 | ```tokens 2 | let positive: Number = 1 3 | let float: Number = 0.1 4 | let negative: Number = -1 5 | ``` 6 | -------------------------------------------------------------------------------- /src/plugins/__tests__/fixtures/literal/string.md: -------------------------------------------------------------------------------- 1 | ```tokens 2 | let string: String = "hello" 3 | let stringWithQuote: String = "Hello \"world\"" 4 | ``` 5 | -------------------------------------------------------------------------------- /src/plugins/__tests__/fixtures/statement/loop.md: -------------------------------------------------------------------------------- 1 | ```tokens 2 | func loopStatement() -> Number { 3 | while false { 4 | return 0 5 | } 6 | } 7 | ``` 8 | -------------------------------------------------------------------------------- /src/plugins/__tests__/fixtures/literal/color.md: -------------------------------------------------------------------------------- 1 | ```tokens 2 | let color: Color = #color(css: "pink") 3 | let hexColor: Color = #color(css: "#123456") 4 | ``` 5 | -------------------------------------------------------------------------------- /src/plugins/__tests__/fixtures/expression/identifier.md: -------------------------------------------------------------------------------- 1 | ```tokens 2 | let x: Number = 4 3 | 4 | func identifier(a: Number) -> Number { 5 | a = x 6 | } 7 | ``` 8 | -------------------------------------------------------------------------------- /src/plugins/__tests__/fixtures/statement/branch.md: -------------------------------------------------------------------------------- 1 | ```tokens 2 | func branchStatement() -> Number { 3 | if true { 4 | return 0 5 | } 6 | return 1 7 | } 8 | ``` 9 | -------------------------------------------------------------------------------- /src/plugins/__tests__/fixtures/expression/member.md: -------------------------------------------------------------------------------- 1 | ```tokens 2 | extension Foo { 3 | let primary: Color = #color(css: "#45CBFF") 4 | } 5 | 6 | let b: Color = Foo.primary 7 | ``` 8 | -------------------------------------------------------------------------------- /src/plugins/__tests__/fixtures/declaration/record.md: -------------------------------------------------------------------------------- 1 | ```tokens 2 | struct ThemedColor { 3 | let light: Color = #color(css: "white") 4 | let dark: Color = #color(css: "black") 5 | } 6 | ``` 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## OS X files 2 | .DS_Store 3 | .DS_Store? 4 | .Trashes 5 | .Spotlight-V100 6 | *.swp 7 | 8 | # build folders 9 | lib 10 | 11 | .vscode 12 | node_modules 13 | yarn-error.log 14 | -------------------------------------------------------------------------------- /src/plugins/__tests__/fixtures/declaration/namespace.md: -------------------------------------------------------------------------------- 1 | ```tokens 2 | extension Boolean { 3 | func foo(a: Boolean, b: Boolean) -> Boolean {} 4 | func bar(a: Boolean, b: Boolean) -> Boolean {} 5 | } 6 | ``` 7 | -------------------------------------------------------------------------------- /src/plugins/__tests__/fixtures/expression/functionCall.md: -------------------------------------------------------------------------------- 1 | ```tokens 2 | func colorIdentity(a: Color) -> Color { 3 | return a 4 | } 5 | let pinkWithDetour: Color = colorIdentity(a: #color(css: "pink")) 6 | ``` 7 | -------------------------------------------------------------------------------- /src/plugins/swift/convert/LogicGenerationContext.ts: -------------------------------------------------------------------------------- 1 | import { Helpers } from '../../../helpers' 2 | export type LogicGenerationContext = { 3 | isStatic: boolean 4 | isTopLevel: boolean 5 | helpers: Helpers 6 | } 7 | -------------------------------------------------------------------------------- /src/plugins/js/format.ts: -------------------------------------------------------------------------------- 1 | import snakeCase from 'lodash.snakecase' 2 | 3 | export const enumName = (name: string) => snakeCase(name).toUpperCase() 4 | export const enumCaseName = (name: string) => snakeCase(name).toUpperCase() 5 | -------------------------------------------------------------------------------- /examples/workspace/TextStyles.md: -------------------------------------------------------------------------------- 1 | ```tokens 2 | let heading1: TextStyle = TextStyle(fontFamily: Optional.value("Helvetica"), fontSize: Optional.value(28), fontWeight: FontWeight.w700, color: Optional.value(#color(css: "teal"))) 3 | ``` 4 | 5 | -------------------------------------------------------------------------------- /static/swift/Color.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | #if canImport(UIKit) 4 | import UIKit 5 | public typealias Color = UIColor 6 | #elseif canImport(AppKit) 7 | import Cocoa 8 | public typealias Color = NSColor 9 | #endif 10 | -------------------------------------------------------------------------------- /static/swift/Image.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | #if canImport(UIKit) 4 | import UIKit 5 | public typealias Image = UIImage 6 | #elseif canImport(AppKit) 7 | import Cocoa 8 | public typealias Image = NSImage 9 | #endif 10 | -------------------------------------------------------------------------------- /examples/workspace/Colors.md: -------------------------------------------------------------------------------- 1 | # Colors 2 | 3 | ```tokens 4 | let primary: Color = #color(css: "#45CBFF") 5 | ``` 6 | 7 | ```tokens 8 | let accent: Color = primary 9 | ``` 10 | 11 | ```tokens 12 | let testSaturate: Color = Color.saturate(color: accent, factor: 0.3) 13 | ``` 14 | -------------------------------------------------------------------------------- /examples/workspace/README.md: -------------------------------------------------------------------------------- 1 | # Flat Tokens 2 | 3 | This is an example workspace for testing token generation. 4 | 5 | ### Pages 6 | 7 | Colors 8 | 9 | TextStyles 10 | 11 | Shadows 12 | 13 | -------------------------------------------------------------------------------- /src/utils/sequence.ts: -------------------------------------------------------------------------------- 1 | export function compact(input: (T | false | null | undefined)[]): T[] { 2 | let output: T[] = [] 3 | 4 | for (let value of input) { 5 | if (typeof value !== 'undefined' && value !== false && value !== null) { 6 | output.push(value) 7 | } 8 | } 9 | 10 | return output 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/workspace.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | 4 | /** 5 | * Returns true if the path is a Lona workspace directory (containing a `lona.json`) 6 | */ 7 | export const isWorkspacePath = (workspacePath: string): boolean => { 8 | return fs.existsSync(path.join(workspacePath, 'lona.json')) 9 | } 10 | -------------------------------------------------------------------------------- /static/swift/Font.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | #if canImport(UIKit) 4 | import UIKit 5 | public typealias Font = UIFont 6 | public typealias FontDescriptor = UIFontDescriptor 7 | #elseif canImport(AppKit) 8 | import AppKit 9 | public typealias Font = NSFont 10 | public typealias FontDescriptor = NSFontDescriptor 11 | #endif 12 | -------------------------------------------------------------------------------- /src/logic/nodes/ReturnStatement.ts: -------------------------------------------------------------------------------- 1 | import { LogicAST as AST } from '@lona/serialization' 2 | import { Node, IExpression } from './interfaces' 3 | import { createExpressionNode } from './createNode' 4 | 5 | export class ReturnStatement extends Node { 6 | get expression(): IExpression { 7 | return createExpressionNode(this.syntaxNode.data.expression)! 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/typeHelpers.ts: -------------------------------------------------------------------------------- 1 | export function assertNever(x: never): never { 2 | throw new Error('Unknown type: ' + x['type']) 3 | } 4 | 5 | export function typeNever(x: never, reporter: (s: string) => void) { 6 | reporter('Unknown type: ' + x['type']) 7 | } 8 | 9 | export function nonNullable(value: T): value is NonNullable { 10 | return value !== null && value !== undefined 11 | } 12 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | import { isWorkspacePath } from './utils/workspace' 4 | import { load, Config } from './utils/config' 5 | 6 | export const getConfig = (workspacePath: string): Config | undefined => { 7 | const resolvedPath = path.resolve(workspacePath) 8 | 9 | return isWorkspacePath(resolvedPath) ? load(fs, resolvedPath) : undefined 10 | } 11 | -------------------------------------------------------------------------------- /src/logic/__tests__/__snapshots__/implementations.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Logic / Implementations implements the standard library 1`] = ` 4 | Array [ 5 | "Color.setHue", 6 | "Color.setSaturation", 7 | "Color.setLightness", 8 | "Color.fromHSL", 9 | "Boolean.or", 10 | "Boolean.and", 11 | "Number.range", 12 | "String.concat", 13 | "Array.at", 14 | ] 15 | `; 16 | -------------------------------------------------------------------------------- /src/plugins/index.ts: -------------------------------------------------------------------------------- 1 | import { Helpers } from '../helpers' 2 | 3 | export interface Plugin< 4 | ExpectedOptions extends { [argName: string]: any } = { 5 | [argName: string]: unknown 6 | }, 7 | T = unknown 8 | > { 9 | format: string 10 | convertWorkspace( 11 | workspacePath: string, 12 | helpers: Helpers, 13 | options: { 14 | [argName: string]: unknown 15 | } 16 | ): Promise 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "module": "CommonJS", 5 | "lib": ["ESNext"], 6 | "outDir": "./lib", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "types": ["node", "jest"], 11 | "typeRoots": ["node_modules/@types", "src/types"] 12 | }, 13 | "exclude": ["node_modules", "**/*.test.ts", "scripts", "lib"] 14 | } 15 | -------------------------------------------------------------------------------- /src/plugins/documentation/utils.ts: -------------------------------------------------------------------------------- 1 | import * as serialization from '@lona/serialization' 2 | import { nonNullable } from '../../utils/typeHelpers' 3 | 4 | export const findChildPages = (root: { 5 | children: serialization.MDXAST.Content[] 6 | }): string[] => { 7 | return root.children 8 | .map(child => { 9 | if (child.type === 'page') { 10 | return child.data.url 11 | } 12 | }) 13 | .filter(nonNullable) 14 | } 15 | -------------------------------------------------------------------------------- /src/plugins/documentation/documentationAst.ts: -------------------------------------------------------------------------------- 1 | export type ConvertedFileContents = { 2 | type: 'documentationPage' 3 | value: { 4 | mdxString: string 5 | children: Array 6 | } 7 | } 8 | 9 | export interface ConvertedFile { 10 | inputPath: string 11 | outputPath: string 12 | name: string 13 | contents: ConvertedFileContents 14 | } 15 | 16 | export interface ConvertedWorkspace { 17 | files: Array 18 | flatTokensSchemaVersion: string 19 | } 20 | -------------------------------------------------------------------------------- /src/logic/nodePath.ts: -------------------------------------------------------------------------------- 1 | export class NodePath { 2 | components: string[] = [] 3 | 4 | pushComponent(name: string) { 5 | this.components = [...this.components, name] 6 | } 7 | 8 | popComponent() { 9 | this.components = this.components.slice(0, -1) 10 | } 11 | 12 | pathString(finalComponent?: string): string { 13 | const components = 14 | typeof finalComponent === 'string' 15 | ? [...this.components, finalComponent] 16 | : this.components 17 | 18 | return components.join('.') 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/reporter.ts: -------------------------------------------------------------------------------- 1 | export type Reporter = { 2 | info(...args: any[]): void 3 | log(...args: any[]): void 4 | warn(...args: any[]): void 5 | error(...args: any[]): void 6 | } 7 | 8 | export const defaultReporter: Reporter = { 9 | info: console.info.bind(console), 10 | log: console.log.bind(console), 11 | warn: console.warn.bind(console), 12 | error: console.error.bind(console), 13 | } 14 | 15 | export const silentReporter: Reporter = { 16 | log: () => {}, 17 | info: () => {}, 18 | warn: () => {}, 19 | error: () => {}, 20 | } 21 | -------------------------------------------------------------------------------- /src/logic/implementations.ts: -------------------------------------------------------------------------------- 1 | import { FuncImplementation } from './runtime/memory' 2 | import { Decode, Encode } from './runtime/value' 3 | import Color from 'color' 4 | 5 | const implementations: { 6 | [key: string]: FuncImplementation 7 | } = { 8 | 'Color.saturate': ({ color, factor }) => { 9 | const colorString = Decode.color(color) 10 | const factorNumber = Decode.number(factor) ?? 1 11 | 12 | const result = Color(colorString) 13 | .saturate(factorNumber) 14 | .hex() 15 | 16 | return Encode.color(result).memory 17 | }, 18 | } 19 | 20 | export default implementations 21 | -------------------------------------------------------------------------------- /src/utils/uuid.ts: -------------------------------------------------------------------------------- 1 | import { v4 } from 'uuid' 2 | 3 | // Math.random()-based (RNG) 4 | // 5 | // when executing uuid in JSCore, we don't have access to Crypto, 6 | // so it fails. Instead we will fallback to a Math.random RNG. 7 | // We don't really care about enthropy so it's fine. 8 | export function rng() { 9 | var rnds = new Array(16) 10 | for (var i = 0, r; i < 16; i++) { 11 | if ((i & 0x03) === 0) r = Math.random() * 0x100000000 12 | // @ts-ignore 13 | rnds[i] = (r >>> ((i & 0x03) << 3)) & 0xff 14 | } 15 | 16 | return rnds 17 | } 18 | 19 | export const uuid = () => { 20 | return v4({ rng }) 21 | } 22 | -------------------------------------------------------------------------------- /src/plugins/swift/convert/nativeType.ts: -------------------------------------------------------------------------------- 1 | import { LogicGenerationContext } from './LogicGenerationContext' 2 | 3 | export const convertNativeType = ( 4 | typeName: string, 5 | _context: LogicGenerationContext 6 | ): string => { 7 | switch (typeName) { 8 | case 'Boolean': 9 | return 'Bool' 10 | case 'Number': 11 | return 'CGFloat' 12 | case 'WholeNumber': 13 | return 'Int' 14 | case 'String': 15 | return 'String' 16 | case 'Optional': 17 | return 'Optional' 18 | case 'URL': 19 | return 'Image' 20 | case 'Color': 21 | return 'Color' 22 | default: 23 | return typeName 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/plugins/swift/__tests__/mocks/SwiftLanguage.logic: -------------------------------------------------------------------------------- 1 | @codable() 2 | enum AccessLevelModifier { 3 | case privateModifier() 4 | case publicModifier() 5 | } 6 | 7 | @codable() 8 | enum Literal { 9 | case nil() 10 | case floatingPoint(Number) 11 | case array(Array) 12 | } 13 | 14 | @codable() 15 | struct Identifier { 16 | let id: String = "" 17 | let name: String = "" 18 | } 19 | 20 | @codable() 21 | enum Declaration { 22 | case functionDeclaration(name: String, parameters: Array) 23 | } 24 | 25 | @codable() 26 | struct ExistingType { 27 | let id: UUID = UUID() 28 | } 29 | 30 | @existingImplementation() 31 | struct UUID { 32 | let value: String = "" 33 | } 34 | -------------------------------------------------------------------------------- /src/plugins/documentation/__tests__/documentation.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import { createFs, copy } from 'buffs' 4 | import plugin from '../index' 5 | import { createHelpers } from '../../../helpers' 6 | 7 | const workspacePath = path.join(__dirname, '../../../../examples/workspace') 8 | 9 | it('converts workspace', async () => { 10 | const source = createFs() 11 | 12 | copy(fs, source, workspacePath, '/') 13 | 14 | const helpers = createHelpers(source, '/') 15 | 16 | await plugin.convertWorkspace('/', helpers, { output: '/docs.json' }) 17 | 18 | const output = JSON.parse(source.readFileSync('/docs.json', 'utf8')) 19 | 20 | expect(output).toMatchSnapshot() 21 | }) 22 | -------------------------------------------------------------------------------- /src/logic/traversal.ts: -------------------------------------------------------------------------------- 1 | import { LogicAST as AST } from '@lona/serialization' 2 | import { withOptions, IndexPath } from 'tree-visit' 3 | 4 | const { visit, find, findAll } = withOptions({ 5 | getChildren: AST.subNodes, 6 | }) 7 | 8 | // Overload to support type guard 9 | export function findNode< 10 | A extends AST.SyntaxNode['type'], 11 | T extends AST.SyntaxNode & { type: A } 12 | >( 13 | rootNode: AST.SyntaxNode, 14 | predicate: (node: AST.SyntaxNode, indexPath: IndexPath) => node is T 15 | ): T | undefined 16 | 17 | export function findNode( 18 | node: AST.SyntaxNode, 19 | predicate: (node: AST.SyntaxNode, indexPath: IndexPath) => boolean 20 | ): ReturnType { 21 | return find(node, predicate) 22 | } 23 | 24 | export const findNodes = findAll 25 | 26 | export { visit } 27 | -------------------------------------------------------------------------------- /static/swift/Shadow.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | #if canImport(UIKit) 4 | import UIKit 5 | #elseif canImport(AppKit) 6 | import AppKit 7 | #endif 8 | 9 | public struct Shadow { 10 | let color: Color 11 | let offset: CGSize 12 | let blur: CGFloat 13 | 14 | // radius is not supported on swift 15 | public init(x: CGFloat? = nil, y: CGFloat? = nil, blur: CGFloat? = nil, radius: CGFloat? = nil, color: Color? = nil) { 16 | self.color = color ?? #colorLiteral(red: 0, green: 0, blue: 0, alpha: 1) 17 | self.offset = CGSize(width: x ?? 0, height: y ?? 0) 18 | self.blur = blur ?? 0 19 | } 20 | 21 | func apply(to layer: CALayer) { 22 | layer.shadowColor = color.cgColor 23 | layer.shadowOffset = offset 24 | layer.shadowRadius = blur 25 | layer.shadowOpacity = 1 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/logic/multiMap.ts: -------------------------------------------------------------------------------- 1 | import { isDeepStrictEqual } from 'util' 2 | 3 | export class MultiMap { 4 | dict: { [key: string]: Value[] } = {} 5 | 6 | serializeKey: (key: Key) => string 7 | 8 | constructor(serializeKey?: (key: Key) => string) { 9 | this.serializeKey = serializeKey || (value => JSON.stringify(value)) 10 | } 11 | 12 | get(key: Key): Value | undefined { 13 | const existing = this.dict[this.serializeKey(key)] 14 | return existing ? existing[0] : undefined 15 | } 16 | 17 | set(key: Key, value: Value) { 18 | const existing = this.dict[this.serializeKey(key)] || [] 19 | 20 | if ( 21 | !existing.find(existingValue => isDeepStrictEqual(existingValue, value)) 22 | ) { 23 | existing.push(value) 24 | this.dict[this.serializeKey(key)] = existing 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/logic/scopeStack.ts: -------------------------------------------------------------------------------- 1 | export default class ScopeStack { 2 | public scopes: { [key: string]: V }[] = [{}] 3 | 4 | public get(key: K): V | void { 5 | let scope = [...this.scopes].reverse().find(scope => key in scope) 6 | return scope ? scope[key] : undefined 7 | } 8 | 9 | public set(key: K, value: V) { 10 | this.scopes[this.scopes.length - 1][key] = value 11 | } 12 | 13 | public push() { 14 | this.scopes.push({}) 15 | } 16 | 17 | public pop(): { [key: string]: V } | undefined { 18 | return this.scopes.pop() 19 | } 20 | 21 | public flattened(): { [key: string]: V } { 22 | return Object.assign({}, ...this.scopes) 23 | } 24 | 25 | public copy() { 26 | const stack = new ScopeStack() 27 | stack.scopes = this.scopes.map(scope => ({ ...scope })) 28 | return stack 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /static/logic/Shadow.logic: -------------------------------------------------------------------------------- 1 | import Prelude 2 | 3 | import Color 4 | 5 | /* 6 | * I> Press enter to create a new shadow. 7 | * 8 | * # Shadow 9 | * 10 | * A drop shadow definition. 11 | */ 12 | struct Shadow { 13 | /* 14 | * # x 15 | * 16 | * The horizontal offset of the shadow. 17 | */ 18 | let x: Number = 0 19 | /* 20 | * # y 21 | * 22 | * The vertical of the shadow. 23 | */ 24 | let y: Number = 0 25 | /* 26 | * # Blur 27 | * 28 | * The blur radius of the shadow. 29 | */ 30 | let blur: Number = 0 31 | /* 32 | * W> This property is not supported on iOS or Android. 33 | * 34 | * # Radius 35 | * 36 | * The spread radius of the shadow. 37 | */ 38 | let radius: Number = 0 39 | /* 40 | * # Color 41 | * 42 | * The shadow color. 43 | */ 44 | let color: Color = #color(css: "black") 45 | } 46 | -------------------------------------------------------------------------------- /src/utils/config.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import { IFS } from 'buffs' 3 | 4 | type LonaJSON = { 5 | ignore: string[] 6 | format: { 7 | [key: string]: { 8 | [key: string]: any 9 | } 10 | } 11 | [key: string]: unknown 12 | } 13 | 14 | export type Config = { 15 | version: string 16 | } & LonaJSON 17 | 18 | /** 19 | * Load the workspace config file, `lona.json`. 20 | */ 21 | export function load(fs: IFS, workspacePath: string): Config { 22 | // TODO: Validate lona.json 23 | const lonaFile = JSON.parse( 24 | fs.readFileSync(path.join(workspacePath, 'lona.json'), 'utf8') 25 | ) as LonaJSON 26 | 27 | if (!lonaFile.ignore) { 28 | lonaFile.ignore = ['**/node_modules/**', '**/.git/**'] 29 | } 30 | 31 | return { 32 | ...lonaFile, 33 | workspacePath, 34 | version: require('../../package.json').version, 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/plugins/js/utils.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | 3 | export function resolveImportPath( 4 | outputPath: string, 5 | from: string, 6 | to: string 7 | ) { 8 | const relativePath = path 9 | .relative(path.dirname(path.join(outputPath, from)), to) 10 | .replace(path.extname(to), '') 11 | 12 | return relativePath.indexOf('.') === 0 ? relativePath : `./${relativePath}` 13 | } 14 | 15 | export function generateTranspiledImport( 16 | outputPath: string, 17 | importPath: string, 18 | index: number 19 | ) { 20 | return `var __lona_import_${index} = require("${resolveImportPath( 21 | outputPath, 22 | 'index.js', 23 | importPath 24 | )}"); 25 | Object.keys(__lona_import_${index}).forEach(function (key) { 26 | Object.defineProperty(module.exports, key, { 27 | enumerable: true, 28 | get: function get() { 29 | return __lona_import_${index}[key]; 30 | } 31 | }); 32 | })` 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yaml: -------------------------------------------------------------------------------- 1 | name: 'Build and Test' 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v1 10 | - uses: actions/setup-node@v1 11 | with: 12 | node-version: '12.x' 13 | 14 | - name: Get yarn cache directory path 15 | id: yarn-cache-dir-path 16 | run: echo "::set-output name=dir::$(yarn cache dir)" 17 | 18 | - uses: actions/cache@v1 19 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 20 | with: 21 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 22 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 23 | - run: yarn install --frozen-lockfile 24 | 25 | # build to check everything is ok 26 | - run: yarn build 27 | 28 | # run all the tests 29 | - run: yarn test 30 | -------------------------------------------------------------------------------- /src/utils/printer.ts: -------------------------------------------------------------------------------- 1 | import { doc, Doc } from 'prettier' 2 | export { Doc } from 'prettier' 3 | 4 | export const builders = doc.builders 5 | 6 | export function group(x: Doc[] | Doc): Doc { 7 | if (Array.isArray(x)) { 8 | return builders.group(builders.concat(x)) 9 | } 10 | return builders.group(x) 11 | } 12 | export function indent(x: Doc[] | Doc): Doc { 13 | if (Array.isArray(x)) { 14 | return builders.indent(builders.concat(x)) 15 | } 16 | return builders.indent(x) 17 | } 18 | 19 | export function join(x: Doc[], separator: Doc | Doc[]): Doc { 20 | if (Array.isArray(separator)) { 21 | return builders.join(builders.concat(separator), x) 22 | } 23 | return builders.join(separator, x) 24 | } 25 | 26 | export function prefixAll(x: Doc[], prefix: Doc): Doc[] { 27 | return x.map(y => builders.concat([prefix, y])) 28 | } 29 | 30 | export function print(document: Doc, options: doc.printer.Options) { 31 | return doc.printer.printDocToString(document, options).formatted 32 | } 33 | -------------------------------------------------------------------------------- /src/logic/nodes/NamespaceDeclaration.ts: -------------------------------------------------------------------------------- 1 | import { LogicAST as AST } from '@lona/serialization' 2 | import { IDeclaration, Node } from './interfaces' 3 | import NamespaceVisitor from '../namespaceVisitor' 4 | import { ScopeVisitor } from '../scopeVisitor' 5 | 6 | export class NamespaceDeclaration extends Node 7 | implements IDeclaration { 8 | namespaceEnter(visitor: NamespaceVisitor): void { 9 | const { 10 | name: { name, id }, 11 | } = this.syntaxNode.data 12 | 13 | visitor.pushPathComponent(name) 14 | } 15 | 16 | namespaceLeave(visitor: NamespaceVisitor): void { 17 | const { 18 | name: { name, id }, 19 | } = this.syntaxNode.data 20 | 21 | visitor.popPathComponent() 22 | } 23 | 24 | scopeEnter(visitor: ScopeVisitor): void { 25 | const { 26 | name: { name }, 27 | } = this.syntaxNode.data 28 | 29 | visitor.pushNamespace(name) 30 | } 31 | 32 | scopeLeave(visitor: ScopeVisitor): void { 33 | visitor.popNamespace() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { IFS } from 'buffs' 2 | import { createModule, ModuleContext } from './logic/module' 3 | import { Config, load } from './utils/config' 4 | import { defaultReporter, Reporter } from './utils/reporter' 5 | 6 | /** 7 | * Helpers passed to every plugins. They contain: 8 | * 9 | * - methods abstracting the file system 10 | * - a centralized log reporter 11 | * - the workspace's configuration, etc. 12 | */ 13 | export type Helpers = { 14 | fs: IFS 15 | reporter: Reporter 16 | config: Config 17 | module: ModuleContext 18 | workspacePath: string 19 | } 20 | 21 | export function createHelpers( 22 | fs: IFS, 23 | workspacePath: string, 24 | options: { 25 | outputPath?: unknown 26 | reporter?: Reporter 27 | } = {} 28 | ): Helpers { 29 | const { reporter = defaultReporter } = options 30 | 31 | const helpers: Helpers = { 32 | fs, 33 | reporter, 34 | config: load(fs, workspacePath), 35 | module: createModule(fs, workspacePath), 36 | workspacePath, 37 | } 38 | 39 | return helpers 40 | } 41 | -------------------------------------------------------------------------------- /src/convert.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import * as fs from 'fs' 3 | 4 | import { Plugin } from './plugins' 5 | import { createHelpers } from './helpers' 6 | import { findPlugin } from './utils/plugin' 7 | import { isWorkspacePath } from './utils/workspace' 8 | 9 | export async function convert( 10 | workspacePath: string, 11 | plugin: string | Plugin, 12 | options: { 13 | output?: string 14 | [argName: string]: unknown 15 | } = {} 16 | ): Promise { 17 | const resolvedPath = path.resolve(workspacePath) 18 | 19 | if (!isWorkspacePath(resolvedPath)) { 20 | throw new Error( 21 | 'The path provided is not a Lona Workspace. A workspace must contain a `lona.json` file.' 22 | ) 23 | } 24 | 25 | const helpers = createHelpers(fs, resolvedPath, { 26 | outputPath: options.output, 27 | }) 28 | 29 | const pluginFunction = 30 | typeof plugin === 'string' ? findPlugin(plugin) : plugin 31 | 32 | return pluginFunction.convertWorkspace(workspacePath, helpers, { 33 | ...((helpers.config.format || {})[pluginFunction.format] || {}), 34 | ...(options || {}), 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /src/logic/nodes/LiteralExpression.ts: -------------------------------------------------------------------------------- 1 | import { LogicAST as AST } from '@lona/serialization' 2 | import { ScopeVisitor } from '../scopeVisitor' 3 | import { TypeCheckerVisitor } from '../typeChecker' 4 | import { IExpression, Node, ILiteral } from './interfaces' 5 | import { EvaluationVisitor } from '../evaluationVisitor' 6 | import { createLiteralNode } from './createNode' 7 | 8 | export class LiteralExpression extends Node 9 | implements IExpression { 10 | get literal(): ILiteral { 11 | return createLiteralNode(this.syntaxNode.data.literal)! 12 | } 13 | 14 | scopeEnter(visitor: ScopeVisitor): void {} 15 | 16 | scopeLeave(visitor: ScopeVisitor): void {} 17 | 18 | typeCheckerEnter(visitor: TypeCheckerVisitor): void {} 19 | 20 | typeCheckerLeave(visitor: TypeCheckerVisitor): void { 21 | const { id, literal } = this.syntaxNode.data 22 | 23 | visitor.setType(id, visitor.getType(literal.data.id)) 24 | } 25 | 26 | evaluationEnter(visitor: EvaluationVisitor) { 27 | const { id, literal } = this.syntaxNode.data 28 | 29 | visitor.add(id, { 30 | label: 'Literal expression', 31 | dependencies: [literal.data.id], 32 | f: values => values[0], 33 | }) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/logic/runtime/memory.ts: -------------------------------------------------------------------------------- 1 | import { StaticType } from '../staticType' 2 | import { Value } from './value' 3 | 4 | export type RecordMemory = { [key: string]: Value } 5 | 6 | export type DefaultArguments = { 7 | [key: string]: [StaticType, Value | void] 8 | } 9 | 10 | export type FuncImplementation = (args: RecordMemory) => Memory 11 | 12 | export type FuncMemory = { 13 | f: FuncImplementation 14 | defaultArguments: DefaultArguments 15 | } 16 | 17 | export type Memory = 18 | | { type: 'unit' } 19 | | { type: 'bool'; value: boolean } 20 | | { type: 'number'; value: number } 21 | | { type: 'string'; value: string } 22 | | { type: 'array'; value: Value[] } 23 | | { type: 'enum'; value: string; data: Value[] } 24 | | { type: 'record'; value: RecordMemory } 25 | | { 26 | type: 'function' 27 | value: FuncMemory 28 | } 29 | 30 | export const unit = (): Memory => ({ type: 'unit' }) 31 | 32 | export const bool = (value: boolean): Memory => ({ type: 'bool', value }) 33 | 34 | export const number = (value: number): Memory => ({ type: 'number', value }) 35 | 36 | export const string = (value: string): Memory => ({ type: 'string', value }) 37 | 38 | export const array = (value: Value[]): Memory => ({ type: 'array', value }) 39 | -------------------------------------------------------------------------------- /src/plugins/swift/__tests__/typeGeneration.ts: -------------------------------------------------------------------------------- 1 | import { createFs } from 'buffs' 2 | import { execSync } from 'child_process' 3 | import fs from 'fs' 4 | import os from 'os' 5 | import path from 'path' 6 | import { createHelpers } from '../../../helpers' 7 | import plugin from '../index' 8 | 9 | it('converts Swift language', async () => { 10 | const source = createFs({ 11 | 'lona.json': JSON.stringify({}), 12 | 'SwiftLanguage.logic': fs.readFileSync( 13 | path.join(__dirname, './mocks/SwiftLanguage.logic'), 14 | 'utf8' 15 | ), 16 | }) 17 | 18 | const helpers = createHelpers(source, '/') 19 | 20 | await plugin.convertWorkspace('/', helpers, { output: '/output' }) 21 | 22 | const colors = source.readFileSync('/output/SwiftLanguage.swift', 'utf8') 23 | 24 | expect(colors).toMatchSnapshot() 25 | 26 | const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lona-')) 27 | 28 | fs.writeFileSync(path.join(tmp, 'Test.swift'), colors, 'utf8') 29 | 30 | let swiftc: string | undefined 31 | 32 | try { 33 | swiftc = execSync(`which swiftc`) 34 | .toString() 35 | .trim() 36 | } catch { 37 | // No swiftc available 38 | } 39 | 40 | if (swiftc) { 41 | execSync(`${swiftc} Test.swift`, { cwd: tmp }) 42 | } 43 | 44 | fs.rmdirSync(tmp, { recursive: true }) 45 | }) 46 | -------------------------------------------------------------------------------- /src/utils/plugin.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from '../plugins' 2 | 3 | /** 4 | * Support requiring both CommonJS `module.exports` and ES5 `export` modules 5 | * 6 | * @param path The path to `require` 7 | */ 8 | const requireInterop = (path: string): any => { 9 | const obj = require(path) 10 | return obj && obj.__esModule && obj['default'] ? obj['default'] : obj 11 | } 12 | 13 | /** Look for a plugin in 14 | * - FORMAT is FORMAT starts with a `.` or a `/` (heuristic for a path) 15 | * - node_modules/@lona/compiler-FORMAT 16 | * - node_modules/lona-compiler-FORMAT 17 | * - ../plugins/FORMAT 18 | */ 19 | export const findPlugin = ( 20 | format: string 21 | ): Plugin => { 22 | try { 23 | if (format.startsWith('.') || format.startsWith('/')) { 24 | return requireInterop(format) 25 | } 26 | throw new Error('not a path') 27 | } catch (err) { 28 | try { 29 | return requireInterop(`@lona/compiler-${format}`) 30 | } catch (err) { 31 | try { 32 | return requireInterop(`lona-compiler-${format}`) 33 | } catch (err) { 34 | try { 35 | return requireInterop(`../plugins/${format}`) 36 | } catch (err) { 37 | console.error(err) 38 | throw new Error(`Could not find plugin ${format}`) 39 | } 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/logic/__tests__/unify.ts: -------------------------------------------------------------------------------- 1 | import * as Serialization from '@lona/serialization' 2 | import isEqual from 'lodash.isequal' 3 | import { createNamespace } from '../namespace' 4 | import { createScopeContext } from '../scope' 5 | import { StaticType } from '../staticType' 6 | import { createUnificationContext } from '../typeChecker' 7 | import { substitute, unify } from '../typeUnifier' 8 | import { silentReporter } from '../../utils/reporter' 9 | 10 | describe('Logic / Scope', () => { 11 | it('finds identifier expression references', () => { 12 | const file = `struct Array {} 13 | 14 | let x: Array = []` 15 | 16 | let rootNode = Serialization.decodeLogic(file) 17 | 18 | let namespace = createNamespace(rootNode) 19 | 20 | let scope = createScopeContext(rootNode, namespace) 21 | 22 | let unification = createUnificationContext(rootNode, scope, silentReporter) 23 | 24 | const substitution = unify(unification.constraints, silentReporter) 25 | 26 | let type: StaticType = { type: 'variable', value: '?5' } 27 | 28 | let result = substitute(substitution, type) 29 | 30 | while (!isEqual(result, substitute(substitution, result))) { 31 | result = substitute(substitution, result) 32 | } 33 | 34 | expect(result).toEqual({ 35 | type: 'constructor', 36 | name: 'Number', 37 | parameters: [], 38 | }) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /static/logic/Color.logic: -------------------------------------------------------------------------------- 1 | import Prelude 2 | 3 | /* 4 | * # Color 5 | * 6 | * A color value. Colors can be defined using CSS color codes. 7 | * 8 | * ## Example 9 | * 10 | * We might declare a color variable, `ocean` to use throughout our design system to represent the hex code `#69D2E7`: 11 | * 12 | * ```logic 13 | * 14 | * 15 | * 16 | * ``` 17 | */ 18 | struct Color { 19 | let value: String = "" 20 | } 21 | 22 | extension Color { 23 | func setHue(color: Color, hue: Number) -> Color {} 24 | /* 25 | * # Set Saturation 26 | * 27 | * Adjust the saturation of a color. 28 | * 29 | * @param color - # Color 30 | * 31 | * The base color to adjust. 32 | */ 33 | func setSaturation(color: Color, saturation: Number) -> Color {} 34 | func setLightness(color: Color, lightness: Number) -> Color {} 35 | func fromHSL(hue: Number, saturation: Number, lightness: Number) -> Color {} 36 | /* 37 | * # Saturate 38 | * 39 | * Adjust color saturation. 40 | * 41 | * @param color - # Color 42 | * 43 | * The base color to adjust. 44 | * @param factor - # Factor 45 | * 46 | * This value will be multiplied with the current saturation value. 47 | */ 48 | func saturate(color: Color, factor: Number) -> Color {} 49 | } 50 | -------------------------------------------------------------------------------- /src/logic/nodes/typeAnnotations.ts: -------------------------------------------------------------------------------- 1 | import { LogicAST as AST } from '@lona/serialization' 2 | import { ITypeAnnotation, Node } from './interfaces' 3 | import { ScopeVisitor } from '../scopeVisitor' 4 | import { EnterReturnValue } from 'buffs' 5 | 6 | export class IdentifierTypeAnnotation 7 | extends Node 8 | implements ITypeAnnotation { 9 | scopeEnter(visitor: ScopeVisitor): EnterReturnValue { 10 | const { genericArguments, id, identifier } = this.syntaxNode.data 11 | 12 | genericArguments.forEach(arg => { 13 | visitor.traverse(arg) 14 | }) 15 | 16 | if (identifier.isPlaceholder) return 17 | 18 | const found = visitor.findTypeIdentifierReference(identifier.string) 19 | 20 | if (found) { 21 | visitor.scope.typeIdentifierToPattern[id] = found 22 | } else { 23 | visitor.reporter.warn( 24 | `No type identifier: ${identifier.string}`, 25 | visitor.scope.valueNames 26 | ) 27 | visitor.scope.undefinedTypeIdentifiers.add(id) 28 | } 29 | 30 | return 'skip' 31 | } 32 | 33 | scopeLeave(visitor: ScopeVisitor): void {} 34 | } 35 | 36 | export class FunctionTypeAnnotation extends Node 37 | implements ITypeAnnotation { 38 | scopeEnter(visitor: ScopeVisitor): EnterReturnValue { 39 | return 'skip' 40 | } 41 | 42 | scopeLeave(visitor: ScopeVisitor): void {} 43 | } 44 | -------------------------------------------------------------------------------- /src/plugins/js/astUtils.ts: -------------------------------------------------------------------------------- 1 | import * as JSAST from './jsAst' 2 | 3 | export function convertObject(json: unknown): JSAST.JSNode { 4 | switch (typeof json) { 5 | case 'object': 6 | if (json === null) { 7 | return { type: 'Literal', data: { type: 'Null', data: undefined } } 8 | } else if (json instanceof Array) { 9 | return { 10 | type: 'Literal', 11 | data: { type: 'Array', data: json.map(convertObject) }, 12 | } 13 | } else { 14 | const properties: JSAST.JSNode[] = Object.entries(json).map( 15 | ([key, value]): JSAST.JSNode => ({ 16 | type: 'Property', 17 | data: { 18 | key: { type: 'Identifier', data: [key] }, 19 | value: convertObject(value), 20 | }, 21 | }) 22 | ) 23 | 24 | return { type: 'Literal', data: { type: 'Object', data: properties } } 25 | } 26 | case 'boolean': 27 | return { type: 'Literal', data: { type: 'Boolean', data: json } } 28 | case 'number': 29 | return { type: 'Literal', data: { type: 'Number', data: json } } 30 | case 'string': 31 | return { type: 'Literal', data: { type: 'String', data: json } } 32 | case 'undefined': 33 | return { type: 'Literal', data: { type: 'Undefined', data: json } } 34 | case 'function': 35 | case 'symbol': 36 | case 'bigint': 37 | default: 38 | throw new Error('Not supported') 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/logic/namespaceVisitor.ts: -------------------------------------------------------------------------------- 1 | import { LogicAST as AST } from '@lona/serialization' 2 | import { NodePath } from './nodePath' 3 | import { createDeclarationNode } from './nodes/createNode' 4 | import { Namespace, UUID } from './namespace' 5 | import { visit } from './traversal' 6 | 7 | export default class NamespaceVisitor { 8 | namespace: Namespace 9 | currentPath = new NodePath() 10 | 11 | constructor(namespace: Namespace) { 12 | this.namespace = namespace 13 | } 14 | 15 | pushPathComponent(name: string) { 16 | this.currentPath.pushComponent(name) 17 | } 18 | 19 | popPathComponent() { 20 | this.currentPath.popComponent() 21 | } 22 | 23 | declareValue(name: string, value: UUID) { 24 | const path = this.currentPath.pathString(name) 25 | 26 | if (this.namespace.values[path]) { 27 | throw new Error(`Value already declared: ${path}`) 28 | } 29 | 30 | this.namespace.values[path] = value 31 | } 32 | 33 | declareType(name: string, type: UUID) { 34 | const path = this.currentPath.pathString(name) 35 | 36 | if (this.namespace.types[path]) { 37 | throw new Error(`Type already declared: ${path}`) 38 | } 39 | 40 | this.namespace.types[path] = type 41 | } 42 | 43 | traverse(rootNode: AST.SyntaxNode) { 44 | visit(rootNode, { 45 | onEnter: node => createDeclarationNode(node)?.namespaceEnter(this), 46 | onLeave: node => createDeclarationNode(node)?.namespaceLeave(this), 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/plugins/tokens/convert.ts: -------------------------------------------------------------------------------- 1 | import { LogicAST as AST } from '@lona/serialization' 2 | import { Helpers } from '../../helpers' 3 | import { Token } from './tokensAst' 4 | import * as TokenValue from './tokenValue' 5 | import { nonNullable } from '../../utils/typeHelpers' 6 | 7 | export const convertDeclaration = ( 8 | declaration: AST.Declaration, 9 | helpers: Helpers 10 | ): Token | undefined => { 11 | if (declaration.type !== 'variable' || !declaration.data.initializer) { 12 | return undefined 13 | } 14 | const logicValue = helpers.module.evaluationContext.evaluate( 15 | declaration.data.initializer.data.id 16 | ) 17 | const tokenValue = TokenValue.create(logicValue) 18 | 19 | if (!tokenValue) { 20 | return undefined 21 | } 22 | 23 | return { qualifiedName: [declaration.data.name.name], value: tokenValue } 24 | } 25 | 26 | export const convert = (node: AST.SyntaxNode, helpers: Helpers): Token[] => { 27 | let declarations: AST.Declaration[] 28 | 29 | if ('type' in node && node.type === 'program') { 30 | declarations = node.data.block 31 | .map(x => (x.type === 'declaration' ? x.data.content : undefined)) 32 | .filter(nonNullable) 33 | } else if ('type' in node && node.type === 'topLevelDeclarations') { 34 | declarations = node.data.declarations 35 | } else { 36 | helpers.reporter.warn('Unhandled top-level syntaxNode type') 37 | return [] 38 | } 39 | 40 | return declarations 41 | .map(x => convertDeclaration(x, helpers)) 42 | .filter(nonNullable) 43 | } 44 | -------------------------------------------------------------------------------- /src/plugins/tokens/tokensAst.ts: -------------------------------------------------------------------------------- 1 | export type FontWeight = 2 | | '100' 3 | | '200' 4 | | '300' 5 | | '400' 6 | | '500' 7 | | '600' 8 | | '700' 9 | | '800' 10 | | '900' 11 | 12 | export interface ColorValue { 13 | css: string 14 | } 15 | 16 | export interface TextStyleValue { 17 | fontName?: string 18 | fontFamily?: string 19 | fontWeight: FontWeight 20 | fontSize?: number 21 | lineHeight?: number 22 | letterSpacing?: number 23 | color?: ColorValue 24 | } 25 | 26 | export interface ShadowValue { 27 | x: number 28 | y: number 29 | blur: number 30 | radius: number 31 | color: ColorValue 32 | } 33 | 34 | export type ColorTokenValue = { 35 | type: 'color' 36 | value: ColorValue 37 | } 38 | 39 | export type ShadowTokenValue = { 40 | type: 'shadow' 41 | value: ShadowValue 42 | } 43 | 44 | export type TextStyleTokenValue = { 45 | type: 'textStyle' 46 | value: TextStyleValue 47 | } 48 | 49 | export type TokenValue = 50 | | ColorTokenValue 51 | | ShadowTokenValue 52 | | TextStyleTokenValue 53 | 54 | export type Token = { 55 | qualifiedName: Array 56 | value: TokenValue 57 | } 58 | 59 | export type ConvertedFileContents = { 60 | type: 'flatTokens' 61 | value: Array 62 | } 63 | 64 | export interface ConvertedFile { 65 | inputPath: string 66 | outputPath: string 67 | name: string 68 | contents: ConvertedFileContents 69 | } 70 | 71 | export interface ConvertedWorkspace { 72 | files: Array 73 | flatTokensSchemaVersion: string 74 | } 75 | -------------------------------------------------------------------------------- /src/logic/__tests__/implementations.ts: -------------------------------------------------------------------------------- 1 | import { createFs } from 'buffs' 2 | import { createModule } from '../module' 3 | import { RecordMemory } from '../runtime/memory' 4 | import { Encode } from '../runtime/value' 5 | 6 | describe('Logic / Implementations', () => { 7 | it('implements the standard library', () => { 8 | const source = createFs({ 'lona.json': JSON.stringify({}) }) 9 | const module = createModule(source, '/') 10 | 11 | // Detect missing functions in the global namespace 12 | const missingImplementations = Object.entries( 13 | module.namespace.values 14 | ).flatMap(([qualifiedNameString, id]) => { 15 | const value = module.evaluationContext.evaluate(id) 16 | 17 | if (!value) { 18 | throw new Error(`Problem evaluating ${qualifiedNameString}`) 19 | } 20 | 21 | // Find all functions 22 | if (value.memory.type === 'function') { 23 | const { defaultArguments, f } = value.memory.value 24 | 25 | const args: RecordMemory = Object.fromEntries( 26 | Object.entries(defaultArguments).map(([name, [_, val]]) => { 27 | return [name, typeof val === 'undefined' ? Encode.unit() : val] 28 | }) 29 | ) 30 | 31 | // Call each function to check if it exists 32 | const result = f(args) 33 | 34 | // A function that returns unit most likely doesn't exist 35 | if (result.type === 'unit') { 36 | return [qualifiedNameString] 37 | } 38 | } 39 | 40 | return [] 41 | }) 42 | 43 | // Assert missing implementations. This should be an empty array before committing. 44 | expect(missingImplementations).toMatchSnapshot() 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /src/logic/evaluationVisitor.ts: -------------------------------------------------------------------------------- 1 | import { LogicAST as AST } from '@lona/serialization' 2 | import { Value } from './runtime/value' 3 | import { Scope } from './scope' 4 | import { TypeChecker } from './typeChecker' 5 | import { Substitution, substitute } from './typeUnifier' 6 | import { EvaluationContext, Thunk } from './evaluation' 7 | import { UUID, Namespace } from './namespace' 8 | import { StaticType } from './staticType' 9 | import { Reporter } from '../utils/reporter' 10 | 11 | export class EvaluationVisitor { 12 | evaluation: EvaluationContext 13 | rootNode: AST.SyntaxNode 14 | scope: Scope 15 | reporter: Reporter 16 | typeChecker: TypeChecker 17 | substitution: Substitution 18 | namespace: Namespace 19 | 20 | constructor( 21 | rootNode: AST.SyntaxNode, 22 | namespace: Namespace, 23 | scope: Scope, 24 | typeChecker: TypeChecker, 25 | substitution: Substitution, 26 | reporter: Reporter 27 | ) { 28 | this.evaluation = new EvaluationContext(reporter) 29 | this.rootNode = rootNode 30 | this.namespace = namespace 31 | this.scope = scope 32 | this.reporter = reporter 33 | this.typeChecker = typeChecker 34 | this.substitution = substitution 35 | } 36 | 37 | add(uuid: UUID, thunk: Thunk) { 38 | this.evaluation.add(uuid, thunk) 39 | } 40 | 41 | addValue(uuid: UUID, value: Value) { 42 | this.evaluation.addValue(uuid, value) 43 | } 44 | 45 | resolveType = (uuid: UUID): StaticType | undefined => { 46 | const { typeChecker, substitution, reporter } = this 47 | 48 | let type = typeChecker.nodes[uuid] 49 | 50 | if (!type) { 51 | reporter.error(`Unknown type ${uuid}`) 52 | return 53 | } 54 | 55 | return substitute(substitution, type) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/plugins/tokens/__tests__/tokens.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import { createFs, copy } from 'buffs' 4 | import { createHelpers } from '../../../helpers' 5 | import plugin from '../index' 6 | 7 | const workspacePath = path.join(__dirname, '../../../../examples/workspace') 8 | 9 | const tokensBlock = (string: string) => '```tokens\n' + string + '\n```\n' 10 | 11 | // TODO: Test more kinds of tokens 12 | describe('Tokens', () => { 13 | it('generates tokens', async () => { 14 | const source = createFs({ 15 | 'lona.json': JSON.stringify({}), 16 | 'Colors.md': tokensBlock(`let color: Color = #color(css: "pink")`), 17 | }) 18 | 19 | const helpers = createHelpers(source, '/') 20 | const converted = await plugin.convertWorkspace('/', helpers, {}) 21 | 22 | expect(converted).toMatchSnapshot() 23 | }) 24 | 25 | it('generates tokens with function call', async () => { 26 | const source = createFs({ 27 | 'lona.json': JSON.stringify({}), 28 | 'Colors.md': tokensBlock( 29 | `let testSaturate: Color = Color.saturate(color: #color(css: "pink"), factor: 0.3)` 30 | ), 31 | }) 32 | 33 | const helpers = createHelpers(source, '/') 34 | const converted = await plugin.convertWorkspace('/', helpers, {}) 35 | 36 | expect(converted).toMatchSnapshot() 37 | }) 38 | 39 | it('converts workspace', async () => { 40 | const source = createFs() 41 | 42 | copy(fs, source, workspacePath, '/') 43 | 44 | const helpers = createHelpers(source, '/') 45 | 46 | await plugin.convertWorkspace('/', helpers, { output: '/tokens.json' }) 47 | 48 | const output = JSON.parse(source.readFileSync('/tokens.json', 'utf8')) 49 | 50 | expect(output).toMatchSnapshot() 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /src/logic/staticType.ts: -------------------------------------------------------------------------------- 1 | export type FunctionArgument = { 2 | label?: string 3 | type: StaticType 4 | } 5 | 6 | export type Variable = { 7 | type: 'variable' 8 | value: string 9 | } 10 | 11 | export type Constructor = { 12 | type: 'constructor' 13 | name: string 14 | parameters: StaticType[] 15 | } 16 | 17 | export type Generic = { 18 | type: 'generic' 19 | name: string 20 | } 21 | 22 | export type Function = { 23 | type: 'function' 24 | arguments: FunctionArgument[] 25 | returnType: StaticType 26 | } 27 | 28 | export type StaticType = Variable | Constructor | Generic | Function 29 | 30 | export const unit: Constructor = { 31 | type: 'constructor', 32 | name: 'Void', 33 | parameters: [], 34 | } 35 | 36 | export const bool: Constructor = { 37 | type: 'constructor', 38 | name: 'Boolean', 39 | parameters: [], 40 | } 41 | 42 | export const number: Constructor = { 43 | type: 'constructor', 44 | name: 'Number', 45 | parameters: [], 46 | } 47 | 48 | export const string: Constructor = { 49 | type: 'constructor', 50 | name: 'String', 51 | parameters: [], 52 | } 53 | 54 | export const color: Constructor = { 55 | type: 'constructor', 56 | name: 'Color', 57 | parameters: [], 58 | } 59 | 60 | export const shadow: Constructor = { 61 | type: 'constructor', 62 | name: 'Shadow', 63 | parameters: [], 64 | } 65 | 66 | export const textStyle: Constructor = { 67 | type: 'constructor', 68 | name: 'TextStyle', 69 | parameters: [], 70 | } 71 | 72 | export const optional = (type: StaticType): Constructor => ({ 73 | type: 'constructor', 74 | name: 'Optional', 75 | parameters: [type], 76 | }) 77 | 78 | export const array = (typeUnification: StaticType): Constructor => ({ 79 | type: 'constructor', 80 | name: 'Array', 81 | parameters: [typeUnification], 82 | }) 83 | -------------------------------------------------------------------------------- /src/logic/namespace.ts: -------------------------------------------------------------------------------- 1 | import { LogicAST as AST } from '@lona/serialization' 2 | import NamespaceVisitor from './namespaceVisitor' 3 | 4 | export type UUID = string 5 | 6 | export type Namespace = { 7 | values: { [key: string]: UUID } 8 | types: { [key: string]: UUID } 9 | } 10 | 11 | export const builtInTypeConstructorNames: Set = new Set([ 12 | 'Boolean', 13 | 'Number', 14 | 'String', 15 | 'Array', 16 | 'Color', 17 | ]) 18 | 19 | /** 20 | * Merge namespaces, throwing an error in the case of collisions 21 | */ 22 | export function mergeNamespaces(namespaces: Namespace[]): Namespace { 23 | return namespaces.reduce((result, namespace) => { 24 | Object.entries(namespace.values).forEach(([key, value]) => { 25 | if (key in result.values) { 26 | throw new Error(`Namespace error: value ${key} declared more than once`) 27 | } 28 | 29 | result.values[key] = value 30 | }) 31 | 32 | Object.entries(namespace.types).forEach(([key, type]) => { 33 | if (key in result.types) { 34 | throw new Error(`Namespace error: type ${key} declared more than once`) 35 | } 36 | 37 | result.types[key] = type 38 | }) 39 | 40 | return result 41 | }, createNamespace()) 42 | } 43 | 44 | /** 45 | * Copy a namespace 46 | */ 47 | export function copy(namespace: Namespace): Namespace { 48 | return { 49 | values: { ...namespace.values }, 50 | types: { ...namespace.types }, 51 | } 52 | } 53 | 54 | /** 55 | * Build the global namespace by visiting each node. 56 | */ 57 | export function createNamespace(topLevelNode?: AST.SyntaxNode): Namespace { 58 | let namespace: Namespace = { types: {}, values: {} } 59 | 60 | if (topLevelNode) { 61 | let visitor = new NamespaceVisitor(namespace) 62 | 63 | visitor.traverse(topLevelNode) 64 | } 65 | 66 | return namespace 67 | } 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lona Compiler 2 | 3 | ![Build and Test](https://github.com/Lona/compiler/workflows/Build%20and%20Test/badge.svg) 4 | 5 | The Lona Compiler is a CLI tool for generating cross-platform UI code from JSON definitions. 6 | 7 | The Lona Compiler is published on `npm` as `@lona/compiler`. 8 | 9 | ## Usage 10 | 11 | ### Installation 12 | 13 | First, install the compiler with: 14 | 15 | ```bash 16 | npm install --global @lona/compiler 17 | ``` 18 | 19 | You may also install locally to your current project if you prefer, by removing the `--global`. 20 | 21 | ### Commands 22 | 23 | For each command, you'll choose a code generation `format`: `swift`, `js`, `tokens`, or `documentation`. 24 | 25 | Each format as a specific set of options. 26 | 27 | In the case of `js`, the `--framework` option can have a few values: 28 | 29 | - `reactnative`: [React Native](https://facebook.github.io/react-native/) (default) 30 | - `reactdom`: [React DOM](https://reactjs.org) 31 | - `reactsketchapp`: [React SketchApp](http://airbnb.io/react-sketchapp/) 32 | 33 | #### Generate workspace 34 | 35 | This will generate the colors, text styles, shadows, custom types, and all components, writing them to `output-directory` in the same structure as the input workspace directory. 36 | 37 | ```bash 38 | lona convert [path-to-workspace-directory] --format=js --output=[output-directory] 39 | ``` 40 | 41 | ## Contributing 42 | 43 | To build the compiler from source, follow these steps. 44 | 45 | This project is written in TypeScript. 46 | 47 | ### Setup: Install dependencies with yarn 48 | 49 | From this directory, run: 50 | 51 | ```bash 52 | yarn 53 | ``` 54 | 55 | > Note: If you don't have yarn installed already, you can download it with npm: `npm install --global yarn` 56 | 57 | ### Running commands 58 | 59 | The above examples can now be run by replacing `lona` with `ts-node src/bin.ts`. 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@lona/compiler", 3 | "version": "0.2.1", 4 | "description": "Lona cross-platform code compiler", 5 | "main": "lib/index.js", 6 | "bin": { 7 | "lona": "lib/bin.js" 8 | }, 9 | "files": [ 10 | "lib", 11 | "static" 12 | ], 13 | "types": "lib/index.d.ts", 14 | "scripts": { 15 | "build": "npm run clean && tsc --declaration", 16 | "clean": "rm -rf ./lib", 17 | "prepublishOnly": "npm run build", 18 | "test": "jest", 19 | "test:watch": "jest --watch" 20 | }, 21 | "repository": "https://github.com/Lona/compiler", 22 | "author": "Mathieu Dutour", 23 | "license": "MIT", 24 | "dependencies": { 25 | "@lona/serialization": "^0.7.0", 26 | "buffs": "^0.5.0", 27 | "color": "^3.1.2", 28 | "lodash.camelcase": "^4.3.0", 29 | "lodash.intersection": "^4.4.0", 30 | "lodash.isequal": "^4.5.0", 31 | "lodash.lowerfirst": "^4.3.1", 32 | "lodash.snakecase": "^4.1.1", 33 | "lodash.upperfirst": "^4.3.1", 34 | "prettier": "^1.19.1", 35 | "tree-visit": "^0.0.5", 36 | "uuid": "^7.0.2", 37 | "yargs": "^15.1.0" 38 | }, 39 | "devDependencies": { 40 | "@types/color": "^3.0.1", 41 | "@types/jest": "^25.1.3", 42 | "@types/lodash.camelcase": "^4.3.6", 43 | "@types/lodash.intersection": "^4.4.6", 44 | "@types/lodash.isequal": "^4.5.5", 45 | "@types/lodash.lowerfirst": "^4.3.6", 46 | "@types/lodash.snakecase": "^4.1.6", 47 | "@types/lodash.upperfirst": "^4.3.6", 48 | "@types/node": "^13.7.6", 49 | "@types/prettier": "^1.19.0", 50 | "@types/uuid": "^7.0.0", 51 | "jest": "^25.1.0", 52 | "ts-jest": "^25.2.1", 53 | "ts-node": "^8.6.2", 54 | "typescript": "^3.8.2" 55 | }, 56 | "prettier": { 57 | "proseWrap": "never", 58 | "singleQuote": true, 59 | "trailingComma": "es5", 60 | "semi": false 61 | }, 62 | "jest": { 63 | "preset": "ts-jest", 64 | "testEnvironment": "node", 65 | "testPathIgnorePatterns": [ 66 | "lib" 67 | ] 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/logic/nodes/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { LogicAST as AST } from '@lona/serialization' 2 | import { EnterReturnValue, LeaveReturnValue } from 'tree-visit' 3 | import { EvaluationVisitor } from '../evaluationVisitor' 4 | import NamespaceVisitor from '../namespaceVisitor' 5 | import { ScopeVisitor } from '../scopeVisitor' 6 | import { TypeCheckerVisitor } from '../typeChecker' 7 | 8 | export type SyntaxNodeType = AST.SyntaxNode['type'] 9 | 10 | export interface INode { 11 | syntaxNode: AST.SyntaxNode 12 | type: SyntaxNodeType 13 | id: string 14 | } 15 | 16 | export class Node implements INode { 17 | syntaxNode: T 18 | 19 | constructor(syntaxNode: T) { 20 | this.syntaxNode = syntaxNode 21 | } 22 | 23 | get type(): SyntaxNodeType { 24 | return this.syntaxNode.type 25 | } 26 | 27 | get id(): string { 28 | return this.syntaxNode.data.id 29 | } 30 | } 31 | 32 | export interface INamespaceContributor extends INode { 33 | namespaceEnter(visitor: NamespaceVisitor): EnterReturnValue 34 | namespaceLeave(visitor: NamespaceVisitor): LeaveReturnValue 35 | } 36 | 37 | export interface IScopeContributor extends INode { 38 | scopeEnter(visitor: ScopeVisitor): EnterReturnValue 39 | scopeLeave(visitor: ScopeVisitor): LeaveReturnValue 40 | } 41 | 42 | export interface ITypeCheckerContributor extends INode { 43 | typeCheckerEnter(visitor: TypeCheckerVisitor): EnterReturnValue 44 | typeCheckerLeave(visitor: TypeCheckerVisitor): LeaveReturnValue 45 | } 46 | 47 | export interface IEvaluationContributor extends INode { 48 | evaluationEnter(visitor: EvaluationVisitor): EnterReturnValue 49 | } 50 | 51 | export interface IDeclaration 52 | extends INode, 53 | INamespaceContributor, 54 | IScopeContributor {} 55 | 56 | export interface ITypeAnnotation extends INode, IScopeContributor {} 57 | 58 | export interface IExpression extends INode, IScopeContributor {} 59 | 60 | export interface ILiteral 61 | extends INode, 62 | ITypeCheckerContributor, 63 | IEvaluationContributor {} 64 | -------------------------------------------------------------------------------- /static/logic/LonaDevice.logic: -------------------------------------------------------------------------------- 1 | import Prelude 2 | 3 | /* 4 | * # Lona Device 5 | * 6 | * Configure a device for previewing components. Each device can have a width and height. 7 | */ 8 | struct LonaDevice { 9 | let name: String = "" 10 | let width: Number = 0 11 | let height: Number = 0 12 | } 13 | 14 | extension LonaDevice { 15 | static let iPhoneSE: LonaDevice = LonaDevice(name: "iPhone SE", width: 320, height: 568) 16 | static let iPhone8: LonaDevice = LonaDevice(name: "iPhone 8", width: 375, height: 667) 17 | static let iPhone8Plus: LonaDevice = LonaDevice(name: "iPhone 8 Plus", width: 414, height: 736) 18 | static let iPhoneXS: LonaDevice = LonaDevice(name: "iPhone XS", width: 375, height: 812) 19 | static let iPhoneXR: LonaDevice = LonaDevice(name: "iPhone XR", width: 414, height: 896) 20 | static let iPhoneXSMax: LonaDevice = LonaDevice(name: "iPhone XS Max", width: 414, height: 896) 21 | static let iPad: LonaDevice = LonaDevice(name: "iPad", width: 768, height: 1024) 22 | static let iPadPro10_5: LonaDevice = LonaDevice(name: "iPad Pro 10.5", width: 834, height: 1112) 23 | static let iPadPro11: LonaDevice = LonaDevice(name: "iPad Pro 11", width: 834, height: 1194) 24 | static let iPadPro12_9: LonaDevice = LonaDevice(name: "iPad Pro 12.9", width: 1024, height: 1366) 25 | static let Pixel2: LonaDevice = LonaDevice(name: "Pixel 2", width: 412, height: 732) 26 | static let Pixel2XL: LonaDevice = LonaDevice(name: "Pixel 2 XL", width: 360, height: 720) 27 | static let GalaxyS8: LonaDevice = LonaDevice(name: "Galaxy S8", width: 360, height: 740) 28 | static let Nexus7: LonaDevice = LonaDevice(name: "Nexus 7", width: 600, height: 960) 29 | static let Nexus9: LonaDevice = LonaDevice(name: "Nexus 9", width: 768, height: 1024) 30 | static let Nexus10: LonaDevice = LonaDevice(name: "Nexus 10", width: 800, height: 1280) 31 | static let Desktop: LonaDevice = LonaDevice(name: "Desktop", width: 1024, height: 1024) 32 | static let DesktopHD: LonaDevice = LonaDevice(name: "Desktop HD", width: 1440, height: 1024) 33 | } 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/plugins/js/__tests__/js.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import fs from 'fs' 3 | import { createFs, toJSON, copy, match } from 'buffs' 4 | import plugin from '../index' 5 | import { createHelpers } from '../../../helpers' 6 | 7 | const workspacePath = path.join(__dirname, '../../../../examples/workspace') 8 | const fixturesPath = path.join(__dirname, '../../__tests__/fixtures') 9 | 10 | const tokensBlock = (string: string) => '```tokens\n' + string + '\n```\n' 11 | 12 | // TODO: Test more kinds of JS 13 | describe('JS', () => { 14 | it('generates js', async () => { 15 | const source = createFs({ 16 | 'lona.json': JSON.stringify({}), 17 | 'Colors.md': tokensBlock(`let color: Color = #color(css: "pink")`), 18 | }) 19 | 20 | const helpers = createHelpers(source, '/') 21 | 22 | await plugin.convertWorkspace('/', helpers, { output: '/output' }) 23 | 24 | const index = source.readFileSync('/output/index.js', 'utf8') 25 | const colors = source.readFileSync('/output/Colors.js', 'utf8') 26 | 27 | expect(index).toMatchSnapshot() 28 | expect(colors).toMatchSnapshot() 29 | }) 30 | 31 | it('converts workspace', async () => { 32 | const source = createFs() 33 | 34 | copy(fs, source, workspacePath, '/') 35 | 36 | const helpers = createHelpers(source, '/') 37 | 38 | await plugin.convertWorkspace('/', helpers, { output: '/output' }) 39 | 40 | expect(toJSON(source, '/output')).toMatchSnapshot() 41 | }) 42 | 43 | describe('Fixtures', () => { 44 | it('converts', async () => { 45 | const fixtures = match(fs, fixturesPath, { includePatterns: ['**/*.md'] }) 46 | 47 | for (let fixture of fixtures) { 48 | const source = createFs({ 49 | 'lona.json': JSON.stringify({}), 50 | 'Fixture.md': fs.readFileSync( 51 | path.join(fixturesPath, fixture), 52 | 'utf8' 53 | ), 54 | }) 55 | 56 | const helpers = createHelpers(source, '/') 57 | 58 | await plugin.convertWorkspace('/', helpers, { output: '/output' }) 59 | 60 | expect( 61 | source.readFileSync('/output/Fixture.js', 'utf8') 62 | ).toMatchSnapshot(fixture) 63 | } 64 | }) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /src/plugins/tokens/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import { Helpers } from '../../helpers' 3 | import { Plugin } from '../index' 4 | import { ConvertedWorkspace, ConvertedFile } from './tokensAst' 5 | import { convert } from './convert' 6 | import { LogicFile } from '../../logic/module' 7 | 8 | export { ConvertedWorkspace, ConvertedFile } 9 | 10 | // depending on whether we have an output or not, 11 | // we return the tokens or write them to disk 12 | function convertWorkspace( 13 | workspacePath: string, 14 | helpers: Helpers, 15 | options: { 16 | [key: string]: unknown 17 | } & { output?: never } 18 | ): Promise 19 | 20 | function convertWorkspace( 21 | workspacePath: string, 22 | helpers: Helpers, 23 | options: { 24 | [key: string]: unknown 25 | } & { output?: string } 26 | ): Promise 27 | 28 | async function convertWorkspace( 29 | workspacePath: string, 30 | helpers: Helpers, 31 | options: { 32 | [key: string]: unknown 33 | } 34 | ): Promise { 35 | let workspace: ConvertedWorkspace = { 36 | files: helpers.module.documentFiles.map(file => 37 | convertFile(workspacePath, file, helpers) 38 | ), 39 | flatTokensSchemaVersion: '0.0.1', 40 | } 41 | 42 | if (typeof options.output !== 'string') return workspace 43 | 44 | helpers.fs.writeFileSync( 45 | options.output, 46 | JSON.stringify(workspace, null, 2), 47 | 'utf8' 48 | ) 49 | } 50 | 51 | function convertFile(workspacePath: string, file: LogicFile, helpers: Helpers) { 52 | const filePath = file.sourcePath 53 | const name = path.basename(filePath, path.extname(filePath)) 54 | const outputPath = path.join(path.dirname(filePath), `${name}.flat.json`) 55 | 56 | const result: ConvertedFile = { 57 | inputPath: path.relative(workspacePath, filePath), 58 | outputPath: path.relative(workspacePath, outputPath), 59 | name, 60 | contents: { 61 | type: 'flatTokens', 62 | value: convert(file.rootNode, helpers), 63 | }, 64 | } 65 | 66 | return result 67 | } 68 | 69 | const plugin: Plugin<{}, ConvertedWorkspace | void> = { 70 | format: 'tokens', 71 | convertWorkspace, 72 | } 73 | 74 | export default plugin 75 | -------------------------------------------------------------------------------- /src/logic/nodes/FunctionParameter.ts: -------------------------------------------------------------------------------- 1 | import { LogicAST as AST } from '@lona/serialization' 2 | import { createExpressionNode, createNode } from './createNode' 3 | import { IExpression, Node, INode } from './interfaces' 4 | import { TypeChecker } from '../typeChecker' 5 | import { 6 | IdentifierTypeAnnotation, 7 | FunctionTypeAnnotation, 8 | } from './typeAnnotations' 9 | import { Substitution, substitute } from '../typeUnifier' 10 | import { StaticType } from '../staticType' 11 | 12 | export class FunctionParameterDefaultValue extends Node< 13 | AST.FunctionParameterDefaultValue 14 | > { 15 | get expression(): IExpression | undefined { 16 | switch (this.syntaxNode.type) { 17 | case 'none': 18 | return undefined 19 | case 'value': 20 | return createExpressionNode(this.syntaxNode.data.expression) 21 | } 22 | } 23 | } 24 | 25 | export class FunctionParameter extends Node { 26 | get name(): string { 27 | return this.namePattern.name 28 | } 29 | 30 | get namePattern(): AST.Pattern { 31 | switch (this.syntaxNode.type) { 32 | case 'parameter': 33 | return this.syntaxNode.data.localName 34 | case 'placeholder': 35 | throw new Error('Invalid type') 36 | } 37 | } 38 | 39 | get defaultValue(): FunctionParameterDefaultValue { 40 | switch (this.syntaxNode.type) { 41 | case 'parameter': 42 | return createNode( 43 | this.syntaxNode.data.defaultValue 44 | ) as FunctionParameterDefaultValue 45 | case 'placeholder': 46 | throw new Error('Invalid type') 47 | } 48 | } 49 | 50 | get typeAnnotation(): FunctionTypeAnnotation | IdentifierTypeAnnotation { 51 | switch (this.syntaxNode.type) { 52 | case 'parameter': 53 | return createNode(this.syntaxNode.data.annotation) as 54 | | FunctionTypeAnnotation 55 | | IdentifierTypeAnnotation 56 | case 'placeholder': 57 | throw new Error('Invalid type') 58 | } 59 | } 60 | 61 | getType(typeChecker: TypeChecker, substitution: Substitution): StaticType { 62 | const type = typeChecker.nodes[this.namePattern.id] 63 | 64 | const resolvedType = substitute(substitution, type) 65 | 66 | return resolvedType 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/logic/nodes/IdentifierExpression.ts: -------------------------------------------------------------------------------- 1 | import { LogicAST as AST } from '@lona/serialization' 2 | import { ScopeVisitor } from '../scopeVisitor' 3 | import { IExpression, Node } from './interfaces' 4 | import { TypeCheckerVisitor } from '../typeChecker' 5 | import { EvaluationVisitor } from '../evaluationVisitor' 6 | 7 | export class IdentifierExpression extends Node 8 | implements IExpression { 9 | get name(): string { 10 | return this.syntaxNode.data.identifier.string 11 | } 12 | 13 | scopeEnter(visitor: ScopeVisitor): void {} 14 | 15 | scopeLeave(visitor: ScopeVisitor): void { 16 | const { id, identifier } = this.syntaxNode.data 17 | 18 | if (identifier.isPlaceholder) return 19 | 20 | const found = visitor.findValueIdentifierReference(identifier.string) 21 | 22 | if (found) { 23 | visitor.scope.identifierExpressionToPattern[id] = found 24 | 25 | // TEMPORARY. We shouldn't add an identifier to this, only identifierExpression 26 | visitor.scope.identifierExpressionToPattern[identifier.id] = found 27 | } else { 28 | visitor.reporter.warn( 29 | `No identifier: ${identifier.string}`, 30 | visitor.scope.valueNames 31 | ) 32 | visitor.scope.undefinedIdentifierExpressions.add(id) 33 | } 34 | } 35 | 36 | typeCheckerEnter(visitor: TypeCheckerVisitor): void {} 37 | 38 | typeCheckerLeave(visitor: TypeCheckerVisitor): void { 39 | const { id, identifier } = this.syntaxNode.data 40 | const { scope, typeChecker } = visitor 41 | 42 | let type = visitor.specificIdentifierType(scope, typeChecker, identifier.id) 43 | 44 | typeChecker.nodes[id] = type 45 | typeChecker.nodes[identifier.id] = type 46 | } 47 | 48 | evaluationEnter(visitor: EvaluationVisitor): void { 49 | const { 50 | id, 51 | identifier: { string, id: identifierId }, 52 | } = this.syntaxNode.data 53 | 54 | const patternId = visitor.scope.identifierExpressionToPattern[id] 55 | 56 | if (!patternId) return 57 | 58 | visitor.add(identifierId, { 59 | label: `Identifier (${string})`, 60 | dependencies: [patternId], 61 | f: values => values[0], 62 | }) 63 | 64 | visitor.add(id, { 65 | label: `IdentifierExpression (${string})`, 66 | dependencies: [patternId], 67 | f: values => values[0], 68 | }) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/plugins/swift/__tests__/swift.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import fs from 'fs' 3 | import { createFs, copy, toJSON, match } from 'buffs' 4 | import plugin from '../index' 5 | import { createHelpers } from '../../../helpers' 6 | 7 | const workspacePath = path.join(__dirname, '../../../../examples/workspace') 8 | const fixturesPath = path.join(__dirname, '../../__tests__/fixtures') 9 | 10 | const tokensBlock = (string: string) => '```tokens\n' + string + '\n```\n' 11 | 12 | // TODO: Test more kinds of Swift 13 | describe('Swift', () => { 14 | it('generates swift', async () => { 15 | const source = createFs({ 16 | 'lona.json': JSON.stringify({}), 17 | 'Colors.md': tokensBlock(`let color: Color = #color(css: "pink")`), 18 | }) 19 | 20 | const helpers = createHelpers(source, '/') 21 | 22 | await plugin.convertWorkspace('/', helpers, { output: '/output' }) 23 | 24 | const colors = source.readFileSync('/output/Colors.swift', 'utf8') 25 | 26 | expect(colors).toMatchSnapshot() 27 | }) 28 | 29 | it('converts workspace', async () => { 30 | const source = createFs() 31 | 32 | copy(fs, source, workspacePath, '/') 33 | 34 | const helpers = createHelpers(source, '/') 35 | 36 | await plugin.convertWorkspace('/', helpers, { output: '/output' }) 37 | 38 | const files = toJSON(source, '/output') 39 | 40 | // No need to test helper files 41 | Object.keys(files).forEach(file => { 42 | if (file.startsWith('/output/lona-helpers/')) { 43 | delete files[file] 44 | } 45 | }) 46 | 47 | expect(files).toMatchSnapshot() 48 | }) 49 | 50 | describe('Fixtures', () => { 51 | it('converts', async () => { 52 | const fixtures = match(fs, fixturesPath, { includePatterns: ['**/*.md'] }) 53 | 54 | for (let fixture of fixtures) { 55 | const source = createFs({ 56 | 'lona.json': JSON.stringify({}), 57 | 'Fixture.md': fs.readFileSync( 58 | path.join(fixturesPath, fixture), 59 | 'utf8' 60 | ), 61 | }) 62 | 63 | const helpers = createHelpers(source, '/') 64 | 65 | await plugin.convertWorkspace('/', helpers, { output: '/output' }) 66 | 67 | expect( 68 | source.readFileSync('/output/Fixture.swift', 'utf8') 69 | ).toMatchSnapshot(fixture) 70 | } 71 | }) 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /static/logic/Prelude.logic: -------------------------------------------------------------------------------- 1 | /* 2 | * # Boolean 3 | * 4 | * A type that represents `true` and `false` values. 5 | * 6 | * Booleans are frequently used to represent things that can be in only 2 states: **on/off**, **enabled/disabled**, **yes/no**, etc. 7 | */ 8 | struct Boolean { 9 | 10 | } 11 | 12 | extension Boolean { 13 | func or(a: Boolean, b: Boolean) -> Boolean {} 14 | func and(a: Boolean, b: Boolean) -> Boolean {} 15 | } 16 | 17 | /* 18 | * # Number 19 | * 20 | * A rational number. This can be a whole number, like `42`, or a decimal number, like `3.14`. 21 | * 22 | * This number is represented internally as a floating point number. 23 | * 24 | * ## Example 25 | * 26 | * We can declare a number variable to use elsewhere in our app: 27 | * 28 | * ```logic 29 | * 30 | * 31 | * 32 | * ``` 33 | * 34 | * ## Generating Code 35 | * 36 | * This will be converted to a **CGFloat** in Swift. 37 | */ 38 | struct Number { 39 | 40 | } 41 | 42 | extension Number { 43 | /* 44 | * # Range 45 | * 46 | * Create an array of numbers from `from` to `to`. 47 | * 48 | * The numbers will be created by starting at `from` and adding the `by` value repeatedly until it is greater than or equal to `to`. 49 | */ 50 | func range(from: Number, to: Number, by: Number) -> Array {} 51 | } 52 | 53 | /* 54 | * # String 55 | * 56 | * A `String` is a sequence of characters, such as `h` or `e`, put together to form text such as `hello`. 57 | * 58 | * ## Example 59 | * 60 | * We might want to declare a greeting variable as a `String`: 61 | * 62 | * ```logic 63 | * 64 | * 65 | * 66 | * ``` 67 | */ 68 | struct String { 69 | 70 | } 71 | 72 | extension String { 73 | func concat(a: String, b: String) -> String {} 74 | } 75 | 76 | /* 77 | * # Array 78 | * 79 | * A generic type representing a sequence of elements. Each element must be the same type. 80 | */ 81 | struct Array { 82 | 83 | } 84 | 85 | extension Array { 86 | func at(array: Array, index: Number) -> T {} 87 | } 88 | 89 | /* 90 | * # Optional 91 | * 92 | * A generic type representing a value that may or may not exist. 93 | */ 94 | enum Optional { 95 | case value(Wrapped) 96 | case none() 97 | } 98 | -------------------------------------------------------------------------------- /static/logic/Element.logic: -------------------------------------------------------------------------------- 1 | import Prelude 2 | 3 | import Color 4 | 5 | import TextStyle 6 | 7 | enum TextAlign { 8 | case left() 9 | case center() 10 | case right() 11 | } 12 | 13 | enum DimensionSize { 14 | case fixed(Number) 15 | case flexible() 16 | } 17 | 18 | struct Padding { 19 | let top: Number = 0 20 | let right: Number = 0 21 | let bottom: Number = 0 22 | let left: Number = 0 23 | } 24 | 25 | extension Padding { 26 | func size(value: Number) -> Padding { 27 | return Padding(top: value, right: value, bottom: value, left: value) 28 | } 29 | } 30 | 31 | enum ElementParameter { 32 | case boolean(String, Boolean) 33 | case number(String, Number) 34 | case string(String, String) 35 | case color(String, Color) 36 | case textStyle(String, TextStyle) 37 | case elements(String, Array) 38 | case textAlign(String, TextAlign) 39 | case dimension(String, DimensionSize) 40 | case padding(String, Padding) 41 | } 42 | 43 | struct Element { 44 | let type: String = "" 45 | let parameters: Array = [ 46 | 47 | ] 48 | } 49 | 50 | func View(__name: String, width: DimensionSize, height: DimensionSize, padding: Padding, backgroundColor: Color, children: Array) -> Element { 51 | return Element(type: "LonaView", parameters: [ 52 | ElementParameter.color("backgroundColor", backgroundColor), 53 | ElementParameter.elements("children", children), 54 | ElementParameter.dimension("width", width), 55 | ElementParameter.dimension("height", height), 56 | ElementParameter.padding("padding", padding) 57 | ]) 58 | } 59 | 60 | func Text(__name: String, value: String, style: TextStyle, alignment: TextAlign) -> Element { 61 | return Element(type: "LonaText", parameters: [ 62 | ElementParameter.string("value", value), 63 | ElementParameter.textStyle("style", style), 64 | ElementParameter.textAlign("textAlign", alignment) 65 | ]) 66 | } 67 | 68 | func VerticalStack(children: Array) -> Element { 69 | return Element(type: "LonaView", parameters: [ 70 | ElementParameter.elements("children", children), 71 | ElementParameter.string("flexDirection", "column") 72 | ]) 73 | } 74 | 75 | func HorizontalStack(children: Array) -> Element { 76 | return Element(type: "LonaView", parameters: [ 77 | ElementParameter.elements("children", children), 78 | ElementParameter.string("flexDirection", "row") 79 | ]) 80 | } -------------------------------------------------------------------------------- /src/plugins/documentation/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import { Helpers } from '../../helpers' 3 | import { Plugin } from '../index' 4 | import { ConvertedWorkspace, ConvertedFile } from './documentationAst' 5 | import { convert } from './convert' 6 | import { findChildPages } from './utils' 7 | import { LogicFile } from '../../logic/module' 8 | 9 | export { ConvertedWorkspace, ConvertedFile } 10 | 11 | // depending on whether we have an output or not, 12 | // we return the doc or write it to disk 13 | function convertWorkspace( 14 | workspacePath: string, 15 | helpers: Helpers, 16 | options: { 17 | [key: string]: unknown 18 | } & { output?: never } 19 | ): Promise 20 | 21 | function convertWorkspace( 22 | workspacePath: string, 23 | helpers: Helpers, 24 | options: { 25 | [key: string]: unknown 26 | } & { output?: string } 27 | ): Promise 28 | 29 | async function convertWorkspace( 30 | workspacePath: string, 31 | helpers: Helpers, 32 | options: { 33 | [key: string]: unknown 34 | } 35 | ): Promise { 36 | let workspace: ConvertedWorkspace = { 37 | files: helpers.module.documentFiles.map(file => 38 | convertFile(workspacePath, file, helpers) 39 | ), 40 | flatTokensSchemaVersion: '0.0.1', 41 | } 42 | 43 | if (typeof options.output !== 'string') return workspace 44 | 45 | helpers.fs.writeFileSync( 46 | options.output, 47 | JSON.stringify(workspace, null, 2), 48 | 'utf8' 49 | ) 50 | } 51 | 52 | function convertFile( 53 | workspacePath: string, 54 | file: LogicFile, 55 | helpers: Helpers 56 | ): ConvertedFile { 57 | const filePath = file.sourcePath 58 | const name = path.basename(filePath, path.extname(filePath)) 59 | const outputPath = path.join(path.dirname(filePath), `${name}.mdx`) 60 | 61 | const root = { children: file.mdxContent } 62 | 63 | const value = { 64 | mdxString: convert(root, helpers), 65 | children: findChildPages(root), 66 | } 67 | 68 | const result: ConvertedFile = { 69 | inputPath: path.relative(workspacePath, filePath), 70 | outputPath: path.relative(workspacePath, outputPath), 71 | name, 72 | contents: { 73 | type: 'documentationPage', 74 | value, 75 | }, 76 | } 77 | 78 | return result 79 | } 80 | 81 | const plugin: Plugin<{}, ConvertedWorkspace | void> = { 82 | format: 'documentation', 83 | convertWorkspace, 84 | } 85 | 86 | export default plugin 87 | -------------------------------------------------------------------------------- /src/logic/nodes/MemberExpression.ts: -------------------------------------------------------------------------------- 1 | import { LogicAST as AST } from '@lona/serialization' 2 | import { EnterReturnValue } from 'tree-visit' 3 | import { flattenedMemberExpression } from '../ast' 4 | import { EvaluationVisitor } from '../evaluationVisitor' 5 | import { ScopeVisitor } from '../scopeVisitor' 6 | import { TypeCheckerVisitor } from '../typeChecker' 7 | import { IExpression, Node } from './interfaces' 8 | 9 | export class MemberExpression extends Node 10 | implements IExpression { 11 | get names(): string[] { 12 | const { expression, memberName } = this.syntaxNode.data 13 | 14 | let names: string[] = [] 15 | 16 | if (expression.type === 'identifierExpression') { 17 | names.push(expression.data.identifier.string) 18 | } else if (expression.type === 'memberExpression') { 19 | names.push(...new MemberExpression(expression).names) 20 | } 21 | 22 | names.push(memberName.string) 23 | 24 | return names 25 | } 26 | 27 | scopeEnter(visitor: ScopeVisitor): EnterReturnValue { 28 | const { id } = this.syntaxNode.data 29 | 30 | const identifiers = flattenedMemberExpression(this.syntaxNode) 31 | 32 | if (identifiers) { 33 | const keyPath = identifiers.map(x => x.string).join('.') 34 | 35 | const patternId = visitor.namespace.values[keyPath] 36 | 37 | if (patternId) { 38 | visitor.scope.memberExpressionToPattern[id] = patternId 39 | } else { 40 | visitor.reporter.warn(`No identifier path: ${keyPath}`) 41 | visitor.scope.undefinedMemberExpressions.add(id) 42 | } 43 | } 44 | 45 | return 'skip' 46 | } 47 | 48 | scopeLeave(visitor: ScopeVisitor): void {} 49 | 50 | typeCheckerEnter(visitor: TypeCheckerVisitor): EnterReturnValue { 51 | const { id } = this.syntaxNode.data 52 | const { scope, typeChecker } = visitor 53 | 54 | typeChecker.nodes[id] = visitor.specificIdentifierType( 55 | scope, 56 | typeChecker, 57 | id 58 | ) 59 | 60 | return 'skip' 61 | } 62 | 63 | typeCheckerLeave(visitor: TypeCheckerVisitor): void {} 64 | 65 | evaluationEnter(visitor: EvaluationVisitor) { 66 | const { id } = this.syntaxNode.data 67 | const { scope } = visitor 68 | 69 | const patternId = scope.memberExpressionToPattern[id] 70 | 71 | if (!patternId) return 72 | 73 | visitor.add(id, { 74 | label: 'Member expression', 75 | dependencies: [patternId], 76 | f: values => values[0], 77 | }) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/bin.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import yargs from 'yargs' 4 | import { convert, getConfig } from './index' 5 | 6 | yargs 7 | .scriptName('@lona/compiler') 8 | .usage('Usage: lona [options]') 9 | .command( 10 | 'config [workspace]', 11 | 'Get the configuration of a Lona workspace', 12 | cli => { 13 | cli.positional('workspace', { 14 | describe: 'path to the Lona workspace', 15 | type: 'string', 16 | default: process.cwd(), 17 | }) 18 | }, 19 | argv => { 20 | const { workspace } = argv 21 | 22 | if (typeof workspace !== 'string') { 23 | throw new Error('workspace must be a string') 24 | } 25 | 26 | try { 27 | const config = getConfig(workspace) 28 | 29 | if (!config) { 30 | throw new Error( 31 | 'The path provided is not a Lona Workspace. A workspace must contain a `lona.json` file.' 32 | ) 33 | } 34 | 35 | console.log(JSON.stringify(config, null, 2)) 36 | } catch (e) { 37 | console.error(e) 38 | process.exit(1) 39 | } 40 | } 41 | ) 42 | .command( 43 | 'convert [path]', 44 | 'Convert a workspace to a specific format', 45 | cli => { 46 | cli.positional('path', { 47 | describe: 'path to the Lona workspace', 48 | type: 'string', 49 | default: process.cwd(), 50 | }) 51 | cli.option('format', { 52 | describe: 'format to convert it to', 53 | type: 'string', 54 | demandOption: true, 55 | }) 56 | }, 57 | argv => { 58 | const { path, format } = argv 59 | 60 | if (typeof path !== 'string') { 61 | throw new Error('path must be a string') 62 | } 63 | 64 | if (typeof format !== 'string') { 65 | throw new Error('format option must be a string') 66 | } 67 | 68 | convert(path, format, argv) 69 | .then(result => { 70 | if (result) { 71 | if (typeof result === 'string') { 72 | console.log(result) 73 | } else { 74 | console.log(JSON.stringify(result, null, 2)) 75 | } 76 | } 77 | }) 78 | .catch(err => { 79 | console.error(err) 80 | process.exit(1) 81 | }) 82 | } 83 | ) 84 | .demandCommand(1, 'Pass --help to see all available commands and options.') 85 | .fail(msg => { 86 | yargs.showHelp() 87 | console.log('\n' + msg) 88 | }) 89 | .help('h') 90 | .alias('h', 'help').argv 91 | -------------------------------------------------------------------------------- /src/plugins/js/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import upperFirst from 'lodash.upperfirst' 3 | import camelCase from 'lodash.camelcase' 4 | import { Helpers } from '../../helpers' 5 | import { Plugin } from '../index' 6 | import convertLogic from './convertLogic' 7 | import renderJS from './renderAst' 8 | import * as JSAST from './jsAst' 9 | import { generateTranspiledImport } from './utils' 10 | import { LogicFile } from '../../logic/module' 11 | 12 | const convertWorkspace = async ( 13 | workspacePath: string, 14 | helpers: Helpers, 15 | options: { 16 | [key: string]: unknown 17 | } 18 | ): Promise => { 19 | if (typeof options.output !== 'string') { 20 | throw new Error('Output option required when generating JS') 21 | } 22 | 23 | const { output } = options 24 | 25 | try { 26 | helpers.fs.mkdirSync(output, { recursive: true }) 27 | } catch (e) { 28 | // Directory already exists 29 | } 30 | 31 | const imports: string[] = [] 32 | 33 | await Promise.all( 34 | helpers.module.sourceFiles.map(file => { 35 | const outputText = convertFile(file, helpers) 36 | 37 | if (!outputText) return 38 | 39 | const sourcePath = file.sourcePath 40 | 41 | const name = upperFirst( 42 | camelCase(path.basename(sourcePath, path.extname(sourcePath))) 43 | ) 44 | 45 | const relativePath = path.relative(workspacePath, sourcePath) 46 | 47 | const outputPath = path.join( 48 | output, 49 | path.dirname(relativePath), 50 | `${name}.js` 51 | ) 52 | 53 | imports.push(outputPath) 54 | 55 | helpers.fs.writeFileSync(outputPath, outputText, 'utf8') 56 | }) 57 | ) 58 | 59 | helpers.fs.writeFileSync( 60 | path.join(output, 'index.js'), 61 | `${imports 62 | .map((importPath, i) => generateTranspiledImport(output, importPath, i)) 63 | .join('\n\n')}`, 64 | 'utf8' 65 | ) 66 | } 67 | 68 | function convertFile(file: LogicFile, helpers: Helpers): string { 69 | const rootNode = file.rootNode 70 | 71 | if ( 72 | rootNode.type !== 'topLevelDeclarations' || 73 | !rootNode.data.declarations.length 74 | ) { 75 | return '' 76 | } 77 | 78 | let jsAST: JSAST.JSNode = convertLogic(rootNode, file.sourcePath, helpers) 79 | 80 | return `${renderJS(jsAST, { reporter: helpers.reporter })}` 81 | } 82 | 83 | type ExpectedOptions = { 84 | framework?: 'react' | 'react-native' | 'react-sketchapp' 85 | } 86 | 87 | const plugin: Plugin = { 88 | format: 'js', 89 | convertWorkspace, 90 | } 91 | 92 | export default plugin 93 | -------------------------------------------------------------------------------- /src/logic/scope.ts: -------------------------------------------------------------------------------- 1 | import { LogicAST as AST } from '@lona/serialization' 2 | import { Namespace, UUID } from './namespace' 3 | import ScopeStack from './scopeStack' 4 | import { ScopeVisitor } from './scopeVisitor' 5 | import { Reporter, silentReporter } from '../utils/reporter' 6 | 7 | export class Scope { 8 | // References to the pattern they're defined by (e.g. the record name or function argument) 9 | identifierExpressionToPattern: { [key: string]: UUID } = {} 10 | memberExpressionToPattern: { [key: string]: UUID } = {} 11 | typeIdentifierToPattern: { [key: string]: UUID } = {} 12 | 13 | // Undefined identifiers for better error messages 14 | undefinedIdentifierExpressions = new Set() 15 | undefinedMemberExpressions = new Set() 16 | undefinedTypeIdentifiers = new Set() 17 | 18 | // These keep track of the current scope 19 | valueNames = new ScopeStack() 20 | typeNames = new ScopeStack() 21 | 22 | get namesInScope(): string[] { 23 | return Object.keys(this.valueNames.flattened()) 24 | } 25 | 26 | get expressionToPattern(): { [key: string]: UUID } { 27 | return { 28 | ...this.identifierExpressionToPattern, 29 | ...this.memberExpressionToPattern, 30 | } 31 | } 32 | } 33 | 34 | function mergeSets(sets: Set[]): Set { 35 | return new Set(sets.flatMap(set => [...set])) 36 | } 37 | 38 | export function mergeScopes(scopes: Scope[]) { 39 | const result = new Scope() 40 | 41 | result.identifierExpressionToPattern = Object.assign( 42 | {}, 43 | ...scopes.map(scope => scope.identifierExpressionToPattern) 44 | ) 45 | result.memberExpressionToPattern = Object.assign( 46 | {}, 47 | ...scopes.map(scope => scope.memberExpressionToPattern) 48 | ) 49 | result.typeIdentifierToPattern = Object.assign( 50 | {}, 51 | ...scopes.map(scope => scope.typeIdentifierToPattern) 52 | ) 53 | 54 | result.undefinedIdentifierExpressions = mergeSets( 55 | scopes.map(scope => scope.undefinedIdentifierExpressions) 56 | ) 57 | result.undefinedMemberExpressions = mergeSets( 58 | scopes.map(scope => scope.undefinedMemberExpressions) 59 | ) 60 | result.undefinedTypeIdentifiers = mergeSets( 61 | scopes.map(scope => scope.undefinedTypeIdentifiers) 62 | ) 63 | 64 | return result 65 | } 66 | 67 | export function createScopeContext( 68 | rootNode: AST.SyntaxNode, 69 | namespace: Namespace, 70 | targetId: UUID | undefined = undefined, 71 | reporter: Reporter = silentReporter 72 | ): Scope { 73 | const scope = new Scope() 74 | 75 | let visitor = new ScopeVisitor(namespace, scope, reporter, targetId) 76 | 77 | visitor.traverse(rootNode) 78 | 79 | return scope 80 | } 81 | -------------------------------------------------------------------------------- /docs/architecture.md: -------------------------------------------------------------------------------- 1 | # Compiler architecture 2 | 3 | The compiler is built around a plugin architecture. Each format (or target) is defined by a plugin. Some plugins are shipped with the compiler (`js` or `swift`) while other can be installed with npm (`android`). 4 | 5 | When calling `lona convert ./workspace --format=swift`, the compiler [will dynamically look for](../src/utils/find-plugin.ts) a `swift` plugin (in this order): 6 | 7 | - a `@lona/compiler-swift` package 8 | - a `lona-compiler-swift` package 9 | - a folder `swift` in the compiler (in `src/plugins/swift`) 10 | 11 | ## Plugin API 12 | 13 | A plugin is an object with a given [interface](../src/plugins/index.ts): 14 | 15 | ```ts 16 | type Plugin = { 17 | format: string 18 | convertWorkspace( 19 | workspacePath: string, 20 | helpers: Helpers, 21 | options: { 22 | [argName: string]: unknown 23 | } 24 | ): Promise 25 | } 26 | ``` 27 | 28 | ## Plugin's Helpers 29 | 30 | A plugin is passed some helpers from the compiler. They contains some methods abstracting the file system, the evaluation context of the workspace, a reporter (to centralize the logs), the workspace's configuration, etc. 31 | 32 | More information [here](../src/helpers/index.ts). 33 | 34 | ## Plugin's Options 35 | 36 | Any option passed to the compiler will be passed to the plugin (so when calling `lona convert ./workspace --format=swift --foo=bar`, the swift plugin will receive `{ format: 'swift', foo: 'bar' }` as the `options` parameter). 37 | 38 | Alternatively, options can be defined in the `lona.json` file of a workspace: 39 | 40 | ```json 41 | { 42 | "format": { 43 | "swift": { 44 | "foo": "bar" 45 | } 46 | } 47 | } 48 | ``` 49 | 50 | ## Usual Plugin Anatomy 51 | 52 | It's up to you to structure your plugin as you want - but for the core plugins, we use a structure that seems to work well. 53 | 54 | A plugin consists of 4 different parts: 55 | 56 | - the external API. `convertWorkspace` is very similar every time: loop through all the tokens and components files, call a `convertFile` function for each of them, and copy some static files to polyfill the standard library if any. 57 | - the format AST. It is very likely that the AST of the target format will have some difference with the Logic AST. 58 | - A conversion between the Logic AST and the format AST. This includes a map between the logic standard library and the format AST. 59 | - A printer of the format AST. We use [prettier](https://prettier.io) to pretty print so the printer is really just a format AST to prettier AST conversion. 60 | 61 | Decoupling the Logic <-> Format <-> Printer means that updating a plugin when the logic AST or standard library is updated is easier. 62 | -------------------------------------------------------------------------------- /src/logic/scopeVisitor.ts: -------------------------------------------------------------------------------- 1 | import { LogicAST as AST } from '@lona/serialization' 2 | import { Namespace, UUID } from './namespace' 3 | import { NodePath } from './nodePath' 4 | import { createScopeVisitor } from './nodes/createNode' 5 | import { Scope } from './scope' 6 | import { visit } from './traversal' 7 | import { Reporter } from '../utils/reporter' 8 | 9 | export class ScopeVisitor { 10 | namespace: Namespace 11 | scope: Scope 12 | reporter: Reporter 13 | currentPath = new NodePath() 14 | targetId?: UUID 15 | 16 | constructor( 17 | namespace: Namespace, 18 | scope: Scope, 19 | reporter: Reporter, 20 | targetId?: string 21 | ) { 22 | this.namespace = namespace 23 | this.scope = scope 24 | this.reporter = reporter 25 | this.targetId = targetId 26 | } 27 | 28 | traverse(rootNode: AST.SyntaxNode) { 29 | visit(rootNode, { 30 | onEnter: (node: AST.SyntaxNode) => { 31 | if (node.data.id === this.targetId) return 'stop' 32 | return createScopeVisitor(node)?.scopeEnter(this) 33 | }, 34 | onLeave: (node: AST.SyntaxNode) => { 35 | return createScopeVisitor(node)?.scopeLeave(this) 36 | }, 37 | }) 38 | } 39 | 40 | // Scope helpers 41 | 42 | pushScope() { 43 | this.scope.valueNames.push() 44 | this.scope.typeNames.push() 45 | } 46 | 47 | popScope() { 48 | this.scope.valueNames.pop() 49 | this.scope.typeNames.pop() 50 | } 51 | 52 | pushNamespace(name: string) { 53 | this.pushScope() 54 | this.currentPath.pushComponent(name) 55 | } 56 | 57 | popNamespace() { 58 | this.popScope() 59 | this.currentPath.popComponent() 60 | } 61 | 62 | addValueToScope(pattern: AST.Pattern) { 63 | this.scope.valueNames.set(pattern.name, pattern.id) 64 | } 65 | 66 | addTypeToScope(pattern: AST.Pattern) { 67 | this.scope.typeNames.set(pattern.name, pattern.id) 68 | } 69 | 70 | findValueIdentifierReference(name: string): UUID | undefined { 71 | const valueInScope = this.scope.valueNames.get(name) 72 | const valueInNamespace = this.namespace.values[name] 73 | const valueInParentNamespace = this.namespace.values[ 74 | [...this.currentPath.components, name].join('.') 75 | ] 76 | 77 | return valueInScope || valueInNamespace || valueInParentNamespace 78 | } 79 | 80 | findTypeIdentifierReference(name: string): UUID | undefined { 81 | const typeInScope = this.scope.typeNames.get(name) 82 | const typeInNamespace = this.namespace.types[name] 83 | const typeInParentNamespace = this.namespace.types[ 84 | [...this.currentPath.components, name].join('.') 85 | ] 86 | 87 | return typeInScope || typeInNamespace || typeInParentNamespace 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/logic/nodes/VariableDeclaration.ts: -------------------------------------------------------------------------------- 1 | import { LogicAST as AST } from '@lona/serialization' 2 | import { IDeclaration, Node } from './interfaces' 3 | import NamespaceVisitor from '../namespaceVisitor' 4 | import { ScopeVisitor } from '../scopeVisitor' 5 | import { TypeCheckerVisitor } from '../typeChecker' 6 | import { EvaluationVisitor } from '../evaluationVisitor' 7 | import { LeaveReturnValue } from 'tree-visit' 8 | import { FunctionCallExpression } from './FunctionCallExpression' 9 | 10 | export class VariableDeclaration extends Node 11 | implements IDeclaration { 12 | get name(): string { 13 | return this.syntaxNode.data.name.name 14 | } 15 | 16 | get attributes(): FunctionCallExpression[] { 17 | return this.syntaxNode.data.attributes.map( 18 | attribute => new FunctionCallExpression(attribute) 19 | ) 20 | } 21 | 22 | namespaceEnter(visitor: NamespaceVisitor): void {} 23 | 24 | namespaceLeave(visitor: NamespaceVisitor): void { 25 | const { 26 | name: { name, id }, 27 | } = this.syntaxNode.data 28 | 29 | visitor.declareValue(name, id) 30 | } 31 | 32 | scopeEnter(visitor: ScopeVisitor): void {} 33 | 34 | scopeLeave(visitor: ScopeVisitor): void { 35 | const { name } = this.syntaxNode.data 36 | 37 | visitor.addValueToScope(name) 38 | } 39 | 40 | typeCheckerEnter(visitor: TypeCheckerVisitor): void {} 41 | 42 | typeCheckerLeave(visitor: TypeCheckerVisitor): LeaveReturnValue { 43 | const { initializer, annotation, name } = this.syntaxNode.data 44 | const { typeChecker, reporter } = visitor 45 | 46 | if (!initializer || !annotation || annotation.type === 'placeholder') { 47 | // TODO: This shouldn't do anything here, so why was it here? 48 | // traversalConfig.ignoreChildren = true 49 | } else { 50 | const annotationType = visitor.unificationType( 51 | [], 52 | () => typeChecker.typeNameGenerator.next(), 53 | annotation 54 | ) 55 | const initializerId = initializer.data.id 56 | const initializerType = typeChecker.nodes[initializerId] 57 | 58 | if (initializerType) { 59 | typeChecker.constraints.push({ 60 | head: annotationType, 61 | tail: initializerType, 62 | origin: this.syntaxNode, 63 | }) 64 | } else { 65 | reporter.error( 66 | `WARNING: No initializer type for ${name.name} (${initializerId})` 67 | ) 68 | } 69 | 70 | typeChecker.patternTypes[name.id] = annotationType 71 | } 72 | } 73 | 74 | evaluationEnter(visitor: EvaluationVisitor): void { 75 | const { initializer, name } = this.syntaxNode.data 76 | 77 | if (!initializer) return 78 | 79 | visitor.add(name.id, { 80 | label: 'Variable initializer for ' + name.name, 81 | dependencies: [initializer.data.id], 82 | f: values => values[0], 83 | }) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/plugins/swift/index.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | import upperFirst from 'lodash.upperfirst' 4 | import camelCase from 'lodash.camelcase' 5 | import { Plugin } from '../index' 6 | import { Helpers } from '../../helpers' 7 | import convertLogic from './convertLogic' 8 | import renderSwift from './renderAst' 9 | import * as SwiftAST from './swiftAst' 10 | import { copy, DirectoryJSON, createFs, toJSON } from 'buffs' 11 | import { LogicFile } from '../../logic/module' 12 | 13 | const STATIC_FILES_PATH = path.join(__dirname, '../../../static/swift') 14 | 15 | const convertWorkspace = async ( 16 | workspacePath: string, 17 | helpers: Helpers, 18 | options: { 19 | [key: string]: unknown 20 | } 21 | ): Promise => { 22 | if (typeof options.output !== 'string') { 23 | throw new Error('Output option required when generating JS') 24 | } 25 | 26 | const { output } = options 27 | 28 | try { 29 | helpers.fs.mkdirSync(output, { recursive: true }) 30 | } catch (e) { 31 | // Directory already exists 32 | } 33 | 34 | helpers.module.sourceFiles.map(file => { 35 | const [outputText, auxiliaryFiles] = convertFile(file, helpers) 36 | 37 | if (!outputText) return 38 | 39 | const sourcePath = file.sourcePath 40 | 41 | const name = upperFirst( 42 | camelCase(path.basename(sourcePath, path.extname(sourcePath))) 43 | ) 44 | 45 | const relativePath = path.relative(workspacePath, sourcePath) 46 | 47 | const outputPath = path.join( 48 | output, 49 | path.dirname(relativePath), 50 | `${name}.swift` 51 | ) 52 | 53 | helpers.fs.writeFileSync(outputPath, outputText, 'utf8') 54 | 55 | copy(createFs(auxiliaryFiles), helpers.fs, '/', output) 56 | }) 57 | 58 | copy(fs, helpers.fs, STATIC_FILES_PATH, path.join(output, './lona-helpers')) 59 | } 60 | 61 | function convertFile( 62 | file: LogicFile, 63 | helpers: Helpers 64 | ): [string, DirectoryJSON] { 65 | const rootNode = file.rootNode 66 | 67 | if ( 68 | rootNode.type !== 'topLevelDeclarations' || 69 | !rootNode.data.declarations.length 70 | ) { 71 | return ['', {}] 72 | } 73 | 74 | const swiftAST: SwiftAST.SwiftNode = convertLogic(rootNode, helpers) 75 | 76 | // TODO: Consider a separate/better place for determining files to write 77 | // instead of as a side effect within renderSwift 78 | const files: DirectoryJSON = {} 79 | 80 | const result = renderSwift(swiftAST, { reporter: helpers.reporter, files }) 81 | 82 | return [ 83 | `import Foundation 84 | 85 | #if canImport(UIKit) 86 | import UIKit 87 | #elseif canImport(AppKit) 88 | import AppKit 89 | #endif 90 | 91 | ${result}`, 92 | files, 93 | ] 94 | } 95 | 96 | const plugin: Plugin<{}, void> = { 97 | format: 'swift', 98 | convertWorkspace, 99 | } 100 | 101 | export default plugin 102 | -------------------------------------------------------------------------------- /src/logic/__tests__/namespace.ts: -------------------------------------------------------------------------------- 1 | import { createNamespace } from '../namespace' 2 | import * as Serialization from '@lona/serialization' 3 | import fs from 'fs' 4 | import path from 'path' 5 | import { STANDARD_LIBRARY_PATH } from '../module' 6 | 7 | function readLibrary(name: string): string { 8 | return fs.readFileSync( 9 | path.join(STANDARD_LIBRARY_PATH, `${name}.logic`), 10 | 'utf8' 11 | ) 12 | } 13 | 14 | describe('Logic / Namespace', () => { 15 | it('adds records to the namespace', () => { 16 | const file = ` 17 | struct Color { 18 | let value: String = "" 19 | } 20 | ` 21 | let rootNode = Serialization.decodeLogic(file) 22 | 23 | let namespace = createNamespace(rootNode) 24 | 25 | expect(Object.keys(namespace.types)).toEqual(['Color']) 26 | expect(Object.keys(namespace.values)).toEqual(['Color.value']) 27 | }) 28 | 29 | it('adds enums to the namespace', () => { 30 | const file = ` 31 | enum TextAlign { 32 | case left() 33 | case center() 34 | case right() 35 | } 36 | ` 37 | let rootNode = Serialization.decodeLogic(file) 38 | 39 | let namespace = createNamespace(rootNode) 40 | 41 | expect(Object.keys(namespace.types)).toEqual(['TextAlign']) 42 | expect(Object.keys(namespace.values)).toEqual([ 43 | 'TextAlign.left', 44 | 'TextAlign.center', 45 | 'TextAlign.right', 46 | ]) 47 | }) 48 | 49 | it('adds functions to the namespace', () => { 50 | const file = `extension Foo { 51 | func bar(hello: Number) -> Number {} 52 | } 53 | 54 | func baz() -> Number {} 55 | ` 56 | let rootNode = Serialization.decodeLogic(file) 57 | 58 | let namespace = createNamespace(rootNode) 59 | 60 | expect(Object.keys(namespace.types)).toEqual([]) 61 | expect(Object.keys(namespace.values)).toEqual(['Foo.bar', 'baz']) 62 | }) 63 | 64 | it('loads Color.logic', () => { 65 | let rootNode = Serialization.decodeLogic(readLibrary('Color')) 66 | 67 | let namespace = createNamespace(rootNode) 68 | 69 | expect(Object.keys(namespace.types)).toEqual(['Color']) 70 | expect(Object.keys(namespace.values)).toEqual([ 71 | 'Color.value', 72 | 'Color.setHue', 73 | 'Color.setSaturation', 74 | 'Color.setLightness', 75 | 'Color.fromHSL', 76 | 'Color.saturate', 77 | ]) 78 | }) 79 | 80 | it('loads TextStyle.logic', () => { 81 | let rootNode = Serialization.decodeLogic(readLibrary('TextStyle')) 82 | 83 | let namespace = createNamespace(rootNode) 84 | 85 | expect(Object.keys(namespace.types)).toEqual(['FontWeight', 'TextStyle']) 86 | expect(Object.keys(namespace.values)).toEqual([ 87 | 'FontWeight.ultraLight', 88 | 'FontWeight.thin', 89 | 'FontWeight.light', 90 | 'FontWeight.regular', 91 | 'FontWeight.medium', 92 | 'FontWeight.semibold', 93 | 'FontWeight.bold', 94 | 'FontWeight.heavy', 95 | 'FontWeight.black', 96 | 'FontWeight.w100', 97 | 'FontWeight.w200', 98 | 'FontWeight.w300', 99 | 'FontWeight.w400', 100 | 'FontWeight.w500', 101 | 'FontWeight.w600', 102 | 'FontWeight.w700', 103 | 'FontWeight.w800', 104 | 'FontWeight.w900', 105 | 'TextStyle.fontName', 106 | 'TextStyle.fontFamily', 107 | 'TextStyle.fontWeight', 108 | 'TextStyle.fontSize', 109 | 'TextStyle.lineHeight', 110 | 'TextStyle.letterSpacing', 111 | 'TextStyle.color', 112 | 'TextStyle', 113 | ]) 114 | }) 115 | }) 116 | -------------------------------------------------------------------------------- /static/logic/TextStyle.logic: -------------------------------------------------------------------------------- 1 | import Prelude 2 | 3 | import Color 4 | 5 | /* 6 | * # Font Weight 7 | * 8 | * Font thickness. 9 | * 10 | * An individual font may support multiple weights. However, specifying a weight that isn't supported will generally result in the **regular (400)** weight being displayed. 11 | */ 12 | enum FontWeight { 13 | /* 14 | * # Ultra Light 15 | * 16 | * The lightest font weight, representing **100**. 17 | */ 18 | case ultraLight() 19 | /* 20 | * # Thin 21 | * 22 | * A font weight, representing **200**. 23 | */ 24 | case thin() 25 | /* 26 | * # Light 27 | * 28 | * A font weight, representing **300**. 29 | */ 30 | case light() 31 | /* 32 | * # Regular 33 | * 34 | * A font weight, representing **400**. 35 | */ 36 | case regular() 37 | /* 38 | * # Medium 39 | * 40 | * A font weight, representing **500**. 41 | */ 42 | case medium() 43 | /* 44 | * # Semibold 45 | * 46 | * A font weight, representing **600**. 47 | */ 48 | case semibold() 49 | /* 50 | * # Bold 51 | * 52 | * A font weight, representing **700**. 53 | */ 54 | case bold() 55 | /* 56 | * # Heavy 57 | * 58 | * A font weight, representing **800**. 59 | */ 60 | case heavy() 61 | /* 62 | * # Black 63 | * 64 | * The heaviest font weight, representing **900**. 65 | */ 66 | case black() 67 | } 68 | 69 | extension FontWeight { 70 | static let w100: FontWeight = FontWeight.ultraLight() 71 | static let w200: FontWeight = FontWeight.thin() 72 | static let w300: FontWeight = FontWeight.light() 73 | static let w400: FontWeight = FontWeight.regular() 74 | static let w500: FontWeight = FontWeight.medium() 75 | static let w600: FontWeight = FontWeight.semibold() 76 | static let w700: FontWeight = FontWeight.bold() 77 | static let w800: FontWeight = FontWeight.heavy() 78 | static let w900: FontWeight = FontWeight.black() 79 | } 80 | 81 | /* 82 | * I> Press enter to create a new Text Style. 83 | * 84 | * # Text Style 85 | * 86 | * This represents a specific configuration of text parameters such as font and color. 87 | */ 88 | struct TextStyle { 89 | /* 90 | * W> This parameter is not supported on the web. 91 | * 92 | * # Font Name 93 | * 94 | * The exact name of a font file, e.g. "Helvetica-Oblique". Generally, you should use the `fontFamily` parameter where possible instead. 95 | */ 96 | let fontName: Optional = Optional.none() 97 | /* 98 | * # Font Family 99 | * 100 | * The family name of the font - for example, "Times" or "Helvetica." 101 | */ 102 | let fontFamily: Optional = Optional.none() 103 | /* 104 | * # Font Weight 105 | * 106 | * The thickness of a font, ranging from `black` to `ultraLight`. The default value is `regular`. 107 | */ 108 | let fontWeight: FontWeight = FontWeight.regular() 109 | /* 110 | * # Font Size 111 | * 112 | * The size of the font, measured in pixels. 113 | */ 114 | let fontSize: Optional = Optional.none() 115 | /* 116 | * # Line Height 117 | * 118 | * The line height, measured in pixels. The default is `none`, in which case the text style will default to the system-calculated line height. 119 | */ 120 | let lineHeight: Optional = Optional.none() 121 | /* 122 | * # Letter Spacing 123 | * 124 | * Increase or decrease the kerning (spacing between characters). The default is `none`, which uses the font's built-in kerning. 125 | */ 126 | let letterSpacing: Optional = Optional.none() 127 | /* 128 | * # Color 129 | * 130 | * The color of the text. 131 | */ 132 | let color: Optional = Optional.none() 133 | } 134 | -------------------------------------------------------------------------------- /src/plugins/documentation/__tests__/__snapshots__/documentation.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`converts workspace 1`] = ` 4 | Object { 5 | "files": Array [ 6 | Object { 7 | "contents": Object { 8 | "type": "documentationPage", 9 | "value": Object { 10 | "children": Array [], 11 | "mdxString": "# Colors 12 | 13 |
14 |
15 |
16 | primary 17 | #45CBFF 18 |
19 |
20 | 21 |
22 |
23 |
24 | accent 25 | #45CBFF 26 |
27 |
28 | 29 |
30 |
31 |
32 | testSaturate 33 | #29D7FF 34 |
35 |
", 36 | }, 37 | }, 38 | "inputPath": "Colors.md", 39 | "name": "Colors", 40 | "outputPath": "Colors.mdx", 41 | }, 42 | Object { 43 | "contents": Object { 44 | "type": "documentationPage", 45 | "value": Object { 46 | "children": Array [ 47 | "Colors.md", 48 | "TextStyles.md", 49 | "Shadows.md", 50 | ], 51 | "mdxString": "# Flat Tokens 52 | 53 | This is an example workspace for testing token generation. 54 | 55 | ### Pages 56 | 57 | Colors 58 | 59 | TextStyles 60 | 61 | Shadows", 62 | }, 63 | }, 64 | "inputPath": "README.md", 65 | "name": "README", 66 | "outputPath": "README.mdx", 67 | }, 68 | Object { 69 | "contents": Object { 70 | "type": "documentationPage", 71 | "value": Object { 72 | "children": Array [], 73 | "mdxString": "
74 |
75 |
76 | small 77 | 0px 2px 2px 0px #45CBFF 78 |
79 |
", 80 | }, 81 | }, 82 | "inputPath": "Shadows.md", 83 | "name": "Shadows", 84 | "outputPath": "Shadows.mdx", 85 | }, 86 | Object { 87 | "contents": Object { 88 | "type": "documentationPage", 89 | "value": Object { 90 | "children": Array [], 91 | "mdxString": "
92 |
93 |
94 | heading1 95 | Helvetica 700 28px teal 96 |
97 |
", 98 | }, 99 | }, 100 | "inputPath": "TextStyles.md", 101 | "name": "TextStyles", 102 | "outputPath": "TextStyles.mdx", 103 | }, 104 | ], 105 | "flatTokensSchemaVersion": "0.0.1", 106 | } 107 | `; 108 | -------------------------------------------------------------------------------- /src/plugins/tokens/tokenValue.ts: -------------------------------------------------------------------------------- 1 | import * as TokenAST from './tokensAst' 2 | import { Memory } from '../../logic/runtime/memory' 3 | import { Value, Decode } from '../../logic/runtime/value' 4 | 5 | let getField = (key: string, fields: Memory) => { 6 | if (fields.type !== 'record') { 7 | return 8 | } 9 | return fields.value[key] 10 | } 11 | 12 | const getColorValue = (value?: Value): TokenAST.ColorTokenValue | undefined => { 13 | if (!value) { 14 | return undefined 15 | } 16 | const css = Decode.color(value) 17 | if (css) { 18 | return { type: 'color', value: { css } } 19 | } 20 | return undefined 21 | } 22 | 23 | const getOptional = (value?: Value) => { 24 | if (!value) { 25 | return undefined 26 | } 27 | if ( 28 | value.type.type === 'constructor' && 29 | value.type.name === 'Optional' && 30 | value.memory.type === 'enum' && 31 | value.memory.value === 'value' && 32 | value.memory.data.length === 1 33 | ) { 34 | return value.memory.data[0] 35 | } 36 | return undefined 37 | } 38 | 39 | const getFontWeight = (value?: Value): TokenAST.FontWeight | undefined => { 40 | if (!value) { 41 | return undefined 42 | } 43 | if ( 44 | value.type.type !== 'constructor' || 45 | value.type.name !== 'FontWeight' || 46 | value.memory.type !== 'enum' 47 | ) { 48 | return undefined 49 | } 50 | 51 | switch (value.memory.value) { 52 | case 'ultraLight': 53 | return '100' 54 | case 'thin': 55 | return '200' 56 | case 'light': 57 | return '300' 58 | case 'regular': 59 | return '400' 60 | case 'medium': 61 | return '500' 62 | case 'semibold': 63 | return '600' 64 | case 'bold': 65 | return '700' 66 | case 'heavy': 67 | return '800' 68 | case 'black': 69 | return '900' 70 | default: { 71 | throw new Error('Bad FontWeight: ' + value.memory.value) 72 | } 73 | } 74 | } 75 | 76 | const getShadowValue = ( 77 | value?: Value 78 | ): TokenAST.ShadowTokenValue | undefined => { 79 | if (!value) { 80 | return undefined 81 | } 82 | if ( 83 | value.type.type !== 'constructor' || 84 | value.type.name !== 'Shadow' || 85 | value.memory.type !== 'record' 86 | ) { 87 | return undefined 88 | } 89 | 90 | const fields = value.memory 91 | 92 | const [x, y, blur, radius] = ['x', 'y', 'blur', 'radius'] 93 | .map(x => getField(x, fields)) 94 | .map(x => (x && x.memory.type === 'number' ? x.memory.value : 0)) 95 | let color: TokenAST.ColorValue | undefined 96 | if (fields.value['color']) { 97 | const colorValue = getColorValue(fields.value['color']) 98 | if (colorValue) { 99 | color = colorValue.value 100 | } 101 | } 102 | if (!color) { 103 | color = { css: 'black' } 104 | } 105 | return { type: 'shadow', value: { x, y, blur, radius, color } } 106 | } 107 | 108 | const getTextStyleValue = ( 109 | value?: Value 110 | ): TokenAST.TextStyleTokenValue | undefined => { 111 | if (!value) { 112 | return undefined 113 | } 114 | if ( 115 | value.type.type !== 'constructor' || 116 | value.type.name !== 'TextStyle' || 117 | value.memory.type !== 'record' 118 | ) { 119 | return undefined 120 | } 121 | 122 | const fields = value.memory 123 | 124 | const [fontSize, lineHeight, letterSpacing] = [ 125 | 'fontSize', 126 | 'lineHeight', 127 | 'letterSpacing', 128 | ] 129 | .map(x => getOptional(getField(x, fields))) 130 | .map(x => (x && x.memory.type === 'number' ? x.memory.value : undefined)) 131 | const [fontName, fontFamily] = ['fontName', 'fontFamily'] 132 | .map(x => getOptional(getField(x, fields))) 133 | .map(x => (x && x.memory.type === 'string' ? x.memory.value : undefined)) 134 | const [color] = ['color'] 135 | .map(x => getColorValue(getOptional(getField(x, fields)))) 136 | .map(x => (x ? x.value : undefined)) 137 | const [fontWeight] = ['fontWeight'] 138 | .map(x => getField(x, fields)) 139 | .map(x => getFontWeight(x) || '400') 140 | 141 | return { 142 | type: 'textStyle', 143 | value: { 144 | fontFamily, 145 | fontWeight, 146 | fontSize, 147 | lineHeight, 148 | letterSpacing, 149 | fontName, 150 | color, 151 | }, 152 | } 153 | } 154 | 155 | export const create = (value?: Value): TokenAST.TokenValue | undefined => 156 | getColorValue(value) || 157 | getShadowValue(value) || 158 | getTextStyleValue(value) || 159 | undefined 160 | -------------------------------------------------------------------------------- /src/plugins/documentation/convert.ts: -------------------------------------------------------------------------------- 1 | import * as serialization from '@lona/serialization' 2 | 3 | import { Helpers } from '../../helpers' 4 | import { convertDeclaration } from '../tokens/convert' 5 | import { Token } from '../tokens/tokensAst' 6 | import { assertNever, nonNullable } from '../../utils/typeHelpers' 7 | 8 | let tokenNameElement = (kind: string, content: string) => 9 | `${content}` 10 | 11 | let tokenValueElement = (kind: string, content: string) => 12 | `${content}` 13 | 14 | let tokenContainerElement = (kind: string, content: string[]) => 15 | `
16 | ${content.join('\n ')} 17 |
` 18 | 19 | let tokenDetailsElement = (kind: string, content: string[]) => 20 | `
21 | ${content.join('\n ')} 22 |
` 23 | 24 | let tokenPreviewElement = ( 25 | kind: string, 26 | data: { [key: string]: string | void } 27 | ) => 28 | `
(data[k] ? `data-${k}="${data[k]}"` : undefined)) 32 | .filter(x => !!x) 33 | .join(' ')}>
` 34 | 35 | const convertToken = (token: Token): string => { 36 | const tokenName = token.qualifiedName.join('.') 37 | 38 | if (token.value.type === 'color') { 39 | return tokenContainerElement(token.value.type, [ 40 | tokenPreviewElement(token.value.type, { color: token.value.value.css }), 41 | tokenDetailsElement(token.value.type, [ 42 | tokenNameElement(token.value.type, tokenName), 43 | tokenValueElement(token.value.type, token.value.value.css), 44 | ]), 45 | ]) 46 | } 47 | 48 | if (token.value.type === 'shadow') { 49 | return tokenContainerElement(token.value.type, [ 50 | tokenPreviewElement(token.value.type, { 51 | x: `${token.value.value.x}`, 52 | y: `${token.value.value.y}`, 53 | blur: `${token.value.value.blur}`, 54 | radius: `${token.value.value.radius}`, 55 | color: `${token.value.value.color.css}`, 56 | }), 57 | tokenDetailsElement(token.value.type, [ 58 | tokenNameElement(token.value.type, tokenName), 59 | tokenValueElement( 60 | token.value.type, 61 | `${token.value.value.x}px ${token.value.value.y}px ${token.value.value.blur}px ${token.value.value.radius}px ${token.value.value.color.css}` 62 | ), 63 | ]), 64 | ]) 65 | } 66 | 67 | if (token.value.type === 'textStyle') { 68 | const { value } = token.value 69 | return tokenContainerElement(token.value.type, [ 70 | tokenPreviewElement(token.value.type, { 71 | fontFamily: value.fontFamily, 72 | fontWeight: value.fontWeight, 73 | fontSize: 74 | typeof value.fontSize !== 'undefined' 75 | ? `${value.fontSize}` 76 | : undefined, 77 | lineHeight: 78 | typeof value.lineHeight !== 'undefined' 79 | ? `${value.lineHeight}` 80 | : undefined, 81 | letterSpacing: 82 | typeof value.letterSpacing !== 'undefined' 83 | ? `${value.letterSpacing}` 84 | : undefined, 85 | color: value.color ? `${value.color.css}` : undefined, 86 | }), 87 | tokenDetailsElement(token.value.type, [ 88 | tokenNameElement(token.value.type, tokenName), 89 | tokenValueElement( 90 | token.value.type, 91 | `${value.fontFamily} ${value.fontWeight}${ 92 | typeof value.fontSize !== 'undefined' ? ` ${value.fontSize}px` : '' 93 | }${ 94 | typeof value.lineHeight !== 'undefined' 95 | ? ` ${value.lineHeight}px` 96 | : '' 97 | }${ 98 | typeof value.letterSpacing !== 'undefined' 99 | ? ` ${value.letterSpacing}px` 100 | : '' 101 | }${value.color ? ` ${value.color.css}` : ''}` 102 | ), 103 | ]), 104 | ]) 105 | } 106 | 107 | assertNever(token.value) 108 | } 109 | 110 | export const convert = ( 111 | root: { children: serialization.MDXAST.Content[] }, 112 | helpers: Helpers 113 | ) => { 114 | return root.children 115 | .map(child => { 116 | if (child.type === 'code' && child.data.parsed) { 117 | return child.data.parsed.data.declarations 118 | .map(x => convertDeclaration(x, helpers)) 119 | .filter(nonNullable) 120 | .map(convertToken) 121 | .join('') 122 | } 123 | return serialization.printMdxNode(child) 124 | }) 125 | .join('\n\n') 126 | } 127 | -------------------------------------------------------------------------------- /src/plugins/tokens/__tests__/__snapshots__/tokens.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Tokens converts workspace 1`] = ` 4 | Object { 5 | "files": Array [ 6 | Object { 7 | "contents": Object { 8 | "type": "flatTokens", 9 | "value": Array [ 10 | Object { 11 | "qualifiedName": Array [ 12 | "primary", 13 | ], 14 | "value": Object { 15 | "type": "color", 16 | "value": Object { 17 | "css": "#45CBFF", 18 | }, 19 | }, 20 | }, 21 | Object { 22 | "qualifiedName": Array [ 23 | "accent", 24 | ], 25 | "value": Object { 26 | "type": "color", 27 | "value": Object { 28 | "css": "#45CBFF", 29 | }, 30 | }, 31 | }, 32 | Object { 33 | "qualifiedName": Array [ 34 | "testSaturate", 35 | ], 36 | "value": Object { 37 | "type": "color", 38 | "value": Object { 39 | "css": "#29D7FF", 40 | }, 41 | }, 42 | }, 43 | ], 44 | }, 45 | "inputPath": "Colors.md", 46 | "name": "Colors", 47 | "outputPath": "Colors.flat.json", 48 | }, 49 | Object { 50 | "contents": Object { 51 | "type": "flatTokens", 52 | "value": Array [], 53 | }, 54 | "inputPath": "README.md", 55 | "name": "README", 56 | "outputPath": "README.flat.json", 57 | }, 58 | Object { 59 | "contents": Object { 60 | "type": "flatTokens", 61 | "value": Array [ 62 | Object { 63 | "qualifiedName": Array [ 64 | "small", 65 | ], 66 | "value": Object { 67 | "type": "shadow", 68 | "value": Object { 69 | "blur": 2, 70 | "color": Object { 71 | "css": "#45CBFF", 72 | }, 73 | "radius": 0, 74 | "x": 0, 75 | "y": 2, 76 | }, 77 | }, 78 | }, 79 | ], 80 | }, 81 | "inputPath": "Shadows.md", 82 | "name": "Shadows", 83 | "outputPath": "Shadows.flat.json", 84 | }, 85 | Object { 86 | "contents": Object { 87 | "type": "flatTokens", 88 | "value": Array [ 89 | Object { 90 | "qualifiedName": Array [ 91 | "heading1", 92 | ], 93 | "value": Object { 94 | "type": "textStyle", 95 | "value": Object { 96 | "color": Object { 97 | "css": "teal", 98 | }, 99 | "fontFamily": "Helvetica", 100 | "fontSize": 28, 101 | "fontWeight": "700", 102 | }, 103 | }, 104 | }, 105 | ], 106 | }, 107 | "inputPath": "TextStyles.md", 108 | "name": "TextStyles", 109 | "outputPath": "TextStyles.flat.json", 110 | }, 111 | ], 112 | "flatTokensSchemaVersion": "0.0.1", 113 | } 114 | `; 115 | 116 | exports[`Tokens generates tokens 1`] = ` 117 | Object { 118 | "files": Array [ 119 | Object { 120 | "contents": Object { 121 | "type": "flatTokens", 122 | "value": Array [ 123 | Object { 124 | "qualifiedName": Array [ 125 | "color", 126 | ], 127 | "value": Object { 128 | "type": "color", 129 | "value": Object { 130 | "css": "pink", 131 | }, 132 | }, 133 | }, 134 | ], 135 | }, 136 | "inputPath": "Colors.md", 137 | "name": "Colors", 138 | "outputPath": "Colors.flat.json", 139 | }, 140 | ], 141 | "flatTokensSchemaVersion": "0.0.1", 142 | } 143 | `; 144 | 145 | exports[`Tokens generates tokens with function call 1`] = ` 146 | Object { 147 | "files": Array [ 148 | Object { 149 | "contents": Object { 150 | "type": "flatTokens", 151 | "value": Array [ 152 | Object { 153 | "qualifiedName": Array [ 154 | "testSaturate", 155 | ], 156 | "value": Object { 157 | "type": "color", 158 | "value": Object { 159 | "css": "#FFB7C5", 160 | }, 161 | }, 162 | }, 163 | ], 164 | }, 165 | "inputPath": "Colors.md", 166 | "name": "Colors", 167 | "outputPath": "Colors.flat.json", 168 | }, 169 | ], 170 | "flatTokensSchemaVersion": "0.0.1", 171 | } 172 | `; 173 | -------------------------------------------------------------------------------- /src/logic/nodes/literals.ts: -------------------------------------------------------------------------------- 1 | import { LogicAST as AST } from '@lona/serialization' 2 | import { EvaluationVisitor } from '../evaluationVisitor' 3 | import { bool, number, string, unit, color, StaticType } from '../staticType' 4 | import { TypeCheckerVisitor } from '../typeChecker' 5 | import { ILiteral, Node, IExpression } from './interfaces' 6 | import { Encode } from '../runtime/value' 7 | import { substitute } from '../typeUnifier' 8 | import { createExpressionNode } from './createNode' 9 | import { compact } from '../../utils/sequence' 10 | 11 | export class NoneLiteral extends Node implements ILiteral { 12 | typeCheckerEnter(visitor: TypeCheckerVisitor): void {} 13 | 14 | typeCheckerLeave(visitor: TypeCheckerVisitor): void { 15 | visitor.setType(this.syntaxNode.data.id, unit) 16 | } 17 | 18 | evaluationEnter(visitor: EvaluationVisitor): void { 19 | visitor.addValue(this.syntaxNode.data.id, Encode.unit()) 20 | } 21 | } 22 | 23 | export class BooleanLiteral extends Node 24 | implements ILiteral { 25 | get value(): boolean { 26 | return this.syntaxNode.data.value 27 | } 28 | 29 | typeCheckerEnter(visitor: TypeCheckerVisitor): void {} 30 | 31 | typeCheckerLeave(visitor: TypeCheckerVisitor): void { 32 | visitor.setType(this.syntaxNode.data.id, bool) 33 | } 34 | 35 | evaluationEnter(visitor: EvaluationVisitor): void { 36 | const { value, id } = this.syntaxNode.data 37 | visitor.addValue(id, Encode.bool(value)) 38 | } 39 | } 40 | 41 | export class NumberLiteral extends Node implements ILiteral { 42 | get value(): number { 43 | return this.syntaxNode.data.value 44 | } 45 | 46 | typeCheckerEnter(visitor: TypeCheckerVisitor): void {} 47 | 48 | typeCheckerLeave(visitor: TypeCheckerVisitor): void { 49 | visitor.setType(this.syntaxNode.data.id, number) 50 | } 51 | 52 | evaluationEnter(visitor: EvaluationVisitor): void { 53 | const { value, id } = this.syntaxNode.data 54 | visitor.addValue(id, Encode.number(value)) 55 | } 56 | } 57 | 58 | export class StringLiteral extends Node implements ILiteral { 59 | get value(): string { 60 | return this.syntaxNode.data.value 61 | } 62 | 63 | typeCheckerEnter(visitor: TypeCheckerVisitor): void {} 64 | 65 | typeCheckerLeave(visitor: TypeCheckerVisitor): void { 66 | visitor.setType(this.syntaxNode.data.id, string) 67 | } 68 | 69 | evaluationEnter(visitor: EvaluationVisitor): void { 70 | const { value, id } = this.syntaxNode.data 71 | visitor.addValue(id, Encode.string(value)) 72 | } 73 | } 74 | 75 | export class ColorLiteral extends Node implements ILiteral { 76 | typeCheckerEnter(visitor: TypeCheckerVisitor): void {} 77 | 78 | typeCheckerLeave(visitor: TypeCheckerVisitor): void { 79 | visitor.setType(this.syntaxNode.data.id, color) 80 | } 81 | 82 | evaluationEnter(visitor: EvaluationVisitor): void { 83 | const { value, id } = this.syntaxNode.data 84 | visitor.addValue(id, Encode.color(value)) 85 | } 86 | } 87 | 88 | export class ArrayLiteral extends Node implements ILiteral { 89 | get elements(): IExpression[] { 90 | return compact(this.syntaxNode.data.value.map(createExpressionNode)) 91 | } 92 | 93 | typeCheckerEnter(visitor: TypeCheckerVisitor): void {} 94 | 95 | typeCheckerLeave(visitor: TypeCheckerVisitor): void { 96 | const { id, value } = this.syntaxNode.data 97 | const { typeChecker } = visitor 98 | 99 | const elementType: StaticType = { 100 | type: 'variable', 101 | value: typeChecker.typeNameGenerator.next(), 102 | } 103 | 104 | visitor.setType(id, { 105 | type: 'constructor', 106 | name: 'Array', 107 | parameters: [elementType], 108 | }) 109 | 110 | const constraints = value.map(expression => ({ 111 | head: elementType, 112 | tail: visitor.getType(expression.data.id) || { 113 | type: 'variable', 114 | value: typeChecker.typeNameGenerator.next(), 115 | }, 116 | origin: this.syntaxNode, 117 | })) 118 | 119 | typeChecker.constraints = typeChecker.constraints.concat(constraints) 120 | } 121 | 122 | evaluationEnter(visitor: EvaluationVisitor): void { 123 | const { id, value } = this.syntaxNode.data 124 | const { typeChecker, reporter, substitution } = visitor 125 | 126 | const type = typeChecker.nodes[id] 127 | 128 | if (!type) { 129 | reporter.error('Failed to unify type of array') 130 | return 131 | } 132 | 133 | const resolvedType = substitute(substitution, type) 134 | 135 | const dependencies = value 136 | .filter(x => x.type !== 'placeholder') 137 | .map(x => x.data.id) 138 | 139 | visitor.add(id, { 140 | label: 'Array Literal', 141 | dependencies, 142 | f: values => Encode.array(resolvedType, values), 143 | }) 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/plugins/swift/__tests__/__snapshots__/typeGeneration.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`converts Swift language 1`] = ` 4 | "import Foundation 5 | 6 | #if canImport(UIKit) 7 | import UIKit 8 | #elseif canImport(AppKit) 9 | import AppKit 10 | #endif 11 | 12 | public indirect enum AccessLevelModifier: Codable { 13 | case privateModifier 14 | case publicModifier 15 | fileprivate enum _AccessLevelModifierType: String, Codable { 16 | case privateModifier 17 | case publicModifier 18 | } 19 | fileprivate struct _TypeContainer: Codable { 20 | let type: _AccessLevelModifierType 21 | } 22 | fileprivate struct _PrivateModifier: Codable { 23 | let type: _AccessLevelModifierType 24 | } 25 | fileprivate struct _PublicModifier: Codable { 26 | let type: _AccessLevelModifierType 27 | } 28 | public init(from decoder: Decoder) throws { 29 | let container = try decoder.singleValueContainer() 30 | switch try container.decode(_TypeContainer.self).type { 31 | case _AccessLevelModifierType.privateModifier: 32 | self = AccessLevelModifier.privateModifier 33 | case _AccessLevelModifierType.publicModifier: 34 | self = AccessLevelModifier.publicModifier 35 | } 36 | } 37 | public func encode(to encoder: Encoder) throws { 38 | var container = encoder.singleValueContainer() 39 | switch self { 40 | case AccessLevelModifier.privateModifier: 41 | try container.encode(_PrivateModifier(type: _AccessLevelModifierType.privateModifier)) 42 | case AccessLevelModifier.publicModifier: 43 | try container.encode(_PublicModifier(type: _AccessLevelModifierType.publicModifier)) 44 | } 45 | } 46 | } 47 | public indirect enum Literal: Codable { 48 | case \`nil\` 49 | case floatingPoint(CGFloat) 50 | case array(Array) 51 | fileprivate enum _LiteralType: String, Codable { 52 | case \`nil\` 53 | case floatingPoint 54 | case array 55 | } 56 | fileprivate struct _TypeContainer: Codable { 57 | let type: _LiteralType 58 | } 59 | fileprivate struct _Nil: Codable { 60 | let type: _LiteralType 61 | } 62 | fileprivate struct _FloatingPoint: Codable { 63 | let type: _LiteralType 64 | let value: CGFloat 65 | } 66 | fileprivate struct _Array: Codable { 67 | let type: _LiteralType 68 | let value: Array 69 | } 70 | public init(from decoder: Decoder) throws { 71 | let container = try decoder.singleValueContainer() 72 | switch try container.decode(_TypeContainer.self).type { 73 | case _LiteralType.nil: 74 | self = Literal.nil 75 | case _LiteralType.floatingPoint: 76 | let decoded = try container.decode(_FloatingPoint.self) 77 | self = Literal.floatingPoint(decoded.value) 78 | case _LiteralType.array: 79 | let decoded = try container.decode(_Array.self) 80 | self = Literal.array(decoded.value) 81 | } 82 | } 83 | public func encode(to encoder: Encoder) throws { 84 | var container = encoder.singleValueContainer() 85 | switch self { 86 | case Literal.nil: 87 | try container.encode(_Nil(type: _LiteralType.nil)) 88 | case Literal.floatingPoint(let value): 89 | try container.encode(_FloatingPoint(type: _LiteralType.floatingPoint, value: value)) 90 | case Literal.array(let value): 91 | try container.encode(_Array(type: _LiteralType.array, value: value)) 92 | } 93 | } 94 | } 95 | public struct Identifier: Codable, Equatable { 96 | public init(id: String = \\"\\", name: String = \\"\\") { 97 | self.id = id 98 | self.name = name 99 | } 100 | public let id: String 101 | public let name: String 102 | } 103 | public indirect enum Declaration: Codable { 104 | case functionDeclaration(name: String, parameters: Array) 105 | fileprivate enum _DeclarationType: String, Codable { 106 | case functionDeclaration 107 | } 108 | fileprivate struct _TypeContainer: Codable { 109 | let type: _DeclarationType 110 | } 111 | fileprivate struct _FunctionDeclaration: Codable { 112 | let type: _DeclarationType 113 | let name: String 114 | let parameters: Array 115 | } 116 | public init(from decoder: Decoder) throws { 117 | let container = try decoder.singleValueContainer() 118 | switch try container.decode(_TypeContainer.self).type { 119 | case _DeclarationType.functionDeclaration: 120 | let decoded = try container.decode(_FunctionDeclaration.self) 121 | self = Declaration.functionDeclaration(name: decoded.name, parameters: decoded.parameters) 122 | } 123 | } 124 | public func encode(to encoder: Encoder) throws { 125 | var container = encoder.singleValueContainer() 126 | switch self { 127 | case Declaration.functionDeclaration(let name, let parameters): 128 | try container 129 | .encode(_FunctionDeclaration(type: _DeclarationType.functionDeclaration, name: name, parameters: parameters)) 130 | } 131 | } 132 | } 133 | public struct ExistingType: Codable, Equatable { 134 | public init(id: UUID = UUID()) { 135 | self.id = id 136 | } 137 | public let id: UUID 138 | } 139 | 140 | " 141 | `; 142 | -------------------------------------------------------------------------------- /src/logic/module.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import { 4 | decodeDocument, 5 | decodeLogic, 6 | extractProgramFromAST, 7 | LogicAST as AST, 8 | MDXAST, 9 | } from '@lona/serialization' 10 | import { IFS, match } from 'buffs' 11 | import { evaluate, EvaluationContext } from './evaluation' 12 | import { createNamespace, mergeNamespaces, Namespace } from './namespace' 13 | import { createScopeContext, mergeScopes, Scope } from './scope' 14 | import { createUnificationContext, TypeChecker } from './typeChecker' 15 | import { Substitution, unify } from './typeUnifier' 16 | import { joinPrograms, makeProgram } from './ast' 17 | 18 | export type LogicFile = { 19 | isLibrary: boolean 20 | sourcePath: string 21 | rootNode: AST.TopLevelDeclarations 22 | mdxContent: MDXAST.Content[] 23 | } 24 | 25 | export type ModuleContext = { 26 | componentFiles: LogicFile[] 27 | libraryFiles: LogicFile[] 28 | documentFiles: LogicFile[] 29 | logicFiles: LogicFile[] 30 | sourceFiles: LogicFile[] 31 | namespace: Namespace 32 | scope: Scope 33 | typeChecker: TypeChecker 34 | substitution: Substitution 35 | evaluationContext: EvaluationContext 36 | isValidProgram: boolean 37 | } 38 | 39 | export function createModule( 40 | workspaceFs: IFS, 41 | workspacePath: string 42 | ): ModuleContext { 43 | const libraryFiles: LogicFile[] = libraryFilePaths().map(sourcePath => ({ 44 | isLibrary: true, 45 | sourcePath, 46 | // Always read library files from the real FS 47 | rootNode: decodeLogic( 48 | fs.readFileSync(sourcePath, 'utf8') 49 | ) as AST.TopLevelDeclarations, 50 | mdxContent: [], 51 | })) 52 | 53 | const componentFiles: LogicFile[] = componentFilePaths( 54 | workspaceFs, 55 | workspacePath 56 | ).map(sourcePath => ({ 57 | isLibrary: false, 58 | sourcePath, 59 | rootNode: decodeLogic( 60 | workspaceFs.readFileSync(sourcePath, 'utf8') 61 | ) as AST.TopLevelDeclarations, 62 | mdxContent: [], 63 | })) 64 | 65 | const logicFiles: LogicFile[] = logicFilePaths( 66 | workspaceFs, 67 | workspacePath 68 | ).map(sourcePath => ({ 69 | isLibrary: false, 70 | sourcePath, 71 | rootNode: decodeLogic( 72 | workspaceFs.readFileSync(sourcePath, 'utf8') 73 | ) as AST.TopLevelDeclarations, 74 | mdxContent: [], 75 | })) 76 | 77 | const documentFiles: LogicFile[] = documentFilePaths( 78 | workspaceFs, 79 | workspacePath 80 | ).map(sourcePath => { 81 | const decoded = decodeDocument(workspaceFs.readFileSync(sourcePath, 'utf8')) 82 | 83 | return { 84 | isLibrary: false, 85 | sourcePath, 86 | rootNode: extractProgramFromAST(decoded), 87 | mdxContent: decoded.children, 88 | } 89 | }) 90 | 91 | const files: LogicFile[] = [ 92 | ...libraryFiles, 93 | ...componentFiles, 94 | ...documentFiles, 95 | ...logicFiles, 96 | ] 97 | 98 | const namespace: Namespace = mergeNamespaces( 99 | files.map(logicFile => createNamespace(logicFile.rootNode)) 100 | ) 101 | 102 | const scope: Scope = mergeScopes( 103 | files.map(logicFile => 104 | createScopeContext(logicFile.rootNode, namespace, undefined, console) 105 | ) 106 | ) 107 | 108 | const programNode = joinPrograms( 109 | files.map(logicFile => makeProgram(logicFile.rootNode)) 110 | ) 111 | 112 | const typeChecker = createUnificationContext(programNode, scope, console) 113 | 114 | const substitution = unify(typeChecker.constraints, console) 115 | 116 | const evaluationContext = evaluate( 117 | programNode, 118 | namespace, 119 | scope, 120 | typeChecker, 121 | substitution, 122 | console 123 | ) 124 | 125 | return { 126 | componentFiles, 127 | libraryFiles, 128 | documentFiles, 129 | logicFiles, 130 | get sourceFiles() { 131 | return [...documentFiles, ...logicFiles] 132 | }, 133 | namespace, 134 | scope, 135 | typeChecker, 136 | substitution, 137 | evaluationContext, 138 | isValidProgram: 139 | scope.undefinedIdentifierExpressions.size === 0 && 140 | scope.undefinedMemberExpressions.size === 0 && 141 | scope.undefinedTypeIdentifiers.size === 0, 142 | } 143 | } 144 | 145 | function componentFilePaths(fs: IFS, workspacePath: string): string[] { 146 | return match(fs, workspacePath, { includePatterns: ['**/*.cmp'] }).map(file => 147 | path.join(workspacePath, file) 148 | ) 149 | } 150 | 151 | function logicFilePaths(fs: IFS, workspacePath: string): string[] { 152 | return match(fs, workspacePath, { 153 | includePatterns: ['**/*.logic'], 154 | }).map(file => path.join(workspacePath, file)) 155 | } 156 | 157 | function documentFilePaths(fs: IFS, workspacePath: string): string[] { 158 | return match(fs, workspacePath, { includePatterns: ['**/*.md'] }).map(file => 159 | path.join(workspacePath, file) 160 | ) 161 | } 162 | 163 | export function libraryFilePaths(): string[] { 164 | return match(fs, STANDARD_LIBRARY_PATH, { 165 | includePatterns: ['**/*.logic'], 166 | }).map(file => path.join(STANDARD_LIBRARY_PATH, file)) 167 | } 168 | 169 | export const STANDARD_LIBRARY_PATH = path.join(__dirname, '../../static/logic') 170 | -------------------------------------------------------------------------------- /static/swift/TextStyle.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | #if canImport(UIKit) 4 | import UIKit 5 | #elseif canImport(AppKit) 6 | import AppKit 7 | #endif 8 | 9 | public class TextStyle { 10 | public let fontFamily: String? 11 | public let fontName: String? 12 | public let fontWeight: Font.Weight 13 | public let fontSize: CGFloat 14 | public let lineHeight: CGFloat? 15 | public let kerning: Double 16 | public let color: Color? 17 | public let alignment: NSTextAlignment 18 | 19 | public init( 20 | fontFamily: String? = nil, 21 | fontName: String? = nil, 22 | fontSize: CGFloat? = nil, 23 | fontWeight: Font.Weight? = nil, 24 | lineHeight: CGFloat? = nil, 25 | kerning: Double? = nil, 26 | color: Color? = nil, 27 | alignment: NSTextAlignment? = nil) { 28 | self.fontFamily = fontFamily 29 | self.fontName = fontName 30 | self.fontWeight = fontWeight ?? Font.Weight.regular 31 | self.fontSize = fontSize ?? Font.systemFontSize 32 | self.lineHeight = lineHeight 33 | self.kerning = kerning ?? 0 34 | self.color = color 35 | self.alignment = alignment ?? NSTextAlignment.left 36 | } 37 | 38 | public func with( 39 | fontFamily: String? = nil, 40 | fontName: String? = nil, 41 | fontSize: CGFloat? = nil, 42 | fontWeight: Font.Weight? = nil, 43 | lineHeight: CGFloat? = nil, 44 | kerning: Double? = nil, 45 | color: Color? = nil, 46 | alignment: NSTextAlignment? = nil 47 | ) -> TextStyle { 48 | return TextStyle( 49 | fontFamily: fontFamily ?? self.fontFamily, 50 | fontName: fontName ?? self.fontName, 51 | fontSize: fontSize ?? self.fontSize, 52 | fontWeight: fontWeight ?? self.fontWeight, 53 | lineHeight: lineHeight ?? self.lineHeight, 54 | kerning: kerning ?? self.kerning, 55 | color: color ?? self.color, 56 | alignment: alignment ?? self.alignment) 57 | } 58 | 59 | public lazy var paragraphStyle: NSMutableParagraphStyle = { 60 | let paragraphStyle = NSMutableParagraphStyle() 61 | if let lineHeight = lineHeight { 62 | paragraphStyle.minimumLineHeight = lineHeight 63 | paragraphStyle.maximumLineHeight = lineHeight 64 | } 65 | paragraphStyle.alignment = alignment 66 | return paragraphStyle 67 | }() 68 | 69 | public lazy var fontDescriptor: FontDescriptor = { 70 | var attributes: [FontDescriptor.AttributeName: Any] = [:] 71 | var fontFamily = self.fontFamily 72 | 73 | if fontFamily == nil && fontName == nil { 74 | fontFamily = Font.systemFont(ofSize: Font.systemFontSize).familyName 75 | } 76 | 77 | if let fontFamily = fontFamily { 78 | attributes[FontDescriptor.AttributeName.family] = fontFamily 79 | } 80 | 81 | if let fontName = fontName { 82 | attributes[FontDescriptor.AttributeName.name] = fontName 83 | } 84 | 85 | attributes[FontDescriptor.AttributeName.traits] = [ 86 | FontDescriptor.TraitKey.weight: fontWeight 87 | ] 88 | 89 | return FontDescriptor(fontAttributes: attributes) 90 | }() 91 | 92 | public lazy var font: Font = { 93 | #if canImport(UIKit) 94 | return Font(descriptor: fontDescriptor, size: fontSize) 95 | #elseif canImport(AppKit) 96 | // NSFont can return nil as opposed to UIFont 97 | return Font(descriptor: fontDescriptor, size: fontSize) ?? 98 | Font.systemFont(ofSize: fontSize, weight: fontWeight) 99 | #endif 100 | }() 101 | 102 | public lazy var attributeDictionary: [NSAttributedString.Key: Any] = { 103 | var attributes: [NSAttributedString.Key: Any] = [ 104 | .font: font, 105 | .kern: kerning, 106 | .paragraphStyle: paragraphStyle 107 | ] 108 | 109 | if let lineHeight = lineHeight { 110 | attributes[.baselineOffset] = (lineHeight - font.ascender + font.descender) / 2 111 | } 112 | 113 | if let color = color { 114 | attributes[.foregroundColor] = color 115 | } 116 | 117 | return attributes 118 | }() 119 | 120 | public func apply(to string: String) -> NSAttributedString { 121 | return NSAttributedString( 122 | string: string, 123 | attributes: attributeDictionary) 124 | } 125 | 126 | public func apply(to attributedString: NSAttributedString) -> NSAttributedString { 127 | let styledString = NSMutableAttributedString(attributedString: attributedString) 128 | styledString.addAttributes( 129 | attributeDictionary, 130 | range: NSRange(location: 0, length: styledString.length)) 131 | return styledString 132 | } 133 | 134 | public func apply(to attributedString: NSMutableAttributedString, at range: NSRange) { 135 | attributedString.addAttributes( 136 | attributeDictionary, 137 | range: range) 138 | } 139 | } 140 | 141 | // MARK: - Equatable 142 | 143 | extension TextStyle: Equatable { 144 | public static func == (lhs: TextStyle, rhs: TextStyle) -> Bool { 145 | return ( 146 | lhs.fontFamily == rhs.fontFamily && 147 | lhs.fontName == rhs.fontName && 148 | lhs.fontWeight == rhs.fontWeight && 149 | lhs.fontSize == rhs.fontSize && 150 | lhs.lineHeight == rhs.lineHeight && 151 | lhs.kerning == rhs.kerning && 152 | lhs.color == rhs.color && 153 | lhs.alignment == rhs.alignment) 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/plugins/js/jsAst.ts: -------------------------------------------------------------------------------- 1 | export enum binaryOperator { 2 | Eq, 3 | LooseEq, 4 | Neq, 5 | LooseNeq, 6 | Gt, 7 | Gte, 8 | Lt, 9 | Lte, 10 | Plus, 11 | Minus, 12 | And, 13 | Or, 14 | Noop, 15 | } 16 | 17 | /* Types */ 18 | type InterfaceDeclaration = { 19 | type: 'interfaceDeclaration' 20 | data: { 21 | identifier: string 22 | typeParameters: JSType[] 23 | objectType: ObjectType 24 | } 25 | } 26 | 27 | type TypeAliasDeclaration = { 28 | type: 'typeAliasDeclaration' 29 | data: { 30 | identifier: string 31 | typeParameters: JSType[] 32 | type: JSType 33 | } 34 | } 35 | 36 | export type JSType = 37 | | { type: 'LiteralType'; data: string } 38 | | { type: 'UnionType'; data: JSType[] } 39 | /* | IntersectionType 40 | | FunctionType 41 | | ConstructorType 42 | | ParenthesizedType 43 | | PredefinedType(predefinedType)*/ 44 | | { type: 'TypeReference'; data: TypeReference } 45 | | { type: 'ObjectType'; data: ObjectType } 46 | /* | ArrayType */ 47 | | { type: 'TupleType'; data: JSType[] } 48 | /* | TypeQuery 49 | | ThisType */ 50 | /* and predefinedType = 51 | | Any 52 | | Number 53 | | Boolean 54 | | String 55 | | Symbol 56 | | Void */ 57 | 58 | export type ObjectType = { members: TypeMember[] } 59 | 60 | export type TypeReference = { 61 | name: string 62 | arguments: JSType[] 63 | } 64 | 65 | type TypeMember = { type: 'PropertySignature'; data: PropertySignature } 66 | 67 | type PropertySignature = { 68 | name: string 69 | type?: JSType 70 | } 71 | 72 | /* JS */ 73 | type ImportDeclaration = { 74 | source: string 75 | specifiers: { type: 'ImportSpecifier'; data: ImportSpecifier }[] 76 | } 77 | type ImportSpecifier = { 78 | imported: string 79 | local?: string 80 | } 81 | type ClassDeclaration = { 82 | id: string 83 | superClass?: string 84 | body: JSNode[] 85 | } 86 | type MethodDefinition = { 87 | key: string 88 | value: JSNode 89 | } 90 | type FunctionExpression = { 91 | id?: string 92 | params: JSNode[] 93 | body: JSNode[] 94 | } 95 | type CallExpression = { 96 | callee: JSNode 97 | arguments: JSNode[] 98 | } 99 | type MemberExpression = { 100 | memberName: string 101 | expression: JSNode 102 | } 103 | type JSXAttribute = { 104 | name: string 105 | value: JSNode 106 | } 107 | type JSXElement = { 108 | tag: string 109 | attributes: JSNode[] 110 | content: JSNode[] 111 | } 112 | type AssignmentExpression = { 113 | left: JSNode 114 | right: JSNode 115 | } 116 | type BinaryExpression = { 117 | left: JSNode 118 | operator: binaryOperator 119 | right: JSNode 120 | } 121 | type UnaryExpression = { 122 | prefix: boolean 123 | operator: string 124 | argument: JSNode 125 | } 126 | type IfStatement = { 127 | test: JSNode 128 | consequent: JSNode[] 129 | alternate: JSNode[] 130 | } 131 | type WhileStatement = { 132 | test: JSNode 133 | body: JSNode[] 134 | } 135 | type ConditionalExpression = { 136 | test: JSNode 137 | consequent: JSNode 138 | alternate: JSNode 139 | } 140 | type Property = { 141 | key: JSNode 142 | value?: JSNode 143 | } 144 | type LineEndComment = { 145 | comment: string 146 | line: JSNode 147 | } 148 | 149 | export type Literal = 150 | | { type: 'Null'; data: undefined } 151 | | { type: 'Undefined'; data: undefined } 152 | | { type: 'Boolean'; data: boolean } 153 | | { type: 'Number'; data: number } 154 | | { type: 'String'; data: string } 155 | | { type: 'Color'; data: string } 156 | | { type: 'Image'; data: string } 157 | | { type: 'Array'; data: JSNode[] } 158 | | { type: 'Object'; data: JSNode[] } 159 | 160 | export type JSNode = 161 | /* Types */ 162 | | { type: 'InterfaceDeclaration'; data: InterfaceDeclaration } 163 | | { type: 'TypeAliasDeclaration'; data: TypeAliasDeclaration } 164 | /* JS */ 165 | | { type: 'Return'; data: JSNode } 166 | | { type: 'Literal'; data: Literal } 167 | | { type: 'Identifier'; data: string[] } 168 | | { type: 'ImportDeclaration'; data: ImportDeclaration } 169 | | { type: 'ClassDeclaration'; data: ClassDeclaration } 170 | | { type: 'MethodDefinition'; data: MethodDefinition } 171 | | { type: 'FunctionExpression'; data: FunctionExpression } 172 | | { type: 'ArrowFunctionExpression'; data: FunctionExpression } 173 | | { type: 'CallExpression'; data: CallExpression } 174 | | { type: 'MemberExpression'; data: MemberExpression } 175 | | { type: 'JSXAttribute'; data: JSXAttribute } 176 | | { type: 'JSXElement'; data: JSXElement } 177 | | { type: 'JSXExpressionContainer'; data: JSNode } 178 | | { type: 'JSXSpreadAttribute'; data: JSNode } 179 | | { type: 'SpreadElement'; data: JSNode } 180 | | { type: 'VariableDeclaration'; data: JSNode } 181 | | { type: 'AssignmentExpression'; data: AssignmentExpression } 182 | | { type: 'BinaryExpression'; data: BinaryExpression } 183 | | { type: 'UnaryExpression'; data: UnaryExpression } 184 | | { type: 'IfStatement'; data: IfStatement } 185 | | { type: 'WhileStatement'; data: WhileStatement } 186 | | { type: 'ConditionalExpression'; data: ConditionalExpression } 187 | | { type: 'Property'; data: Property } 188 | | { 189 | type: 'ExportNamedDeclaration' 190 | data: { type: 'AssignmentExpression'; data: AssignmentExpression } 191 | } 192 | | { type: 'Program'; data: JSNode[] } 193 | | { type: 'LineEndComment'; data: LineEndComment } 194 | | { type: 'Empty' } 195 | | { type: 'Unknown' } 196 | -------------------------------------------------------------------------------- /src/logic/evaluation.ts: -------------------------------------------------------------------------------- 1 | import { LogicAST as AST } from '@lona/serialization' 2 | import { EvaluationVisitor } from './evaluationVisitor' 3 | import { createEvaluationVisitor } from './nodes/createNode' 4 | import { Value } from './runtime/value' 5 | import { Scope } from './scope' 6 | import { TypeChecker } from './typeChecker' 7 | import { Substitution } from './typeUnifier' 8 | import { Reporter } from '../utils/reporter' 9 | import { declarationPathTo } from './ast' 10 | import { assertNever } from '../utils/typeHelpers' 11 | import { Namespace } from './namespace' 12 | 13 | const STANDARD_LIBRARY = 'standard library' 14 | 15 | export function evaluateIsTrue( 16 | context: EvaluationContext, 17 | expression: AST.Expression 18 | ) { 19 | const condition = context.evaluate(expression.data.id) 20 | return ( 21 | (condition && 22 | condition.type.type === 'constructor' && 23 | condition.type.name === 'Boolean' && 24 | condition.memory.type === 'bool' && 25 | condition.memory.value) || 26 | false 27 | ) 28 | } 29 | 30 | export type Thunk = { 31 | label: string 32 | dependencies: string[] 33 | f: (args: Value[]) => Value 34 | } 35 | 36 | /** 37 | * The evaluation context of the Lona Workspace. 38 | */ 39 | export class EvaluationContext { 40 | values: { [uuid: string]: Value } = {} 41 | thunks: { [uuid: string]: Thunk } = {} 42 | reporter?: Reporter 43 | 44 | constructor(reporter?: Reporter) { 45 | this.reporter = reporter 46 | } 47 | 48 | add(uuid: string, thunk: Thunk) { 49 | this.thunks[uuid] = thunk 50 | } 51 | 52 | addValue(uuid: string, value: Value) { 53 | this.values[uuid] = value 54 | } 55 | 56 | /** 57 | * Evaluate the id to a value, resolving any dependency along the way 58 | */ 59 | evaluate( 60 | uuid: string, 61 | reporter: Reporter | undefined = this.reporter 62 | ): Value | undefined { 63 | const value = this.values[uuid] 64 | 65 | if (value) return value 66 | 67 | const thunk = this.thunks[uuid] 68 | 69 | if (!thunk) { 70 | reporter?.error(`no thunk for ${uuid}`) 71 | return undefined 72 | } 73 | 74 | const resolvedDependencies = thunk.dependencies.map(x => 75 | this.evaluate(x, reporter) 76 | ) 77 | 78 | if (resolvedDependencies.some(x => !x)) { 79 | reporter?.error( 80 | `Failed to evaluate thunk ${uuid} (${thunk.label}) - missing dep ${ 81 | thunk.dependencies[resolvedDependencies.findIndex(x => !x)] 82 | }` 83 | ) 84 | return undefined 85 | } 86 | 87 | const result = thunk.f(resolvedDependencies as Value[]) 88 | this.values[uuid] = result 89 | 90 | return result 91 | } 92 | 93 | copy() { 94 | const newContext = new EvaluationContext(this.reporter) 95 | newContext.thunks = { ...this.thunks } 96 | newContext.values = { ...this.values } 97 | return newContext 98 | } 99 | } 100 | 101 | const evaluateNode = ( 102 | node: AST.SyntaxNode, 103 | visitor: EvaluationVisitor 104 | ): EvaluationContext => { 105 | // TODO: Handle stopping 106 | const context = AST.subNodes(node).reduce( 107 | (prev, subNode) => { 108 | return evaluateNode(subNode, visitor) 109 | }, 110 | visitor.evaluation 111 | ) 112 | 113 | if (!context) return context 114 | 115 | switch (node.type) { 116 | case 'identifierExpression': 117 | case 'none': 118 | case 'boolean': 119 | case 'number': 120 | case 'string': 121 | case 'color': 122 | case 'array': 123 | case 'literalExpression': 124 | case 'memberExpression': 125 | case 'record': 126 | case 'variable': 127 | case 'enumeration': 128 | case 'function': 129 | case 'functionCallExpression': 130 | const visitorNode = createEvaluationVisitor(node) 131 | 132 | if (visitorNode) { 133 | visitorNode.evaluationEnter(visitor) 134 | } 135 | 136 | break 137 | case 'assignmentExpression': { 138 | visitor.add(node.data.left.data.id, { 139 | label: 140 | 'Assignment for ' + 141 | declarationPathTo(visitor.rootNode, node.data.left.data.id).join('.'), 142 | dependencies: [node.data.right.data.id], 143 | f: values => values[0], 144 | }) 145 | break 146 | } 147 | case 'functionType': 148 | case 'typeIdentifier': 149 | case 'program': 150 | case 'parameter': 151 | case 'value': 152 | case 'topLevelParameters': 153 | case 'topLevelDeclarations': 154 | case 'enumerationCase': // handled in 'enumeration' 155 | case 'argument': // handled in 'functionCallExpression' 156 | case 'associatedValue': // handled in enum declaration 157 | case 'namespace': 158 | case 'importDeclaration': 159 | case 'placeholder': 160 | case 'return': // handled in 'function' 161 | case 'loop': // handled in 'function' 162 | case 'branch': // handled in 'function' 163 | case 'expression': 164 | case 'declaration': { 165 | break 166 | } 167 | default: { 168 | assertNever(node) 169 | } 170 | } 171 | 172 | return context 173 | } 174 | 175 | export const evaluate = ( 176 | rootNode: AST.SyntaxNode, 177 | namespace: Namespace, 178 | scope: Scope, 179 | typeChecker: TypeChecker, 180 | substitution: Substitution, 181 | reporter: Reporter 182 | ): EvaluationContext => { 183 | const visitor = new EvaluationVisitor( 184 | rootNode, 185 | namespace, 186 | scope, 187 | typeChecker, 188 | substitution, 189 | reporter 190 | ) 191 | 192 | return evaluateNode(rootNode, visitor) 193 | } 194 | -------------------------------------------------------------------------------- /src/logic/nodes/createNode.ts: -------------------------------------------------------------------------------- 1 | import { LogicAST as AST } from '@lona/serialization' 2 | import { EnumerationDeclaration } from './EnumerationDeclaration' 3 | import { FunctionCallExpression } from './FunctionCallExpression' 4 | import { FunctionDeclaration } from './FunctionDeclaration' 5 | import { IdentifierExpression } from './IdentifierExpression' 6 | import { 7 | IDeclaration, 8 | IEvaluationContributor, 9 | IExpression, 10 | ILiteral, 11 | INode, 12 | IScopeContributor, 13 | ITypeCheckerContributor, 14 | ITypeAnnotation, 15 | } from './interfaces' 16 | import { LiteralExpression } from './LiteralExpression' 17 | import { 18 | ArrayLiteral, 19 | BooleanLiteral, 20 | ColorLiteral, 21 | NoneLiteral, 22 | NumberLiteral, 23 | StringLiteral, 24 | } from './literals' 25 | import { MemberExpression } from './MemberExpression' 26 | import { NamespaceDeclaration } from './NamespaceDeclaration' 27 | import { RecordDeclaration } from './RecordDeclaration' 28 | import { VariableDeclaration } from './VariableDeclaration' 29 | import { 30 | IdentifierTypeAnnotation, 31 | FunctionTypeAnnotation, 32 | } from './typeAnnotations' 33 | import { 34 | FunctionParameter, 35 | FunctionParameterDefaultValue, 36 | } from './FunctionParameter' 37 | 38 | export function createTypeAnnotationNode( 39 | syntaxNode: AST.SyntaxNode 40 | ): ITypeAnnotation | undefined { 41 | switch (syntaxNode.type) { 42 | case 'typeIdentifier': 43 | return new IdentifierTypeAnnotation(syntaxNode) 44 | case 'functionType': 45 | return new FunctionTypeAnnotation(syntaxNode) 46 | default: 47 | return undefined 48 | } 49 | } 50 | 51 | export function createLiteralNode( 52 | syntaxNode: AST.SyntaxNode 53 | ): ILiteral | undefined { 54 | switch (syntaxNode.type) { 55 | case 'boolean': 56 | return new BooleanLiteral(syntaxNode) 57 | case 'number': 58 | return new NumberLiteral(syntaxNode) 59 | case 'string': 60 | return new StringLiteral(syntaxNode) 61 | case 'none': 62 | return new NoneLiteral(syntaxNode) 63 | case 'color': 64 | return new ColorLiteral(syntaxNode) 65 | case 'array': 66 | return new ArrayLiteral(syntaxNode) 67 | default: 68 | return undefined 69 | } 70 | } 71 | 72 | export function createExpressionNode( 73 | syntaxNode: AST.SyntaxNode 74 | ): IExpression | undefined { 75 | switch (syntaxNode.type) { 76 | case 'identifierExpression': 77 | return new IdentifierExpression(syntaxNode) 78 | case 'memberExpression': 79 | return new MemberExpression(syntaxNode) 80 | case 'functionCallExpression': 81 | return new FunctionCallExpression(syntaxNode) 82 | case 'literalExpression': 83 | return new LiteralExpression(syntaxNode) 84 | default: 85 | return undefined 86 | } 87 | } 88 | 89 | export function createDeclarationNode( 90 | syntaxNode: AST.SyntaxNode 91 | ): IDeclaration | undefined { 92 | switch (syntaxNode.type) { 93 | case 'variable': 94 | return new VariableDeclaration(syntaxNode) 95 | case 'record': 96 | return new RecordDeclaration(syntaxNode) 97 | case 'enumeration': 98 | return new EnumerationDeclaration(syntaxNode) 99 | case 'function': 100 | return new FunctionDeclaration(syntaxNode) 101 | case 'namespace': 102 | return new NamespaceDeclaration(syntaxNode) 103 | default: 104 | return undefined 105 | } 106 | } 107 | 108 | function isScopeVisitor(node: INode): node is IScopeContributor { 109 | return 'scopeEnter' in node || 'scopeLeave' in node 110 | } 111 | 112 | function isTypeCheckerVisitor(node: INode): node is ITypeCheckerContributor { 113 | return 'typeCheckerEnter' in node || 'typeCheckerLeave' in node 114 | } 115 | 116 | function isEvaluationVisitor(node: INode): node is IEvaluationContributor { 117 | return 'evaluationEnter' in node 118 | } 119 | 120 | const nodeCache: { [key: string]: INode } = {} 121 | 122 | export function createNode(syntaxNode: AST.SyntaxNode): INode | undefined { 123 | const id = syntaxNode.data.id 124 | 125 | if (id in nodeCache) { 126 | return nodeCache[id] 127 | } 128 | 129 | let node: INode | undefined = 130 | createDeclarationNode(syntaxNode) || 131 | createTypeAnnotationNode(syntaxNode) || 132 | createExpressionNode(syntaxNode) || 133 | createLiteralNode(syntaxNode) 134 | 135 | if (!node) { 136 | switch (syntaxNode.type) { 137 | case 'parameter': 138 | node = new FunctionParameter(syntaxNode as AST.FunctionParameter) 139 | break 140 | case 'value': 141 | case 'none': 142 | node = new FunctionParameterDefaultValue( 143 | syntaxNode as AST.FunctionParameterDefaultValue 144 | ) 145 | break 146 | } 147 | } 148 | 149 | if (node) { 150 | nodeCache[id] = node 151 | } 152 | 153 | return node 154 | } 155 | 156 | export function createScopeVisitor( 157 | syntaxNode: AST.SyntaxNode 158 | ): IScopeContributor | undefined { 159 | const node = createNode(syntaxNode) 160 | 161 | return node && isScopeVisitor(node) ? node : undefined 162 | } 163 | 164 | export function createTypeCheckerVisitor( 165 | syntaxNode: AST.SyntaxNode 166 | ): ITypeCheckerContributor | undefined { 167 | const node = createNode(syntaxNode) 168 | 169 | return node && isTypeCheckerVisitor(node) ? node : undefined 170 | } 171 | 172 | export function createEvaluationVisitor( 173 | syntaxNode: AST.SyntaxNode 174 | ): IEvaluationContributor | undefined { 175 | const node = createNode(syntaxNode) 176 | 177 | return node && isEvaluationVisitor(node) ? node : undefined 178 | } 179 | -------------------------------------------------------------------------------- /src/plugins/js/__tests__/__snapshots__/js.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`JS Fixtures converts: declaration/enumeration.md 1`] = ` 4 | "var FOO = { VALUE: \\"value\\", NONE: \\"none\\" } 5 | " 6 | `; 7 | 8 | exports[`JS Fixtures converts: declaration/enumerationWithLabels.md 1`] = ` 9 | "var FOO = { BAR: \\"bar\\" } 10 | " 11 | `; 12 | 13 | exports[`JS Fixtures converts: declaration/function.md 1`] = ` 14 | "var addA = (a, b = 0) => {}; 15 | module.exports.addA = addA; 16 | " 17 | `; 18 | 19 | exports[`JS Fixtures converts: declaration/import.md 1`] = ` 20 | " 21 | " 22 | `; 23 | 24 | exports[`JS Fixtures converts: declaration/namespace.md 1`] = ` 25 | "var boolean = { foo: (a, b) => {}, bar: (a, b) => {} }; 26 | module.exports.boolean = boolean; 27 | " 28 | `; 29 | 30 | exports[`JS Fixtures converts: declaration/record.md 1`] = ` 31 | " 32 | " 33 | `; 34 | 35 | exports[`JS Fixtures converts: declaration/variable.md 1`] = ` 36 | "var x = [ 42 ]; 37 | module.exports.x = x; 38 | " 39 | `; 40 | 41 | exports[`JS Fixtures converts: expression/assignment.md 1`] = ` 42 | "var assignment = (a) => { a = 10 }; 43 | module.exports.assignment = assignment; 44 | " 45 | `; 46 | 47 | exports[`JS Fixtures converts: expression/functionCall.md 1`] = ` 48 | "var colorIdentity = (a) => a ; 49 | module.exports.colorIdentity = colorIdentity; 50 | var pinkWithDetour = \\"pink\\"; 51 | module.exports.pinkWithDetour = pinkWithDetour; 52 | " 53 | `; 54 | 55 | exports[`JS Fixtures converts: expression/identifier.md 1`] = ` 56 | "var x = 4; 57 | module.exports.x = x; 58 | var identifier = (a) => { a = x }; 59 | module.exports.identifier = identifier; 60 | " 61 | `; 62 | 63 | exports[`JS Fixtures converts: expression/member.md 1`] = ` 64 | "var foo = { primary: \\"#45CBFF\\" }; 65 | module.exports.foo = foo; 66 | var b = foo.primary; 67 | module.exports.b = b; 68 | " 69 | `; 70 | 71 | exports[`JS Fixtures converts: literal/array.md 1`] = ` 72 | "var x = [ 42, 35 ]; 73 | module.exports.x = x; 74 | " 75 | `; 76 | 77 | exports[`JS Fixtures converts: literal/boolean.md 1`] = ` 78 | "var trueBoolean = true; 79 | module.exports.trueBoolean = trueBoolean; 80 | var falseBoolean = false; 81 | module.exports.falseBoolean = falseBoolean; 82 | " 83 | `; 84 | 85 | exports[`JS Fixtures converts: literal/color.md 1`] = ` 86 | "var color = \\"pink\\"; 87 | module.exports.color = color; 88 | var hexColor = \\"#123456\\"; 89 | module.exports.hexColor = hexColor; 90 | " 91 | `; 92 | 93 | exports[`JS Fixtures converts: literal/number.md 1`] = ` 94 | "var positive = 1; 95 | module.exports.positive = positive; 96 | var float = 0.1; 97 | module.exports.float = float; 98 | var negative = -1; 99 | module.exports.negative = negative; 100 | " 101 | `; 102 | 103 | exports[`JS Fixtures converts: literal/string.md 1`] = ` 104 | "var string = \\"hello\\"; 105 | module.exports.string = string; 106 | var stringWithQuote = \\"Hello \\\\\\"world\\\\\\"\\"; 107 | module.exports.stringWithQuote = stringWithQuote; 108 | " 109 | `; 110 | 111 | exports[`JS Fixtures converts: statement/branch.md 1`] = ` 112 | "var branchStatement = () => { 113 | if (true) { 114 | return 0; 115 | } 116 | return 1; 117 | }; 118 | module.exports.branchStatement = branchStatement; 119 | " 120 | `; 121 | 122 | exports[`JS Fixtures converts: statement/loop.md 1`] = ` 123 | "var loopStatement = () => { while (false) { 124 | return 0; 125 | } }; 126 | module.exports.loopStatement = loopStatement; 127 | " 128 | `; 129 | 130 | exports[`JS Fixtures converts: statement/return.md 1`] = ` 131 | "var returnStatement = () => 0 ; 132 | module.exports.returnStatement = returnStatement; 133 | " 134 | `; 135 | 136 | exports[`JS converts workspace 1`] = ` 137 | Object { 138 | "/output/Colors.js": "var primary = \\"#45CBFF\\"; 139 | module.exports.primary = primary; 140 | 141 | var accent = primary; 142 | module.exports.accent = accent; 143 | 144 | var testSaturate = \\"#29D7FF\\"; 145 | module.exports.testSaturate = testSaturate; 146 | ", 147 | "/output/Shadows.js": "var small = { x: 0, y: 2, blur: 2, radius: 0, color: \\"#45CBFF\\" }; 148 | module.exports.small = small; 149 | ", 150 | "/output/TextStyles.js": "var heading1 = { fontFamily: \\"Helvetica\\", fontWeight: 700, fontSize: 28, color: \\"teal\\" }; 151 | module.exports.heading1 = heading1; 152 | ", 153 | "/output/index.js": "var __lona_import_0 = require(\\"./Colors\\"); 154 | Object.keys(__lona_import_0).forEach(function (key) { 155 | Object.defineProperty(module.exports, key, { 156 | enumerable: true, 157 | get: function get() { 158 | return __lona_import_0[key]; 159 | } 160 | }); 161 | }) 162 | 163 | var __lona_import_1 = require(\\"./Shadows\\"); 164 | Object.keys(__lona_import_1).forEach(function (key) { 165 | Object.defineProperty(module.exports, key, { 166 | enumerable: true, 167 | get: function get() { 168 | return __lona_import_1[key]; 169 | } 170 | }); 171 | }) 172 | 173 | var __lona_import_2 = require(\\"./TextStyles\\"); 174 | Object.keys(__lona_import_2).forEach(function (key) { 175 | Object.defineProperty(module.exports, key, { 176 | enumerable: true, 177 | get: function get() { 178 | return __lona_import_2[key]; 179 | } 180 | }); 181 | })", 182 | } 183 | `; 184 | 185 | exports[`JS generates js 1`] = ` 186 | "var __lona_import_0 = require(\\"./Colors\\"); 187 | Object.keys(__lona_import_0).forEach(function (key) { 188 | Object.defineProperty(module.exports, key, { 189 | enumerable: true, 190 | get: function get() { 191 | return __lona_import_0[key]; 192 | } 193 | }); 194 | })" 195 | `; 196 | 197 | exports[`JS generates js 2`] = ` 198 | "var color = \\"pink\\"; 199 | module.exports.color = color; 200 | " 201 | `; 202 | -------------------------------------------------------------------------------- /src/logic/runtime/value.ts: -------------------------------------------------------------------------------- 1 | import * as StaticType from '../staticType' 2 | import * as Memory from './memory' 3 | 4 | export type Value = { 5 | type: StaticType.StaticType 6 | memory: Memory.Memory 7 | } 8 | 9 | export namespace Encode { 10 | export const unit = (): Value => ({ 11 | type: StaticType.unit, 12 | memory: Memory.unit(), 13 | }) 14 | 15 | export const bool = (value: boolean): Value => ({ 16 | type: StaticType.bool, 17 | memory: Memory.bool(value), 18 | }) 19 | 20 | export const number = (value: number): Value => ({ 21 | type: StaticType.number, 22 | memory: Memory.number(value), 23 | }) 24 | 25 | export const string = (value: string): Value => ({ 26 | type: StaticType.string, 27 | memory: Memory.string(value), 28 | }) 29 | 30 | export const color = (value: string): Value => ({ 31 | type: StaticType.color, 32 | memory: { 33 | type: 'record', 34 | value: { 35 | value: { 36 | type: StaticType.string, 37 | memory: Memory.string(value), 38 | }, 39 | }, 40 | }, 41 | }) 42 | 43 | export const array = ( 44 | elementType: StaticType.StaticType, 45 | values: Value[] 46 | ) => ({ 47 | type: elementType, 48 | memory: Memory.array(values), 49 | }) 50 | } 51 | 52 | export namespace Decode { 53 | export const string = ({ type, memory }: Value): string | undefined => { 54 | if ( 55 | type.type === 'constructor' && 56 | type.name === 'String' && 57 | memory.type === 'string' 58 | ) { 59 | return memory.value 60 | } 61 | } 62 | export const number = ({ type, memory }: Value): number | undefined => { 63 | if ( 64 | type.type === 'constructor' && 65 | type.name === 'Number' && 66 | memory.type === 'number' 67 | ) { 68 | return memory.value 69 | } 70 | } 71 | 72 | export const color = ({ type, memory }: Value): string | undefined => { 73 | if ( 74 | type.type === 'constructor' && 75 | type.name === 'Color' && 76 | memory.type === 'record' 77 | ) { 78 | const colorValue = memory.value['value'] 79 | return string(colorValue) 80 | } 81 | } 82 | 83 | export const optional = ({ type, memory }: Value): Value | undefined => { 84 | if ( 85 | type.type === 'constructor' && 86 | type.name === 'Optional' && 87 | memory.type === 'enum' && 88 | memory.value === 'value' 89 | ) { 90 | return memory.data[0] 91 | } 92 | } 93 | 94 | export const fontWeightToNumberMapping: { [key: string]: string } = { 95 | ultraLight: '100', 96 | thin: '200', 97 | light: '300', 98 | regular: '400', 99 | medium: '500', 100 | semibold: '600', 101 | bold: '700', 102 | heavy: '800', 103 | black: '900', 104 | } 105 | 106 | export const fontNumberToWeightMapping: { 107 | [key: string]: string 108 | } = Object.fromEntries( 109 | Object.entries(fontWeightToNumberMapping).map(([key, value]) => [ 110 | value, 111 | key, 112 | ]) 113 | ) 114 | 115 | export const fontWeight = ({ type, memory }: Value): string | undefined => { 116 | if ( 117 | type.type === 'constructor' && 118 | type.name === 'FontWeight' && 119 | memory.type === 'enum' 120 | ) { 121 | return fontWeightToNumberMapping[memory.value] 122 | } 123 | } 124 | 125 | export type EvaluatedShadow = { 126 | x: number 127 | y: number 128 | blur: number 129 | radius: number 130 | color: string 131 | } 132 | 133 | export const shadow = ({ 134 | type, 135 | memory, 136 | }: Value): EvaluatedShadow | undefined => { 137 | if ( 138 | type.type === 'constructor' && 139 | type.name === 'Shadow' && 140 | memory.type === 'record' 141 | ) { 142 | return { 143 | x: Decode.number(memory.value['x']) ?? 0, 144 | y: Decode.number(memory.value['y']) ?? 0, 145 | blur: Decode.number(memory.value['blur']) ?? 0, 146 | radius: Decode.number(memory.value['radius']) ?? 0, 147 | color: Decode.color(memory.value['color']) ?? 'black', 148 | } 149 | } 150 | } 151 | 152 | export type EvaluatedTextStyle = { 153 | fontName?: string 154 | fontFamily?: string 155 | fontWeight?: number 156 | fontSize?: number 157 | lineHeight?: number 158 | letterSpacing?: number 159 | color?: string 160 | } 161 | 162 | export const textStyle = ({ 163 | type, 164 | memory, 165 | }: Value): EvaluatedTextStyle | undefined => { 166 | if ( 167 | type.type === 'constructor' && 168 | type.name === 'TextStyle' && 169 | memory.type === 'record' 170 | ) { 171 | const fontName = Decode.optional(memory.value['fontName']) 172 | const fontFamily = Decode.optional(memory.value['fontFamily']) 173 | const fontWeight = memory.value['fontWeight'] 174 | const fontSize = Decode.optional(memory.value['fontSize']) 175 | const lineHeight = Decode.optional(memory.value['lineHeight']) 176 | const letterSpacing = Decode.optional(memory.value['letterSpacing']) 177 | const color = Decode.optional(memory.value['color']) 178 | 179 | return { 180 | ...(fontName && { fontName: Decode.string(fontName) }), 181 | ...(fontFamily && { fontFamily: Decode.string(fontFamily) }), 182 | ...(fontWeight && { 183 | fontWeight: Number(Decode.fontWeight(fontWeight)), 184 | }), 185 | ...(fontSize && { fontSize: Decode.number(fontSize) }), 186 | ...(lineHeight && { lineHeight: Decode.number(lineHeight) }), 187 | ...(letterSpacing && { letterSpacing: Decode.number(letterSpacing) }), 188 | ...(color && { color: Decode.color(color) }), 189 | } 190 | } 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/logic/typeUnifier.ts: -------------------------------------------------------------------------------- 1 | import intersection from 'lodash.intersection' 2 | import { MultiMap } from './multiMap' 3 | import { StaticType } from './staticType' 4 | import { Reporter } from '../utils/reporter' 5 | import { assertNever, nonNullable } from '../utils/typeHelpers' 6 | 7 | export type Constraint = { 8 | head: StaticType 9 | tail: StaticType 10 | origin?: any 11 | } 12 | 13 | export type Substitution = MultiMap 14 | 15 | export function substitute( 16 | substitution: Substitution, 17 | type: StaticType 18 | ): StaticType { 19 | let resolvedType = substitution.get(type) 20 | if (!resolvedType) { 21 | resolvedType = type 22 | } 23 | 24 | if (resolvedType.type === 'variable' || resolvedType.type === 'generic') { 25 | return resolvedType 26 | } 27 | 28 | if (resolvedType.type === 'constructor') { 29 | return { 30 | type: 'constructor', 31 | name: resolvedType.name, 32 | parameters: resolvedType.parameters.map(x => substitute(substitution, x)), 33 | } 34 | } 35 | 36 | if (resolvedType.type === 'function') { 37 | return { 38 | type: 'function', 39 | returnType: substitute(substitution, resolvedType.returnType), 40 | arguments: resolvedType.arguments.map(arg => ({ 41 | label: arg.label, 42 | type: substitute(substitution, arg.type), 43 | })), 44 | } 45 | } 46 | 47 | assertNever(resolvedType) 48 | } 49 | 50 | export const unify = ( 51 | constraints: Constraint[], 52 | reporter: Reporter, 53 | substitution: Substitution = new MultiMap() 54 | ): Substitution => { 55 | while (constraints.length > 0) { 56 | const constraint = constraints.shift() 57 | if (!constraint) { 58 | // that's not possible, so it's just for TS 59 | continue 60 | } 61 | let { head, tail } = constraint 62 | 63 | if (head == tail) { 64 | continue 65 | } 66 | 67 | if (head.type === 'function' && tail.type === 'function') { 68 | const headArguments = head.arguments 69 | const tailArguments = tail.arguments 70 | const headContainsLabels = headArguments.some(x => x.label) 71 | const tailContainsLabels = tailArguments.some(x => x.label) 72 | 73 | if ( 74 | (headContainsLabels && !tailContainsLabels && tailArguments.length) || 75 | (tailContainsLabels && !headContainsLabels && headArguments.length) 76 | ) { 77 | reporter.error(headArguments, tailArguments) 78 | throw new Error(`[UnificationError] [GenericArgumentsLabelMismatch]`) 79 | } 80 | 81 | if (!headContainsLabels && !tailContainsLabels) { 82 | if (headArguments.length !== tailArguments.length) { 83 | throw new Error( 84 | `[UnificationError] [GenericArgumentsCountMismatch] ${head} ${tail}` 85 | ) 86 | } 87 | 88 | headArguments.forEach((a, i) => { 89 | constraints.push({ 90 | head: a.type, 91 | tail: tailArguments[i].type, 92 | origin: constraint, 93 | }) 94 | }) 95 | } else { 96 | const headLabels = headArguments 97 | .map(arg => arg.label) 98 | .filter(nonNullable) 99 | const tailLabels = tailArguments 100 | .map(arg => arg.label) 101 | .filter(nonNullable) 102 | 103 | let common = intersection(headLabels, tailLabels) 104 | 105 | common.forEach(label => { 106 | const headArgumentType = headArguments.find( 107 | arg => arg.label === label 108 | ) 109 | const tailArgumentType = tailArguments.find( 110 | arg => arg.label === label 111 | ) 112 | 113 | if (!headArgumentType || !tailArgumentType) { 114 | // not possible but here for TS 115 | return 116 | } 117 | 118 | constraints.push({ 119 | head: headArgumentType.type, 120 | tail: tailArgumentType.type, 121 | origin: constraint, 122 | }) 123 | }) 124 | } 125 | 126 | constraints.push({ 127 | head: head.returnType, 128 | tail: tail.returnType, 129 | origin: constraint, 130 | }) 131 | } else if (head.type === 'constructor' && tail.type === 'constructor') { 132 | if (head.name !== tail.name) { 133 | reporter.error(JSON.stringify(constraint, null, ' ')) 134 | throw new Error( 135 | `[UnificationError] [NameMismatch] ${head.name} <> ${tail.name}` 136 | ) 137 | } 138 | const headParameters = head.parameters 139 | const tailParameters = tail.parameters 140 | if (headParameters.length !== tailParameters.length) { 141 | throw new Error( 142 | `[UnificationError] [GenericArgumentsCountMismatch] ${head} <> ${tail}` 143 | ) 144 | } 145 | headParameters.forEach((a, i) => { 146 | constraints.push({ 147 | head: a, 148 | tail: tailParameters[i], 149 | origin: constraint, 150 | }) 151 | }) 152 | } else if (head.type === 'generic' || tail.type === 'generic') { 153 | reporter.error(JSON.stringify(constraint, null, ' ')) 154 | reporter.error('tried to unify generics (problem?)', head, tail) 155 | } else if (head.type === 'variable') { 156 | substitution.set(head, tail) 157 | } else if (tail.type === 'variable') { 158 | substitution.set(tail, head) 159 | } else if ( 160 | (head.type === 'constructor' && tail.type === 'function') || 161 | (head.type === 'function' && tail.type === 'constructor') 162 | ) { 163 | throw new Error(`[UnificationError] [KindMismatch] ${head} ${tail}`) 164 | } 165 | 166 | constraints = constraints.map(c => { 167 | const head = substitution.get(c.head) 168 | const tail = substitution.get(c.tail) 169 | 170 | if (head && tail) { 171 | return { head, tail, origin: c } 172 | } 173 | if (head) { 174 | return { 175 | head, 176 | tail: c.tail, 177 | origin: c, 178 | } 179 | } 180 | if (tail) { 181 | return { 182 | head: c.head, 183 | tail, 184 | origin: c, 185 | } 186 | } 187 | return c 188 | }) 189 | } 190 | 191 | return substitution 192 | } 193 | -------------------------------------------------------------------------------- /src/logic/__tests__/evaluation.ts: -------------------------------------------------------------------------------- 1 | import { LogicAST as AST } from '@lona/serialization' 2 | import { createFs } from 'buffs' 3 | import { createModule, ModuleContext } from '../module' 4 | import { UUID } from '../namespace' 5 | import { findNode } from '../traversal' 6 | 7 | function getInitializerId(module: ModuleContext, variableName: string): UUID { 8 | const logicFile = module.logicFiles.find(file => 9 | file.sourcePath.endsWith('Example.logic') 10 | ) 11 | 12 | if (!logicFile) { 13 | throw new Error(`Failed to find Example.logic`) 14 | } 15 | 16 | const variable = findNode( 17 | logicFile.rootNode, 18 | (node): node is AST.VariableDeclaration => { 19 | return node.type === 'variable' && node.data.name.name === variableName 20 | } 21 | ) 22 | 23 | if (!variable) { 24 | throw new Error(`Variable ${variableName} not found`) 25 | } 26 | 27 | const initializer = variable.data.initializer 28 | 29 | if (!initializer) { 30 | throw new Error(`Initializer for ${variableName} not found`) 31 | } 32 | 33 | return initializer.data.id 34 | } 35 | 36 | function moduleWithFile(file: string): ModuleContext { 37 | const source = createFs({ 38 | 'lona.json': JSON.stringify({}), 39 | 'Example.logic': file, 40 | }) 41 | 42 | return createModule(source, '/') 43 | } 44 | 45 | describe('Logic / Evaluate', () => { 46 | it('evaluates number literals', () => { 47 | const file = `let x: Number = 4` 48 | const module = moduleWithFile(file) 49 | const initializerId = getInitializerId(module, 'x') 50 | 51 | expect(module.evaluationContext.evaluate(initializerId)).toEqual({ 52 | type: { type: 'constructor', name: 'Number', parameters: [] }, 53 | memory: { type: 'number', value: 4 }, 54 | }) 55 | }) 56 | 57 | it('evaluates color literals', () => { 58 | const file = `let x: Color = #color(css: "red")` 59 | const module = moduleWithFile(file) 60 | const initializerId = getInitializerId(module, 'x') 61 | 62 | expect(module.evaluationContext.evaluate(initializerId)).toMatchSnapshot() 63 | }) 64 | 65 | it('evaluates enums', () => { 66 | const file = ` 67 | enum Foo { 68 | case bar() 69 | } 70 | 71 | let x: Foo = Foo.bar() 72 | ` 73 | const module = moduleWithFile(file) 74 | const initializerId = getInitializerId(module, 'x') 75 | 76 | expect(module.evaluationContext.evaluate(initializerId)).toEqual({ 77 | type: { type: 'constructor', name: 'Foo', parameters: [] }, 78 | memory: { type: 'enum', value: 'bar', data: [] }, 79 | }) 80 | }) 81 | 82 | it('evaluates custom function', () => { 83 | const file = ` 84 | func test() -> Number { 85 | return 42 86 | } 87 | 88 | let x: Number = test() 89 | ` 90 | const module = moduleWithFile(file) 91 | const initializerId = getInitializerId(module, 'x') 92 | 93 | const result = module.evaluationContext.evaluate(initializerId) 94 | 95 | expect(result).toMatchSnapshot() 96 | }) 97 | 98 | it('evaluates custom function with arguments', () => { 99 | const file = ` 100 | func test(myNumber: Number) -> Number { 101 | return myNumber 102 | } 103 | 104 | let x: Number = test(myNumber: 42) 105 | ` 106 | const module = moduleWithFile(file) 107 | const initializerId = getInitializerId(module, 'x') 108 | 109 | const result = module.evaluationContext.evaluate(initializerId) 110 | 111 | expect(result).toMatchSnapshot() 112 | }) 113 | 114 | it('evaluates custom function with argument default value', () => { 115 | const file = ` 116 | func test(myNumber: Number = 42) -> Number { 117 | return myNumber 118 | } 119 | 120 | let x: Number = test() 121 | ` 122 | const module = moduleWithFile(file) 123 | const initializerId = getInitializerId(module, 'x') 124 | 125 | const result = module.evaluationContext.evaluate(initializerId) 126 | 127 | expect(result).toMatchSnapshot() 128 | }) 129 | 130 | it('evaluates DimensionSize', () => { 131 | const file = ` 132 | let x: DimensionSize = DimensionSize.fixed(100) 133 | ` 134 | const module = moduleWithFile(file) 135 | const initializerId = getInitializerId(module, 'x') 136 | 137 | expect(module.evaluationContext.evaluate(initializerId)).toMatchSnapshot() 138 | }) 139 | 140 | it('evaluates ElementParameter', () => { 141 | const file = ` 142 | let x: ElementParameter = ElementParameter.number("height", 20) 143 | ` 144 | const module = moduleWithFile(file) 145 | const initializerId = getInitializerId(module, 'x') 146 | 147 | const result = module.evaluationContext.evaluate(initializerId) 148 | 149 | expect(result).toMatchSnapshot() 150 | }) 151 | 152 | it('evaluates Element', () => { 153 | const file = ` 154 | let x: Element = Element(type: "Test", parameters: []) 155 | ` 156 | const module = moduleWithFile(file) 157 | const initializerId = getInitializerId(module, 'x') 158 | 159 | const result = module.evaluationContext.evaluate(initializerId) 160 | 161 | expect(result).toMatchSnapshot() 162 | }) 163 | 164 | it('evaluates Padding', () => { 165 | const file = ` 166 | let x: Padding = Padding(top: 10, right: 20, bottom: 30, left: 40) 167 | ` 168 | const module = moduleWithFile(file) 169 | const initializerId = getInitializerId(module, 'x') 170 | 171 | const result = module.evaluationContext.evaluate(initializerId) 172 | 173 | expect(result).toMatchSnapshot() 174 | }) 175 | 176 | it('evaluates function that returns Padding', () => { 177 | const file = ` 178 | let x: Padding = Padding.size(value: 8) 179 | ` 180 | const module = moduleWithFile(file) 181 | const initializerId = getInitializerId(module, 'x') 182 | 183 | const result = module.evaluationContext.evaluate(initializerId) 184 | 185 | expect(result).toMatchSnapshot() 186 | }) 187 | 188 | it('evaluates View', () => { 189 | const file = ` 190 | let width: DimensionSize = DimensionSize.fixed(20) 191 | let height: DimensionSize = DimensionSize.fixed(20) 192 | let name: String = "name" 193 | let padding: Padding = Padding.size(value: 8) 194 | let backgroundColor: Color = #color(css: "red") 195 | let x: Element = View(__name: name, width: width, height: height, padding: padding, backgroundColor: backgroundColor, children: []) 196 | ` 197 | const module = moduleWithFile(file) 198 | const initializerId = getInitializerId(module, 'x') 199 | 200 | const result = module.evaluationContext.evaluate(initializerId) 201 | 202 | expect(result).toMatchSnapshot() 203 | }) 204 | }) 205 | -------------------------------------------------------------------------------- /src/logic/nodes/EnumerationDeclaration.ts: -------------------------------------------------------------------------------- 1 | import { LogicAST as AST } from '@lona/serialization' 2 | import { compact } from '../../utils/sequence' 3 | import { EvaluationVisitor } from '../evaluationVisitor' 4 | import NamespaceVisitor from '../namespaceVisitor' 5 | import { DefaultArguments, RecordMemory } from '../runtime/memory' 6 | import { ScopeVisitor } from '../scopeVisitor' 7 | import { StaticType } from '../staticType' 8 | import { TypeCheckerVisitor } from '../typeChecker' 9 | import { substitute } from '../typeUnifier' 10 | import { IDeclaration, Node } from './interfaces' 11 | import { isNode } from '../ast' 12 | import { FunctionCallExpression } from './FunctionCallExpression' 13 | 14 | export class EnumerationDeclaration extends Node 15 | implements IDeclaration { 16 | get name(): string { 17 | return this.syntaxNode.data.name.name 18 | } 19 | 20 | get cases(): Extract[] { 21 | return this.syntaxNode.data.cases.flatMap(enumCase => 22 | isNode(enumCase) ? [enumCase] : [] 23 | ) 24 | } 25 | 26 | get attributes(): FunctionCallExpression[] { 27 | return this.syntaxNode.data.attributes.map( 28 | attribute => new FunctionCallExpression(attribute) 29 | ) 30 | } 31 | 32 | get hasAssociatedData(): boolean { 33 | return this.cases.some( 34 | enumCase => enumCase.data.associatedValues.filter(isNode).length > 0 35 | ) 36 | } 37 | 38 | namespaceEnter(visitor: NamespaceVisitor): void { 39 | const { 40 | name: { name, id }, 41 | } = this.syntaxNode.data 42 | 43 | visitor.declareType(name, id) 44 | visitor.pushPathComponent(name) 45 | } 46 | 47 | namespaceLeave(visitor: NamespaceVisitor): void { 48 | const { cases } = this.syntaxNode.data 49 | 50 | // Add initializers for each case into the namespace 51 | cases.forEach(enumCase => { 52 | switch (enumCase.type) { 53 | case 'placeholder': 54 | break 55 | case 'enumerationCase': 56 | visitor.declareValue(enumCase.data.name.name, enumCase.data.name.id) 57 | } 58 | }) 59 | 60 | visitor.popPathComponent() 61 | } 62 | 63 | scopeEnter(visitor: ScopeVisitor): void { 64 | const { name, genericParameters } = this.syntaxNode.data 65 | 66 | visitor.addTypeToScope(name) 67 | 68 | visitor.pushNamespace(name.name) 69 | 70 | genericParameters.forEach(parameter => { 71 | switch (parameter.type) { 72 | case 'parameter': 73 | visitor.addTypeToScope(parameter.data.name) 74 | case 'placeholder': 75 | break 76 | } 77 | }) 78 | } 79 | 80 | scopeLeave(visitor: ScopeVisitor): void { 81 | visitor.popNamespace() 82 | } 83 | 84 | typeCheckerEnter(visitor: TypeCheckerVisitor): void { 85 | const { genericParameters, cases, name } = this.syntaxNode.data 86 | const { typeChecker } = visitor 87 | 88 | const genericNames = compact( 89 | genericParameters.map(param => 90 | param.type === 'parameter' ? param.data.name.name : undefined 91 | ) 92 | ) 93 | 94 | const genericsInScope: [string, string][] = genericNames.map(x => [ 95 | x, 96 | typeChecker.typeNameGenerator.next(), 97 | ]) 98 | 99 | const universalTypes = genericNames.map((x, i) => ({ 100 | type: 'generic', 101 | name: genericsInScope[i][1], 102 | })) 103 | 104 | const returnType: StaticType = { 105 | type: 'constructor', 106 | name: name.name, 107 | parameters: universalTypes, 108 | } 109 | 110 | cases.forEach(enumCase => { 111 | if (enumCase.type === 'placeholder') return 112 | 113 | const parameterTypes = compact( 114 | enumCase.data.associatedValues.map(associatedValue => { 115 | if (associatedValue.type === 'placeholder') return 116 | 117 | const { label, annotation } = associatedValue.data 118 | 119 | return { 120 | label: label?.name, 121 | type: visitor.unificationType( 122 | genericsInScope, 123 | () => typeChecker.typeNameGenerator.next(), 124 | annotation 125 | ), 126 | } 127 | }) 128 | ) 129 | 130 | const functionType: StaticType = { 131 | type: 'function', 132 | returnType, 133 | arguments: parameterTypes, 134 | } 135 | 136 | typeChecker.nodes[enumCase.data.name.id] = functionType 137 | typeChecker.patternTypes[enumCase.data.name.id] = functionType 138 | }) 139 | 140 | /* Not used for unification, but used for convenience in evaluation */ 141 | typeChecker.nodes[name.id] = returnType 142 | typeChecker.patternTypes[name.id] = returnType 143 | } 144 | 145 | typeCheckerLeave(visitor: TypeCheckerVisitor): void {} 146 | 147 | evaluationEnter(visitor: EvaluationVisitor) { 148 | const { name, cases } = this.syntaxNode.data 149 | const { typeChecker, substitution, reporter } = visitor 150 | 151 | const type = typeChecker.patternTypes[name.id] 152 | 153 | if (!type) { 154 | reporter.error('unknown enumberation type') 155 | return 156 | } 157 | 158 | const enumType = substitute(substitution, type) 159 | 160 | cases.forEach(enumCase => { 161 | if (enumCase.type !== 'enumerationCase') return 162 | 163 | const { name: caseName } = enumCase.data 164 | 165 | const caseType = substitute( 166 | substitution, 167 | typeChecker.nodes[enumCase.data.name.id] 168 | ) 169 | 170 | if (caseType.type !== 'function') { 171 | throw new Error('Enum case type must be a function') 172 | } 173 | 174 | const defaultArguments: DefaultArguments = Object.fromEntries( 175 | caseType.arguments.map(({ label, type }, index) => { 176 | return [ 177 | typeof label === 'string' ? label : index.toString(), 178 | [substitute(substitution, type), undefined], 179 | ] 180 | }) 181 | ) 182 | 183 | visitor.addValue(caseName.id, { 184 | type: enumType, 185 | memory: { 186 | type: 'function', 187 | value: { 188 | f: (record: RecordMemory) => { 189 | return { 190 | type: 'enum', 191 | value: caseName.name, 192 | data: Object.values(record), 193 | } 194 | }, 195 | defaultArguments, 196 | }, 197 | }, 198 | }) 199 | }) 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/logic/ast.ts: -------------------------------------------------------------------------------- 1 | import { LogicAST } from '@lona/serialization' 2 | import { uuid } from '../utils/uuid' 3 | 4 | // TODO: Move to serialization? 5 | export function isNode( 6 | node: T 7 | ): node is Exclude { 8 | return node.type !== 'placeholder' 9 | } 10 | 11 | /** 12 | * Takes an array of programs and returns a program containing 13 | * the statements of all programs 14 | */ 15 | export function joinPrograms( 16 | programs: (LogicAST.Program | void)[] 17 | ): LogicAST.Program { 18 | return { 19 | type: 'program', 20 | data: { 21 | id: uuid(), 22 | block: programs.reduce( 23 | (prev, x) => (x ? prev.concat(x.data.block) : prev), 24 | [] as LogicAST.Statement[] 25 | ), 26 | }, 27 | } 28 | } 29 | 30 | /** tries to make a program out of any Logic node */ 31 | export function makeProgram( 32 | node: LogicAST.SyntaxNode 33 | ): LogicAST.Program | undefined { 34 | if (node.type === 'program') { 35 | return node 36 | } 37 | if (LogicAST.isStatement(node)) { 38 | return { type: 'program', data: { id: uuid(), block: [node] } } 39 | } 40 | if (LogicAST.isDeclaration(node)) { 41 | return makeProgram({ 42 | type: 'declaration', 43 | data: { id: uuid(), content: node }, 44 | }) 45 | } 46 | if (node.type === 'topLevelDeclarations') { 47 | return { 48 | type: 'program', 49 | data: { 50 | id: uuid(), 51 | block: node.data.declarations.map(x => ({ 52 | type: 'declaration', 53 | data: { id: uuid(), content: x }, 54 | })), 55 | }, 56 | } 57 | } 58 | 59 | return undefined 60 | } 61 | 62 | export function getPattern( 63 | node: LogicAST.SyntaxNode 64 | ): LogicAST.Pattern | undefined { 65 | if ( 66 | node.type === 'variable' || 67 | node.type === 'enumeration' || 68 | node.type === 'namespace' || 69 | node.type === 'record' || 70 | node.type === 'importDeclaration' || 71 | node.type === 'enumerationCase' || 72 | node.type === 'function' 73 | ) { 74 | return node.data.name 75 | } 76 | 77 | if (node.type === 'parameter') { 78 | if ('localName' in node.data) { 79 | return node.data.localName 80 | } 81 | return node.data.name 82 | } 83 | } 84 | 85 | export function getIdentifier( 86 | node: LogicAST.SyntaxNode 87 | ): LogicAST.Identifier | undefined { 88 | if (node.type === 'identifierExpression' || node.type === 'typeIdentifier') { 89 | return node.data.identifier 90 | } 91 | if (node.type === 'memberExpression') { 92 | return node.data.memberName 93 | } 94 | } 95 | 96 | export function flattenedMemberExpression( 97 | memberExpression: LogicAST.Expression 98 | ): LogicAST.Identifier[] | void { 99 | if (memberExpression.type === 'identifierExpression') { 100 | return [memberExpression.data.identifier] 101 | } 102 | if (memberExpression.type !== 'memberExpression') { 103 | return undefined 104 | } 105 | if (memberExpression.data.expression.type === 'identifierExpression') { 106 | return [ 107 | memberExpression.data.expression.data.identifier, 108 | memberExpression.data.memberName, 109 | ] 110 | } 111 | const flattenedChildren = flattenedMemberExpression( 112 | memberExpression.data.expression 113 | ) 114 | if (!flattenedChildren) { 115 | return undefined 116 | } 117 | return flattenedChildren.concat(memberExpression.data.memberName) 118 | } 119 | 120 | export function getNode( 121 | rootNode: LogicAST.SyntaxNode, 122 | id: string 123 | ): LogicAST.SyntaxNode | undefined { 124 | if (rootNode.data.id === id) { 125 | return rootNode 126 | } 127 | 128 | if ('name' in rootNode.data && rootNode.data.name.id === id) { 129 | return rootNode 130 | } 131 | 132 | const children = LogicAST.subNodes(rootNode) 133 | 134 | for (let child of children) { 135 | const node = getNode(child, id) 136 | if (node) { 137 | return node 138 | } 139 | } 140 | 141 | return undefined 142 | } 143 | 144 | function pathTo( 145 | rootNode: LogicAST.SyntaxNode | LogicAST.Pattern | LogicAST.Identifier, 146 | id: string 147 | ): 148 | | (LogicAST.SyntaxNode | LogicAST.Pattern | LogicAST.Identifier)[] 149 | | undefined { 150 | if (id === ('id' in rootNode ? rootNode.id : rootNode.data.id)) { 151 | return [rootNode] 152 | } 153 | if (!('type' in rootNode)) { 154 | return undefined 155 | } 156 | 157 | const pattern = getPattern(rootNode) 158 | if (pattern && pattern.id === id) { 159 | return [pattern] 160 | } 161 | 162 | const identifier = getIdentifier(rootNode) 163 | if (identifier && identifier.id === id) { 164 | return [identifier] 165 | } 166 | 167 | for (let item of LogicAST.subNodes(rootNode)) { 168 | const subPath = pathTo(item, id) 169 | if (subPath) { 170 | return [rootNode, ...subPath] 171 | } 172 | } 173 | 174 | return undefined 175 | } 176 | 177 | export function findNode( 178 | rootNode: LogicAST.SyntaxNode | LogicAST.Pattern | LogicAST.Identifier, 179 | id: string 180 | ) { 181 | const path = pathTo(rootNode, id) 182 | if (!path) { 183 | return undefined 184 | } 185 | 186 | return path[path.length - 1] 187 | } 188 | 189 | export function findParentNode( 190 | node: LogicAST.SyntaxNode | LogicAST.Pattern | LogicAST.Identifier, 191 | id: string 192 | ) { 193 | const path = pathTo(node, id) 194 | if (!path || path.length <= 1) { 195 | return undefined 196 | } 197 | 198 | const parent = path[path.length - 2] 199 | 200 | if (!('type' in parent)) { 201 | return undefined 202 | } 203 | 204 | return parent 205 | } 206 | 207 | export function declarationPathTo( 208 | node: LogicAST.SyntaxNode | LogicAST.Pattern | LogicAST.Identifier, 209 | id: string 210 | ): string[] { 211 | const path = pathTo(node, id) 212 | if (!path) { 213 | return [] 214 | } 215 | return path 216 | .map(x => { 217 | if (!('type' in x)) { 218 | return '' 219 | } 220 | switch (x.type) { 221 | case 'variable': 222 | case 'function': 223 | case 'enumeration': 224 | case 'namespace': 225 | case 'record': 226 | case 'importDeclaration': { 227 | return x.data.name.name 228 | } 229 | case 'argument': { 230 | return x.data.label || '' 231 | } 232 | case 'parameter': { 233 | if ('localName' in x.data) { 234 | return x.data.localName.name 235 | } 236 | return x.data.name.name 237 | } 238 | default: 239 | return '' 240 | } 241 | }) 242 | .filter(x => !!x) 243 | } 244 | -------------------------------------------------------------------------------- /src/logic/nodes/FunctionCallExpression.ts: -------------------------------------------------------------------------------- 1 | import { LogicAST as AST } from '@lona/serialization' 2 | import { EvaluationVisitor } from '../evaluationVisitor' 3 | import { Value } from '../runtime/value' 4 | import { ScopeVisitor } from '../scopeVisitor' 5 | import { FunctionArgument, StaticType, unit } from '../staticType' 6 | import { TypeCheckerVisitor } from '../typeChecker' 7 | import { IExpression, Node, INode } from './interfaces' 8 | import { createExpressionNode } from './createNode' 9 | import { compact } from '../../utils/sequence' 10 | import { nonNullable } from '../../utils/typeHelpers' 11 | 12 | export class FunctionCallExpression extends Node 13 | implements IExpression { 14 | get callee(): IExpression { 15 | return createExpressionNode(this.syntaxNode.data.expression)! 16 | } 17 | 18 | get argumentExpressionNodes(): { [key: string]: IExpression } { 19 | return Object.fromEntries( 20 | compact( 21 | this.syntaxNode.data.arguments.map((arg, index) => { 22 | if (arg.type === 'placeholder') return 23 | 24 | const expressionNode = createExpressionNode(arg.data.expression) 25 | 26 | if (!expressionNode) return 27 | 28 | return [arg.data.label ?? index.toString(), expressionNode] 29 | }) 30 | ) 31 | ) 32 | } 33 | 34 | scopeEnter(visitor: ScopeVisitor): void {} 35 | 36 | scopeLeave(visitor: ScopeVisitor): void {} 37 | 38 | typeCheckerEnter(visitor: TypeCheckerVisitor): void {} 39 | 40 | typeCheckerLeave(visitor: TypeCheckerVisitor): void { 41 | const { 42 | expression, 43 | arguments: functionArguments, 44 | id, 45 | } = this.syntaxNode.data 46 | const { typeChecker } = visitor 47 | 48 | const calleeType = typeChecker.nodes[expression.data.id] 49 | 50 | /* Unify against these to enforce a function type */ 51 | const placeholderReturnType: StaticType = { 52 | type: 'variable', 53 | value: typeChecker.typeNameGenerator.next(), 54 | } 55 | 56 | const placeholderArgTypes = functionArguments 57 | .map(arg => { 58 | if (arg.type === 'placeholder') return 59 | 60 | return { 61 | label: arg.data.label, 62 | type: { 63 | type: 'variable', 64 | value: typeChecker.typeNameGenerator.next(), 65 | }, 66 | } 67 | }) 68 | .filter(nonNullable) 69 | 70 | const placeholderFunctionType: StaticType = { 71 | type: 'function', 72 | returnType: placeholderReturnType, 73 | arguments: placeholderArgTypes, 74 | } 75 | 76 | typeChecker.constraints.push({ 77 | head: calleeType, 78 | tail: placeholderFunctionType, 79 | origin: this.syntaxNode, 80 | }) 81 | 82 | typeChecker.nodes[id] = placeholderReturnType 83 | 84 | let argumentValues = functionArguments 85 | .map(arg => 86 | arg.type === 'placeholder' ? undefined : arg.data.expression 87 | ) 88 | .filter(nonNullable) 89 | 90 | const constraints = placeholderArgTypes.map((argType, i) => ({ 91 | head: argType.type, 92 | tail: typeChecker.nodes[argumentValues[i].data.id], 93 | origin: this.syntaxNode, 94 | })) 95 | 96 | typeChecker.constraints = typeChecker.constraints.concat(constraints) 97 | } 98 | 99 | evaluationEnter(visitor: EvaluationVisitor) { 100 | const { expression, arguments: args, id } = this.syntaxNode.data 101 | const { reporter, resolveType } = visitor 102 | 103 | const type = resolveType(expression.data.id) 104 | 105 | if (!type) return 106 | 107 | if (type.type !== 'function') { 108 | reporter.error( 109 | 'Invalid functionCallExpression type (only functions are valid)', 110 | type 111 | ) 112 | return 113 | } 114 | 115 | // TODO: Fix union between argument and placeholder type 116 | const isValidArgument = (arg: AST.FunctionCallArgument) => { 117 | if ( 118 | arg.type === 'placeholder' || 119 | arg.data.expression.type === 'placeholder' || 120 | (arg.data.expression.type === 'identifierExpression' && 121 | arg.data.expression.data.identifier.isPlaceholder) 122 | ) { 123 | return false 124 | } 125 | return true 126 | } 127 | 128 | const validArguments = args.filter(isValidArgument) 129 | 130 | const dependencies = [ 131 | expression.data.id, 132 | ...validArguments.map(arg => { 133 | if (arg.type === 'placeholder') throw new Error('Invalid argument') 134 | return arg.data.expression.data.id 135 | }), 136 | ] 137 | 138 | visitor.add(id, { 139 | label: 'FunctionCallExpression', 140 | dependencies, 141 | f: values => { 142 | const [functionValue] = values 143 | 144 | if (functionValue.memory.type !== 'function') { 145 | reporter.error('tried to evaluate a function that is not a function') 146 | return { type: unit, memory: { type: 'unit' } } 147 | } 148 | 149 | const { f, defaultArguments } = functionValue.memory.value 150 | 151 | const alreadyMatched = new Set() 152 | 153 | const members: [string, Value | void][] = Object.entries( 154 | defaultArguments 155 | ).map(([key, value]) => { 156 | const match = validArguments.find( 157 | x => 158 | x.type === 'argument' && 159 | !alreadyMatched.has(x.data.id) && 160 | (x.data.label == null || x.data.label === key) 161 | ) 162 | 163 | // If an argument is explicitly passed, use it 164 | if (match && match.type === 'argument' && isValidArgument(match)) { 165 | const dependencyIndex = dependencies.indexOf( 166 | match.data.expression.data.id 167 | ) 168 | 169 | if (dependencyIndex !== -1) { 170 | alreadyMatched.add(match.data.id) 171 | 172 | return [key, values[dependencyIndex]] 173 | } else { 174 | throw new Error( 175 | `Failed to find arg dependency ${match.data.expression.data.id}` 176 | ) 177 | } 178 | } else { 179 | // Fall back to default argument 180 | return [key, value[1]] 181 | } 182 | }) 183 | 184 | const namedArguments = Object.fromEntries( 185 | members.flatMap(([key, value]) => (value ? [[key, value]] : [])) 186 | ) 187 | 188 | return { 189 | type: type.returnType, 190 | memory: f(namedArguments), 191 | } 192 | }, 193 | }) 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/logic/nodes/RecordDeclaration.ts: -------------------------------------------------------------------------------- 1 | import { LogicAST as AST } from '@lona/serialization' 2 | import { compact } from '../../utils/sequence' 3 | import { EvaluationVisitor } from '../evaluationVisitor' 4 | import { builtInTypeConstructorNames } from '../namespace' 5 | import NamespaceVisitor from '../namespaceVisitor' 6 | import { DefaultArguments } from '../runtime/memory' 7 | import { ScopeVisitor } from '../scopeVisitor' 8 | import { FunctionArgument, StaticType } from '../staticType' 9 | import { TypeCheckerVisitor } from '../typeChecker' 10 | import { IDeclaration, Node } from './interfaces' 11 | import { substitute } from '../typeUnifier' 12 | import { EnterReturnValue } from 'buffs' 13 | import { FunctionCallExpression } from './FunctionCallExpression' 14 | import { VariableDeclaration } from './VariableDeclaration' 15 | 16 | export class RecordDeclaration extends Node 17 | implements IDeclaration { 18 | get name(): string { 19 | return this.syntaxNode.data.name.name 20 | } 21 | 22 | get attributes(): FunctionCallExpression[] { 23 | return this.syntaxNode.data.attributes.map( 24 | attribute => new FunctionCallExpression(attribute) 25 | ) 26 | } 27 | 28 | get variables(): VariableDeclaration[] { 29 | return this.syntaxNode.data.declarations.flatMap(node => 30 | node.type === 'variable' ? [new VariableDeclaration(node)] : [] 31 | ) 32 | } 33 | 34 | namespaceEnter(visitor: NamespaceVisitor): void { 35 | const { 36 | name: { name, id }, 37 | } = this.syntaxNode.data 38 | 39 | visitor.declareType(name, id) 40 | visitor.pushPathComponent(name) 41 | } 42 | 43 | namespaceLeave(visitor: NamespaceVisitor): void { 44 | const { 45 | name: { name, id }, 46 | } = this.syntaxNode.data 47 | 48 | visitor.popPathComponent() 49 | 50 | // Built-ins should be constructed using literals 51 | if (builtInTypeConstructorNames.has(name)) return 52 | 53 | // Create constructor function 54 | visitor.declareValue(name, id) 55 | } 56 | 57 | scopeEnter(visitor: ScopeVisitor): EnterReturnValue { 58 | const { name, declarations, genericParameters } = this.syntaxNode.data 59 | 60 | visitor.addTypeToScope(name) 61 | 62 | visitor.pushNamespace(name.name) 63 | 64 | genericParameters.forEach(parameter => { 65 | switch (parameter.type) { 66 | case 'parameter': 67 | visitor.addTypeToScope(parameter.data.name) 68 | case 'placeholder': 69 | break 70 | } 71 | }) 72 | 73 | // Handle variable initializers manually 74 | declarations.forEach(declaration => { 75 | switch (declaration.type) { 76 | case 'variable': { 77 | const { name: variableName, initializer } = declaration.data 78 | 79 | if (!initializer) break 80 | 81 | visitor.traverse(initializer) 82 | 83 | visitor.addValueToScope(variableName) 84 | } 85 | default: 86 | break 87 | } 88 | }) 89 | 90 | visitor.popNamespace() 91 | 92 | // Don't introduce variables names into scope 93 | return 'skip' 94 | } 95 | 96 | scopeLeave(visitor: ScopeVisitor): void {} 97 | 98 | typeCheckerEnter(visitor: TypeCheckerVisitor): void { 99 | const { genericParameters, declarations, name } = this.syntaxNode.data 100 | const { typeChecker } = visitor 101 | 102 | const genericNames = compact( 103 | genericParameters.map(param => 104 | param.type === 'parameter' ? param.data.name.name : undefined 105 | ) 106 | ) 107 | 108 | const genericsInScope = genericNames.map(x => [ 109 | x, 110 | typeChecker.typeNameGenerator.next(), 111 | ]) 112 | 113 | const universalTypes = genericNames.map((x, i) => ({ 114 | type: 'generic', 115 | name: genericsInScope[i][1], 116 | })) 117 | 118 | let parameterTypes: FunctionArgument[] = [] 119 | 120 | declarations.forEach(declaration => { 121 | if (declaration.type !== 'variable' || !declaration.data.annotation) { 122 | return 123 | } 124 | const { annotation, name } = declaration.data 125 | const annotationType = visitor.unificationType( 126 | [], 127 | () => typeChecker.typeNameGenerator.next(), 128 | annotation 129 | ) 130 | parameterTypes.unshift({ 131 | label: name.name, 132 | type: annotationType, 133 | }) 134 | 135 | typeChecker.nodes[name.id] = annotationType 136 | typeChecker.patternTypes[name.id] = annotationType 137 | }) 138 | 139 | const returnType: StaticType = { 140 | type: 'constructor', 141 | name: name.name, 142 | parameters: universalTypes, 143 | } 144 | 145 | const functionType: StaticType = { 146 | type: 'function', 147 | returnType, 148 | arguments: parameterTypes, 149 | } 150 | 151 | typeChecker.nodes[name.id] = functionType 152 | typeChecker.patternTypes[name.id] = functionType 153 | } 154 | 155 | typeCheckerLeave(visitor: TypeCheckerVisitor): void {} 156 | 157 | evaluationEnter(visitor: EvaluationVisitor) { 158 | const { name, declarations } = this.syntaxNode.data 159 | const { typeChecker, substitution, reporter } = visitor 160 | 161 | const type = typeChecker.patternTypes[name.id] 162 | 163 | if (!type) { 164 | reporter.error('Unknown record type') 165 | return 166 | } 167 | 168 | const recordType = substitute(substitution, type) 169 | 170 | const memberVariables: { 171 | pattern: AST.Pattern 172 | initializer: AST.Expression 173 | type: StaticType 174 | }[] = compact( 175 | declarations.map(declaration => { 176 | if (declaration.type === 'variable') { 177 | const { name: variableName, initializer } = declaration.data 178 | 179 | const memberType = typeChecker.patternTypes[variableName.id] 180 | 181 | if (!memberType || !initializer) return 182 | 183 | return { 184 | pattern: variableName, 185 | initializer: initializer, 186 | type: substitute(substitution, memberType), 187 | } 188 | } 189 | }) 190 | ) 191 | 192 | visitor.add(name.id, { 193 | label: 'Record initializer for ' + name.name, 194 | dependencies: memberVariables.map(member => member.initializer.data.id), 195 | f: values => { 196 | const defaultArguments: DefaultArguments = Object.fromEntries( 197 | memberVariables.map(({ pattern, type }, index) => { 198 | return [pattern.name, [type, values[index]]] 199 | }) 200 | ) 201 | 202 | return { 203 | type: recordType, 204 | memory: { 205 | type: 'function', 206 | value: { 207 | defaultArguments, 208 | f: record => { 209 | return { 210 | type: 'record', 211 | value: record, 212 | } 213 | }, 214 | }, 215 | }, 216 | } 217 | }, 218 | }) 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/logic/typeChecker.ts: -------------------------------------------------------------------------------- 1 | import { LogicAST as AST } from '@lona/serialization' 2 | import { MultiMap } from './multiMap' 3 | import { UUID } from './namespace' 4 | import { createTypeCheckerVisitor } from './nodes/createNode' 5 | import { Scope } from './scope' 6 | import { bool, StaticType } from './staticType' 7 | import { Constraint, substitute, Substitution } from './typeUnifier' 8 | import { Reporter } from '../utils/reporter' 9 | import { assertNever } from '../utils/typeHelpers' 10 | import { visit } from './traversal' 11 | import { EnterReturnValue, LeaveReturnValue } from 'tree-visit' 12 | 13 | class LogicNameGenerator { 14 | private prefix: string 15 | private currentIndex = 0 16 | constructor(prefix: string = '') { 17 | this.prefix = prefix 18 | } 19 | next() { 20 | this.currentIndex += 1 21 | let name = this.currentIndex.toString(36) 22 | return `${this.prefix}${name}` 23 | } 24 | } 25 | 26 | export type TypeChecker = { 27 | constraints: Constraint[] 28 | nodes: { [key: string]: StaticType } 29 | patternTypes: { [key: string]: StaticType } 30 | typeNameGenerator: LogicNameGenerator 31 | } 32 | 33 | const makeEmptyContext = (): TypeChecker => ({ 34 | constraints: [], 35 | nodes: {}, 36 | patternTypes: {}, 37 | typeNameGenerator: new LogicNameGenerator('?'), 38 | }) 39 | 40 | export class TypeCheckerVisitor { 41 | typeChecker: TypeChecker = makeEmptyContext() 42 | scope: Scope 43 | reporter: Reporter 44 | 45 | constructor(scope: Scope, reporter: Reporter) { 46 | this.scope = scope 47 | this.reporter = reporter 48 | } 49 | 50 | setType(id: UUID, type: StaticType) { 51 | this.typeChecker.nodes[id] = type 52 | } 53 | 54 | getType(id: UUID): StaticType { 55 | return this.typeChecker.nodes[id] 56 | } 57 | 58 | specificIdentifierType( 59 | scope: Scope, 60 | unificationContext: TypeChecker, 61 | id: string 62 | ): StaticType { 63 | const patternId = scope.expressionToPattern[id] 64 | 65 | if (!patternId) { 66 | return { 67 | type: 'variable', 68 | value: unificationContext.typeNameGenerator.next(), 69 | } 70 | } 71 | 72 | const scopedType = unificationContext.patternTypes[patternId] 73 | 74 | if (!scopedType) { 75 | return { 76 | type: 'variable', 77 | value: unificationContext.typeNameGenerator.next(), 78 | } 79 | } 80 | 81 | return this.replaceGenericsWithVars( 82 | () => unificationContext.typeNameGenerator.next(), 83 | scopedType 84 | ) 85 | } 86 | 87 | unificationType( 88 | genericsInScope: [string, string][], 89 | getName: () => string, 90 | typeAnnotation: AST.TypeAnnotation 91 | ): StaticType { 92 | if (typeAnnotation.type === 'typeIdentifier') { 93 | const { string, isPlaceholder } = typeAnnotation.data.identifier 94 | if (isPlaceholder) { 95 | return { 96 | type: 'variable', 97 | value: getName(), 98 | } 99 | } 100 | const generic = genericsInScope.find(g => g[0] === string) 101 | if (generic) { 102 | return { 103 | type: 'generic', 104 | name: generic[1], 105 | } 106 | } 107 | const parameters = typeAnnotation.data.genericArguments.map(arg => 108 | this.unificationType(genericsInScope, getName, arg) 109 | ) 110 | return { 111 | type: 'constructor', 112 | name: string, 113 | parameters, 114 | } 115 | } 116 | if (typeAnnotation.type === 'placeholder') { 117 | return { 118 | type: 'variable', 119 | value: getName(), 120 | } 121 | } 122 | return { 123 | type: 'variable', 124 | value: 'Function type error', 125 | } 126 | } 127 | 128 | genericNames = (type: StaticType): string[] => { 129 | if (type.type === 'variable') { 130 | return [] 131 | } 132 | if (type.type === 'constructor') { 133 | return type.parameters 134 | .map(this.genericNames) 135 | .reduce((prev, x) => prev.concat(x), []) 136 | } 137 | if (type.type === 'generic') { 138 | return [type.name] 139 | } 140 | if (type.type === 'function') { 141 | return type.arguments 142 | .map(x => x.type) 143 | .concat(type.returnType) 144 | .map(this.genericNames) 145 | .reduce((prev, x) => prev.concat(x), []) 146 | } 147 | assertNever(type) 148 | } 149 | 150 | replaceGenericsWithVars(getName: () => string, type: StaticType) { 151 | let substitution: Substitution = new MultiMap() 152 | 153 | this.genericNames(type).forEach(name => 154 | substitution.set( 155 | { type: 'generic', name }, 156 | { type: 'variable', value: getName() } 157 | ) 158 | ) 159 | 160 | return substitute(substitution, type) 161 | } 162 | } 163 | 164 | const build = ( 165 | isLeaving: boolean, 166 | node: AST.SyntaxNode, 167 | visitor: TypeCheckerVisitor 168 | ): EnterReturnValue => { 169 | const { typeChecker, scope } = visitor 170 | 171 | switch (node.type) { 172 | case 'record': 173 | case 'variable': 174 | case 'enumeration': 175 | case 'function': 176 | case 'identifierExpression': 177 | case 'memberExpression': 178 | case 'functionCallExpression': 179 | case 'literalExpression': 180 | case 'boolean': 181 | case 'number': 182 | case 'none': 183 | case 'string': 184 | case 'color': 185 | case 'array': 186 | const visitorNode = createTypeCheckerVisitor(node) 187 | 188 | if (visitorNode) { 189 | if (isLeaving) { 190 | return visitorNode.typeCheckerLeave(visitor) 191 | } else { 192 | return visitorNode.typeCheckerEnter(visitor) 193 | } 194 | } 195 | 196 | break 197 | case 'branch': { 198 | if (!isLeaving) { 199 | // the condition needs to be a Boolean 200 | visitor.setType(node.data.condition.data.id, bool) 201 | } 202 | break 203 | } 204 | case 'loop': { 205 | if (!isLeaving) { 206 | // the condition needs to be a Boolean 207 | visitor.setType(node.data.expression.data.id, bool) 208 | } 209 | break 210 | } 211 | case 'placeholder': { 212 | // Using 'placeholder' here may cause problems, since 213 | // placeholder is ambiguous in our LogicAST TS types 214 | if (isLeaving) { 215 | visitor.setType(node.data.id, { 216 | type: 'variable', 217 | value: typeChecker.typeNameGenerator.next(), 218 | }) 219 | } 220 | break 221 | } 222 | case 'return': // already handled in the revisit of the function declaration 223 | case 'parameter': // already handled in the function call 224 | case 'associatedValue': // already handled in enum declaration 225 | case 'functionType': 226 | case 'typeIdentifier': 227 | case 'declaration': 228 | case 'importDeclaration': 229 | case 'namespace': 230 | case 'assignmentExpression': 231 | case 'program': 232 | case 'enumerationCase': 233 | case 'value': 234 | case 'topLevelDeclarations': 235 | case 'topLevelParameters': 236 | case 'argument': 237 | case 'expression': 238 | break 239 | default: 240 | assertNever(node) 241 | } 242 | } 243 | 244 | export const createUnificationContext = ( 245 | rootNode: AST.SyntaxNode, 246 | scope: Scope, 247 | reporter: Reporter 248 | ): TypeChecker => { 249 | const visitor = new TypeCheckerVisitor(scope, reporter) 250 | 251 | visit(rootNode, { 252 | onEnter: node => { 253 | return build(false, node, visitor) 254 | }, 255 | // TODO: Fix return type 256 | onLeave: node => { 257 | return build(true, node, visitor) as LeaveReturnValue 258 | }, 259 | }) 260 | 261 | return visitor.typeChecker 262 | } 263 | --------------------------------------------------------------------------------