├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── contents.xcworkspacedata │ └── xcshareddata │ └── xcschemes │ └── CookInSwift.xcscheme ├── Makefile ├── .github └── workflows │ └── swift.yml ├── Sources ├── CookInSwift │ ├── Semantic Analyzer │ │ ├── Helpers.swift │ │ ├── SemanticAnalyzer.swift │ │ ├── Visitor.swift │ │ ├── IngredientModel+Extentions.swift │ │ ├── IngredientModel.swift │ │ ├── SemanticModel+Extensions.swift │ │ └── SemanticModel.swift │ ├── Extensions │ │ └── Naming+Extensions.swift │ ├── Lexer │ │ ├── Token.swift │ │ ├── Token+Extensions.swift │ │ └── Lexer.swift │ ├── FatalError.swift │ ├── Parser │ │ ├── AST.swift │ │ ├── AST+Extensions.swift │ │ └── Parser.swift │ └── Utils.swift ├── i18n │ ├── Lemmatizer.swift │ ├── Pluralization Rules │ │ └── EnPluralizerFactory.swift │ ├── Lemmatization Lists │ │ └── EnLemmatizerFactory.swift │ └── Pluralizer.swift └── ConfigParser │ └── ConfigParser.swift ├── LICENSE ├── Tests ├── CookInSwiftTests │ ├── ParserTests.swift │ ├── generate_canonical_tests.rb │ ├── IngredientModelTests.swift │ ├── SemanticAnalyzerTests.swift │ ├── LexerTests.swift │ └── ParserCanonicalTests.swift └── ConfigParserTests │ └── ConfigParserTests.swift ├── Package.swift ├── Docs └── Forking.md ├── .devcontainer ├── devcontainer.json ├── Dockerfile └── library-scripts │ ├── node-debian.sh │ └── common-debian.sh ├── README.md ├── .gitignore └── CONTRIBUTING.md /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | dev: 2 | swift build 3 | 4 | test: 5 | swift test 6 | 7 | # generate canonical tests 8 | canonical: 9 | # expects spec repository located in next to the parser's directory 10 | ruby Tests/CookInSwiftTests/generate_canonical_tests.rb ../spec/tests/canonical.yaml 11 | -------------------------------------------------------------------------------- /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: Swift 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | strategy: 12 | matrix: 13 | os: [ ubuntu-latest, macos-latest ] 14 | 15 | 16 | runs-on: ${{ matrix.os }} 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Build 21 | run: swift build -v 22 | - name: Run tests 23 | run: swift test -v 24 | -------------------------------------------------------------------------------- /Sources/CookInSwift/Semantic Analyzer/Helpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Alexey Dubovskoy on 07/03/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | public func mergeIngredientTables(_ left: IngredientTable, _ right: IngredientTable) -> IngredientTable { 11 | var result = IngredientTable() 12 | 13 | for (name, amounts) in left.ingredients { 14 | result.add(name: name, amounts: amounts) 15 | } 16 | 17 | for (name, amounts) in right.ingredients { 18 | result.add(name: name, amounts: amounts) 19 | } 20 | 21 | return result 22 | } 23 | -------------------------------------------------------------------------------- /Sources/CookInSwift/Extensions/Naming+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Naming+Extensions.swift 3 | // SwiftPascalInterpreter 4 | // 5 | // Created by Alexey Dubovskoy on 09/12/2020. 6 | // Copyright © 2017 Alexey Dubovskoy. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Lexer: CustomStringConvertible { 12 | public var description: String { 13 | return "Lexer" 14 | } 15 | } 16 | 17 | extension Parser: CustomStringConvertible { 18 | public var description: String { 19 | return "Parser" 20 | } 21 | } 22 | 23 | extension SemanticAnalyzer: CustomStringConvertible { 24 | public var description: String { 25 | return "SemanticAnalyzer" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/CookInSwift/Lexer/Token.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Token.swift 3 | // SwiftCookInSwift 4 | // 5 | // Created by Alexey Dubovskoy on 06/12/2020. 6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | 12 | enum Braces { 13 | case left 14 | case right 15 | } 16 | 17 | enum Constant { 18 | case integer(Int) 19 | case decimal(Decimal) 20 | case fractional((Int, Int)) 21 | case string(String) 22 | case space 23 | } 24 | 25 | enum Token { 26 | case eof 27 | case eol 28 | case at 29 | case percent 30 | case hash 31 | case tilde 32 | case chevron 33 | case colon 34 | case pipe 35 | case braces(Braces) 36 | case constant(Constant) 37 | } 38 | -------------------------------------------------------------------------------- /Sources/CookInSwift/FatalError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FatalError.swift 3 | // SwiftCookInSwift 4 | // 5 | // Created by Alexey Dubovskoy on 10/12/2020. 6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /** 12 | Taken from https://medium.com/@marcosantadev/how-to-test-fatalerror-in-swift-e1be9ff11a29 13 | */ 14 | func fatalError(_ message: @autoclosure () -> String = "", file: StaticString = #file, line: UInt = #line) -> Never { 15 | FatalErrorUtil.fatalErrorClosure(message(), file, line) 16 | } 17 | 18 | struct FatalErrorUtil { 19 | 20 | // 1 21 | static var fatalErrorClosure: (String, StaticString, UInt) -> Never = defaultFatalErrorClosure 22 | 23 | // 2 24 | private static let defaultFatalErrorClosure = { Swift.fatalError($0, file: $1, line: $2) } 25 | 26 | // 3 27 | static func replaceFatalError(closure: @escaping (String, StaticString, UInt) -> Never) { 28 | fatalErrorClosure = closure 29 | } 30 | 31 | // 4 32 | static func restoreFatalError() { 33 | fatalErrorClosure = defaultFatalErrorClosure 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 cooklang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Tests/CookInSwiftTests/ParserTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ParserTests.swift 3 | // SwiftCookInSwiftTests 4 | // 5 | // Created by Alexey Dubovskoy on 07/12/2020. 6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | @testable import CookInSwift 12 | 13 | class ParserTests: XCTestCase { 14 | 15 | // TODO add tests for errors and edge-cases 16 | // TODO spaces in all possible random places 17 | // special symbols, quotes 18 | 19 | func testTimerInteger() { 20 | let recipe = 21 | """ 22 | Fry for ~{10 minutes} 23 | """ 24 | 25 | let result = try! Parser.parse(recipe) as! RecipeNode 26 | 27 | let steps: [StepNode] = [ 28 | StepNode(instructions: [ 29 | DirectionNode("Fry for "), 30 | TimerNode(quantity: "10 minutes", units: "", name: ""), 31 | ]), 32 | ] 33 | 34 | let metadata: [MetadataNode] = [] 35 | 36 | let node = RecipeNode(steps: steps, metadata: metadata) 37 | 38 | XCTAssertEqual(result, node) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "CookInSwift", 8 | products: [ 9 | .library( 10 | name: "CookInSwift", 11 | targets: ["CookInSwift"]), 12 | .library( 13 | name: "ConfigParser", 14 | targets: ["ConfigParser"]), 15 | .library( 16 | name: "i18n", 17 | targets: ["i18n"]), 18 | ], 19 | targets: [ 20 | .target( 21 | name: "i18n", 22 | dependencies: []), 23 | .target( 24 | name: "ConfigParser", 25 | dependencies: []), 26 | .target( 27 | name: "CookInSwift", 28 | dependencies: ["ConfigParser", "i18n"]), 29 | .testTarget( 30 | name: "CookInSwiftTests", 31 | dependencies: ["CookInSwift"]), 32 | .testTarget( 33 | name: "ConfigParserTests", 34 | dependencies: ["ConfigParser", "CookInSwift"]) 35 | ] 36 | ) 37 | -------------------------------------------------------------------------------- /Docs/Forking.md: -------------------------------------------------------------------------------- 1 | # Forking cooklang-swift 2 | 3 | Community members wishing to contribute code to `cooklang-swift` must fork the `cooklang-swift` project 4 | (`your-github-username/cooklang-swift`). Branches pushed to that fork can then be submitted 5 | as pull requests to the upstream project (`cooklang/cooklang-swift`). 6 | 7 | To locally clone the repo so that you can pull the latest from the upstream project 8 | (`cooklang/cooklang-swift`) and push changes to your own fork (`your-github-username/cooklang-swift`): 9 | 10 | 1. [Create the forked repository](https://docs.github.com/en/get-started/quickstart/fork-a-repo#forking-a-repository) (`your-github-username/cooklang-swift`) 11 | 2. Clone the `cooklang/cooklang-swift` repository and `cd` into the folder 12 | 3. Make `cooklang/cooklang-swift` the `upstream` remote rather than `origin`: 13 | `git remote rename origin upstream`. 14 | 4. Add your fork as the `origin` remote. For example: 15 | `git remote add origin https://github.com/myusername/cooklang-swift` 16 | 5. Checkout a feature branch: `git checkout -t -b new-feature` 17 | 6. [Make changes](../CONTRIBUTING.md#prerequisites). 18 | 7. Push changes to the fork when ready to [submit a PR](../CONTRIBUTING.md#submitting-a-pull-request): 19 | `git push -u origin new-feature` 20 | -------------------------------------------------------------------------------- /Sources/CookInSwift/Semantic Analyzer/SemanticAnalyzer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SymbolTableBuilder.swift 3 | // SwiftCookInSwift 4 | // 5 | // Created by Alexey Dubovskoy on 10/12/2020. 6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public class SemanticAnalyzer: Visitor { 12 | private var currentStep: Step = Step() 13 | private var currentRecipe: Recipe = Recipe() 14 | 15 | public init() {} 16 | 17 | func analyze(node: AST) -> Recipe { 18 | visit(node: node) 19 | 20 | return currentRecipe 21 | } 22 | 23 | func visit(direction: DirectionNode) { 24 | currentStep.addText(direction) 25 | } 26 | 27 | func visit(ingredient: IngredientNode) { 28 | currentStep.addIngredient(ingredient) 29 | } 30 | 31 | func visit(equipment: EquipmentNode) { 32 | currentRecipe.addEquipment(equipment) 33 | currentStep.addEquipment(equipment) 34 | } 35 | 36 | func visit(timer: TimerNode) { 37 | currentStep.addTimer(timer) 38 | } 39 | 40 | func visit(recipe: RecipeNode) { 41 | currentRecipe.addMetadata(recipe.metadata) 42 | 43 | for step in recipe.steps { 44 | visit(step: step) 45 | 46 | currentRecipe.addStep(currentStep) 47 | currentStep = Step() 48 | } 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.202.5/containers/swift 3 | { 4 | "name": "Swift (Community)", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | "args": { 8 | // Update the VARIANT arg to pick a Swift version 9 | "VARIANT": "5.3", 10 | "INSTALL_ZSH": "false" 11 | } 12 | }, 13 | "runArgs": [ 14 | "--init", 15 | "--cap-add=SYS_PTRACE", 16 | "--security-opt", 17 | "seccomp=unconfined" 18 | ], 19 | 20 | // Set *default* container specific settings.json values on container create. 21 | "settings": { 22 | "lldb.adapterType": "bundled", 23 | "lldb.executable": "/usr/bin/lldb", 24 | "sde.languageservermode": "sourcekite", 25 | "swift.path.sourcekite": "/usr/local/bin/sourcekite" 26 | }, 27 | 28 | // Add the IDs of extensions you want installed when the container is created. 29 | "extensions": [ 30 | "vknabel.vscode-swift-development-environment", 31 | "vadimcn.vscode-lldb" 32 | ], 33 | 34 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 35 | // "forwardPorts": [], 36 | 37 | // Use 'postCreateCommand' to run commands after the container is created. 38 | // "postCreateCommand": "swiftc --version", 39 | 40 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 41 | "remoteUser": "vscode" 42 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cooklang-swift 2 | 3 | NOTE! Very first implementation of Cooklang parser, now outdated as we migrated to [Rust](https://github.com/cooklang/cooklang-rs) 4 | 5 | This project is an implementation of [Cook Language Spec](https://github.com/cooklang/spec) in Swift language. 6 | 7 | ## Key Features 8 | 9 | - Full support of spec 10 | - macOS/Linux compatible 11 | 12 | ## Install 13 | 14 | Install via the [**Swift Package Manger**](https://swift.org/package-manager/) by declaring **cooklang-swift** as a dependency in your `Package.swift`: 15 | 16 | ``` swift 17 | .package(url: "https://github.com/cooklang/cooklang-swift", from: "0.1.0") 18 | ``` 19 | 20 | Remember to add **cooklang-swift** to your target as a dependency. 21 | 22 | ## Documentation 23 | 24 | #### Using 25 | Creating Swift datastructures from the string containing recipe markup: 26 | 27 | ``` swift 28 | let parsedRecipe = try! Recipe.from(text: program) 29 | ``` 30 | 31 | #### Config parser 32 | Creating Swift datastructures from the string containing cook config: 33 | 34 | ``` swift 35 | func parseConfig(_ content: String) -> CookConfig { 36 | let parser = ConfigParser(textConfig) 37 | return parser.parse() 38 | } 39 | ``` 40 | 41 | ## Development 42 | 43 | See [Contributing](CONTRIBUTING.md) 44 | ### Codespaces 45 | 46 | - We are using the default Swift Community template from [microsoft/vscode-dev-containers](https://github.com/microsoft/vscode-dev-containers/tree/main/containers/swift) 47 | - build the package: `swift build --enable-test-discovery` 48 | - run the tests: `swift test --enable-test-discovery` 49 | -------------------------------------------------------------------------------- /Sources/CookInSwift/Semantic Analyzer/Visitor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Visitor.swift 3 | // CookInSwift 4 | // 5 | // Created by Alexey Dubovskoy on 14/12/2020. 6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol Visitor: AnyObject { 12 | func visit(node: AST) 13 | func visit(recipe: RecipeNode) 14 | func visit(step: StepNode) 15 | func visit(ingredient: IngredientNode) 16 | func visit(direction: DirectionNode) 17 | func visit(timer: TimerNode) 18 | func visit(equipment: EquipmentNode) 19 | } 20 | 21 | extension Visitor { 22 | func visit(node: AST) { 23 | switch node { 24 | case let direction as DirectionNode: 25 | visit(direction: direction) 26 | case let ingredient as IngredientNode: 27 | visit(ingredient: ingredient) 28 | case let timer as TimerNode: 29 | visit(timer: timer) 30 | case let equipment as EquipmentNode: 31 | visit(equipment: equipment) 32 | case let step as StepNode: 33 | visit(step: step) 34 | case let recipe as RecipeNode: 35 | visit(recipe: recipe) 36 | default: 37 | fatalError("Unsupported node type \(node)") 38 | } 39 | } 40 | 41 | func visit(direction: DirectionNode) { 42 | } 43 | 44 | func visit(ingredient: IngredientNode) { 45 | } 46 | 47 | func visit(timer: TimerNode) { 48 | } 49 | 50 | func visit(equipment: EquipmentNode) { 51 | } 52 | 53 | func visit(step: StepNode) { 54 | for item in step.children { 55 | visit(node: item) 56 | } 57 | } 58 | 59 | func visit(recipe: RecipeNode) { 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /Sources/CookInSwift/Semantic Analyzer/IngredientModel+Extentions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IngredientModel+Extentions.swift 3 | // 4 | // 5 | // Created by Alexey Dubovskoy on 18/04/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | 12 | extension IngredientTable: CustomStringConvertible { 13 | public var description: String { 14 | if ingredients.count == 0 { 15 | return "–" 16 | } else { 17 | return ingredients.sorted{ $0.key < $1.key }.map{ "\($0): \($1)" }.joined(separator: "; ") 18 | } 19 | 20 | } 21 | } 22 | 23 | extension IngredientAmount: CustomStringConvertible { 24 | public var description: String { 25 | return displayAmount(quantity: quantity, units: units) 26 | } 27 | } 28 | 29 | 30 | 31 | extension IngredientAmountCollection: CustomStringConvertible { 32 | public var description: String { 33 | return enumerated().sorted{ $0.1.units < $1.1.units }.map{ "\($1)" }.joined(separator: ", ") 34 | } 35 | } 36 | 37 | extension IngredientAmountCollection { 38 | public struct Iterator { 39 | 40 | private var _i1: Array.Iterator , _i2: Array.Iterator 41 | 42 | fileprivate init(_ amountsCountable: [String: Decimal], _ amountsUncountable: [String: String]) { 43 | _i1 = amountsCountable.map{ IngredientAmount($1, $0) }.makeIterator() 44 | _i2 = amountsUncountable.map{ IngredientAmount(String($1), $0) }.makeIterator() 45 | } 46 | } 47 | } 48 | 49 | extension IngredientAmountCollection.Iterator: IteratorProtocol { 50 | public typealias Element = Array.Element 51 | 52 | public mutating func next() -> Element? { 53 | return _i1.next() ?? _i2.next() 54 | } 55 | } 56 | 57 | extension IngredientAmountCollection: Sequence { 58 | public func makeIterator() -> Iterator { 59 | return Iterator(amountsCountable, amountsUncountable) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/i18n/Lemmatizer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Alexey Dubovskoy on 10/03/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | public class Lemmatizer { 11 | public init() {} 12 | 13 | private var singularRules: [InflectorRule] = [] 14 | 15 | private var words: Set = Set() 16 | private var regexCache: [String: NSRegularExpression] = [:] 17 | 18 | public func lemma(_ string: String) -> String { 19 | return singularize(string: string) 20 | } 21 | 22 | func singularize(string: String) -> String { 23 | return apply(rules: singularRules, forString: string) 24 | } 25 | 26 | func addIrregularRule(singular: String, andPlural plural: String) { 27 | let singularRule: String = "\(plural)$" 28 | addSingularRule(rule: singularRule, forReplacement: singular) 29 | } 30 | 31 | func addSingularRule(rule: String, forReplacement replacement: String) { 32 | singularRules.append(InflectorRule(rule: rule, replacement: replacement)) 33 | regexCache[rule] = try! NSRegularExpression(pattern: rule, options: .caseInsensitive) 34 | } 35 | 36 | func uncountableWord(word: String) { 37 | words.insert(word) 38 | } 39 | 40 | func unchanging(word: String) { 41 | words.insert(word) 42 | } 43 | 44 | private func apply(rules: [InflectorRule], forString string: String) -> String { 45 | if string == "" { 46 | return "" 47 | } 48 | 49 | if words.contains(string) { 50 | return string 51 | } else { 52 | for rule in rules { 53 | let range = NSMakeRange(0, string.count) 54 | let regex: NSRegularExpression = regexCache[rule.rule]! 55 | if let _ = regex.firstMatch(in: string, options: [], range: range) { 56 | return regex.stringByReplacingMatches(in: string, options: [], range: range, withTemplate: rule.replacement) 57 | } 58 | } 59 | } 60 | return string 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # [Choice] Swift version: 5.3, 5.2, 5.1, 4.2 2 | ARG VARIANT=5.3 3 | FROM swift:${VARIANT} 4 | 5 | # [Option] Install zsh 6 | ARG INSTALL_ZSH="true" 7 | # [Option] Upgrade OS packages to their latest versions 8 | ARG UPGRADE_PACKAGES="false" 9 | 10 | # Install needed packages and setup non-root user. Use a separate RUN statement to add your own dependencies. 11 | ARG USERNAME=vscode 12 | ARG USER_UID=1000 13 | ARG USER_GID=$USER_UID 14 | COPY library-scripts/common-debian.sh /tmp/library-scripts/ 15 | RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 16 | && /bin/bash /tmp/library-scripts/common-debian.sh "${INSTALL_ZSH}" "${USERNAME}" "${USER_UID}" "${USER_GID}" "${UPGRADE_PACKAGES}" "true" "true" \ 17 | && apt-get -y install --no-install-recommends lldb python3-minimal libpython3.7 \ 18 | && apt-get autoremove -y && apt-get clean -y && rm -rf /var/lib/apt/lists/* && rm -rf /tmp/library-scripts 19 | 20 | # Install SourceKite, see https://github.com/vknabel/vscode-swift-development-environment/blob/master/README.md#installation 21 | RUN git clone https://github.com/vknabel/sourcekite \ 22 | && export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/lib/swift:/usr/lib \ 23 | && ln -s /usr/lib/libsourcekitdInProc.so /usr/lib/sourcekitdInProc \ 24 | && cd sourcekite && make install PREFIX=/usr/local -j2 25 | 26 | # [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 27 | ARG NODE_VERSION="none" 28 | ENV NVM_DIR=/usr/local/share/nvm 29 | ENV NVM_SYMLINK_CURRENT=true \ 30 | PATH=${NVM_DIR}/current/bin:${PATH} 31 | COPY library-scripts/node-debian.sh /tmp/library-scripts/ 32 | RUN bash /tmp/library-scripts/node-debian.sh "${NVM_DIR}" "${NODE_VERSION}" "${USERNAME}" \ 33 | && rm -rf /var/lib/apt/lists/* /tmp/library-scripts 34 | 35 | # [Optional] Uncomment this section to install additional OS packages you may want. 36 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 37 | # && apt-get -y install --no-install-recommends 38 | 39 | # [Optional] Uncomment this line to install global node packages. 40 | # RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 41 | -------------------------------------------------------------------------------- /Sources/i18n/Pluralization Rules/EnPluralizerFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Alexey Dubovskoy on 10/03/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | public class EnPluralizerFactory { 11 | static let uncountables = ["alcohol", "bacon", "beef", "beer", "bread", "butter", "cheese", "coffee", "cream", "fish", "flour", "flu", "food", "garlic", "ground", "honey", "ice", "iron", "jelly", "mayonnaise", "meat", "milk", "oil", "pasta", "rice", "rum", "salad", "sheep", "soup", "steam", "toast", "veal", "vengeance", "g", "kg", "tbsp", "tsp", "ml", "l", "small", "medium", "large"] 12 | 13 | static let singularToPlural = [ 14 | ("$", "s"), 15 | ("s$", "s"), 16 | ("^(ax|test)is$", "$1es"), 17 | ("(octop|vir)us$", "$1i"), 18 | ("(octop|vir)i$", "$1i"), 19 | ("(alias|status)$", "$1es"), 20 | ("(bu)s$", "$1ses"), 21 | ("(buffal|tomat)o$", "$1oes"), 22 | ("([ti])um$", "$1a"), 23 | ("([ti])a$", "$1a"), 24 | ("sis$", "ses"), 25 | ("(?:([^f])fe|([lr])f)$", "$1$2ves"), 26 | ("(hive)$", "$1s"), 27 | ("([^aeiouy]|qu)y$", "$1ies"), 28 | ("(x|ch|ss|sh)$", "$1es"), 29 | ("(matr|vert|ind)(?:ix|ex)$", "$1ices"), 30 | ("^(m|l)ouse$", "$1ice"), 31 | ("^(m|l)ice$", "$1ice"), 32 | ("^(ox)$", "$1en"), 33 | ("^(oxen)$", "$1"), 34 | ("(quiz)$", "$1zes")] 35 | 36 | static let unchangings = [ 37 | "sheep", 38 | "deer", 39 | "moose", 40 | "swine", 41 | "bison", 42 | "corps"] 43 | 44 | static let irregulars = [ 45 | ("person", "people"), 46 | ] 47 | 48 | public static func create() -> Pluralizer { 49 | let pluralizer = Pluralizer() 50 | 51 | irregulars.forEach { (key, value) in 52 | pluralizer.addIrregularRule(singular: key, andPlural: value) 53 | } 54 | 55 | singularToPlural.reversed().forEach { (key, value) in 56 | pluralizer.addPluralRule(rule: key, forReplacement: value) 57 | } 58 | 59 | unchangings.forEach { pluralizer.unchanging(word: $0) } 60 | uncountables.forEach { pluralizer.uncountableWord(word: $0) } 61 | 62 | return pluralizer 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /Sources/ConfigParser/ConfigParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConfigParser.swift 3 | // 4 | // 5 | // Created by Alexey Dubovskoy on 07/04/2021. 6 | // 7 | 8 | 9 | import Foundation 10 | 11 | public class CookConfig { 12 | 13 | public var sections: [String: [String]] = [:] 14 | public var items: [String: String] = [:] 15 | 16 | public func add(item: String, section: String) { 17 | items[item] = section 18 | 19 | if sections[section] == nil { 20 | sections[section] = [] 21 | } 22 | 23 | sections[section]?.append(item) 24 | 25 | } 26 | } 27 | 28 | extension CookConfig: CustomStringConvertible { 29 | public var description: String { 30 | return sections.description 31 | } 32 | } 33 | 34 | extension CookConfig: Equatable { 35 | public static func == (lhs: CookConfig, rhs: CookConfig) -> Bool { 36 | return lhs.sections == rhs.sections 37 | } 38 | } 39 | 40 | func trim(_ s: String) -> String { 41 | let whitespaces = CharacterSet(charactersIn: " \n\r\t") 42 | return s.trimmingCharacters(in: whitespaces) 43 | } 44 | 45 | 46 | func parseSectionHeader(_ line: String) -> String { 47 | let from = line.index(after: line.startIndex) 48 | let to = line.index(before: line.endIndex) 49 | return String(line[from.. [String] { 54 | return String(line).components(separatedBy: "|").map{ trim($0) } 55 | } 56 | 57 | 58 | public class ConfigParser { 59 | var text: String 60 | // MARK: - Fields 61 | 62 | public init(_ text: String) { 63 | self.text = text 64 | } 65 | 66 | public func parse() -> CookConfig { 67 | let config = CookConfig() 68 | var currentSectionName = "Main" 69 | for line in text.components(separatedBy: "\n") { 70 | let line = trim(line) 71 | if (line == "") { continue } 72 | 73 | if line.hasPrefix("[") && line.hasSuffix("]") { 74 | currentSectionName = parseSectionHeader(line) 75 | } else { 76 | parseLine(line).forEach { item in 77 | config.add(item: item, section: currentSectionName) 78 | } 79 | } 80 | } 81 | 82 | return config 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Tests/ConfigParserTests/ConfigParserTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConfigParserTests.swift 3 | // 4 | // 5 | // Created by Alexey Dubovskoy on 07/04/2021. 6 | // 7 | 8 | import Foundation 9 | @testable import CookInSwift 10 | import XCTest 11 | import ConfigParser 12 | 13 | class ConfigParserTests: XCTestCase { 14 | 15 | func testBasicConfig() { 16 | let textConfig = 17 | """ 18 | [fruit and veg] 19 | apples 20 | bananas 21 | beetroots 22 | 23 | [milk and dairy] 24 | butter 25 | egg 26 | """ 27 | 28 | let parser = ConfigParser(textConfig) 29 | let result = parser.parse() 30 | 31 | let sections = ["fruit and veg": ["apples", "bananas", "beetroots"], 32 | "milk and dairy": ["butter", "egg"]] 33 | 34 | let items = ["egg": "milk and dairy", 35 | "butter": "milk and dairy", 36 | "beetroots": "fruit and veg", 37 | "apples": "fruit and veg", 38 | "bananas": "fruit and veg"] 39 | 40 | XCTAssertEqual(result.sections, sections) 41 | XCTAssertEqual(result.items, items) 42 | } 43 | 44 | func testBasicConfigWithSynonyms() { 45 | let textConfig = 46 | """ 47 | [fruit and veg] 48 | apples|apple 49 | bananas|banana 50 | beetroots|beetroot 51 | 52 | [milk and dairy] 53 | butter 54 | egg|eggs 55 | """ 56 | 57 | let parser = ConfigParser(textConfig) 58 | let result = parser.parse() 59 | 60 | let sections = ["fruit and veg": ["apples", "apple", "bananas", "banana", "beetroots", "beetroot"], 61 | "milk and dairy": ["butter", "egg", "eggs"]] 62 | 63 | let items = ["apple": "fruit and veg", 64 | "banana": "fruit and veg", 65 | "egg": "milk and dairy", 66 | "beetroot": "fruit and veg", 67 | "eggs": "milk and dairy", 68 | "beetroots": "fruit and veg", 69 | "apples": "fruit and veg", 70 | "bananas": "fruit and veg", 71 | "butter": "milk and dairy"] 72 | 73 | XCTAssertEqual(result.sections, sections) 74 | XCTAssertEqual(result.items, items) 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /Sources/CookInSwift/Semantic Analyzer/IngredientModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IngredientModel.swift 3 | // 4 | // 5 | // Created by Alexey Dubovskoy on 17/04/2021. 6 | // 7 | 8 | import Foundation 9 | import i18n 10 | 11 | public protocol ValueProtocol {} 12 | 13 | extension String:ValueProtocol {} 14 | extension Int:ValueProtocol {} 15 | extension Decimal:ValueProtocol {} 16 | 17 | public struct IngredientAmount { 18 | public var quantity: ValueProtocol 19 | public var units: String 20 | 21 | init(_ quantity: ValueProtocol, _ units: String) { 22 | self.quantity = quantity 23 | self.units = units 24 | } 25 | 26 | init(_ quantity: Int, _ units: String) { 27 | self.quantity = quantity 28 | self.units = units 29 | } 30 | 31 | init(_ quantity: Decimal, _ units: String) { 32 | self.quantity = quantity 33 | self.units = units 34 | } 35 | 36 | init(_ quantity: String, _ units: String) { 37 | self.quantity = quantity 38 | self.units = units 39 | } 40 | } 41 | 42 | 43 | public struct IngredientAmountCollection { 44 | var amountsCountable: [String: Decimal] = [:] 45 | var amountsUncountable: [String: String] = [:] 46 | 47 | mutating func add(_ amount: IngredientAmount) { 48 | // TODO locale 49 | let units = RuntimeSupport.lemmatizer.lemma(amount.units) 50 | 51 | switch amount.quantity.self { 52 | case let value as Int: 53 | amountsCountable[units] = amountsCountable[units, default: 0] + Decimal(value) 54 | case let value as Decimal: 55 | amountsCountable[units] = amountsCountable[units, default: 0] + value 56 | case let value as String: 57 | amountsUncountable[amount.units] = value 58 | default: 59 | fatalError("Unrecognised value type") 60 | } 61 | } 62 | } 63 | 64 | public struct IngredientTable { 65 | public var ingredients: [String: IngredientAmountCollection] = [:] 66 | 67 | public init() { 68 | } 69 | 70 | mutating public func add(name: String, amount: IngredientAmount) { 71 | if ingredients[name] == nil { 72 | ingredients[name] = IngredientAmountCollection() 73 | } 74 | 75 | ingredients[name]?.add(amount) 76 | } 77 | 78 | mutating public func add(name: String, amounts: IngredientAmountCollection) { 79 | amounts.forEach { 80 | add(name: name, amount: $0) 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Sources/i18n/Lemmatization Lists/EnLemmatizerFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Alexey Dubovskoy on 10/03/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | public class EnLemmatizerFactory { 11 | static let uncountables = ["alcohol", "bacon", "beef", "beer", "bread", "butter", "cheese", "coffee", "cream", "fish", "flour", "flu", "food", "garlic", "ground", "honey", "ice", "iron", "jelly", "mayonnaise", "meat", "milk", "oil", "pasta", "rice", "rum", "salad", "sheep", "soup", "steam", "toast", "veal", "vengeance", "g", "kg", "tbsp", "tsp", "ml", "l", "small", "medium", "large"] 12 | 13 | static let pluralToSingular = [ 14 | ("s$", ""), 15 | ("(ss)$", "$1"), 16 | ("(n)ews$", "$1ews"), 17 | ("([ti])a$", "$1um"), 18 | ("((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)(sis|ses)$", "$1sis"), 19 | ("(^analy)(sis|ses)$", "$1sis"), 20 | ("([^f])ves$", "$1fe"), 21 | ("(hive)s$", "$1"), 22 | ("(tive)s$", "$1"), 23 | ("([lr])ves$", "$1f"), 24 | ("([^aeiouy]|qu)ies$", "$1y"), 25 | ("(s)eries$", "$1eries"), 26 | ("(m)ovies$", "$1ovie"), 27 | ("(x|ch|ss|sh)es$", "$1"), 28 | ("^(m|l)ice$", "$1ouse"), 29 | ("(bus)(es)?$", "$1"), 30 | ("(o)es$", "$1"), 31 | ("(shoe)s$", "$1"), 32 | ("(cris|test)(is|es)$", "$1is"), 33 | ("^(a)x[ie]s$", "$1xis"), 34 | ("(octop|vir)(us|i)$", "$1us"), 35 | ("(alias|status)(es)?$", "$1"), 36 | ("^(ox)en", "$1"), 37 | ("(vert|ind)ices$", "$1ex"), 38 | ("(matr)ices$", "$1ix"), 39 | ("(quiz)zes$", "$1"), 40 | ("(database)s$", "$1")] 41 | 42 | static let unchangings = [ 43 | "sheep", 44 | "deer", 45 | "moose", 46 | "swine", 47 | "bison", 48 | "corps"] 49 | 50 | static let irregulars = [ 51 | ("person", "people"), 52 | ] 53 | 54 | public static func create() -> Lemmatizer { 55 | let lemmatizer = Lemmatizer() 56 | 57 | irregulars.forEach { (key, value) in 58 | lemmatizer.addIrregularRule(singular: key, andPlural: value) 59 | } 60 | 61 | pluralToSingular.reversed().forEach { (key, value) in 62 | lemmatizer.addSingularRule(rule: key, forReplacement: value) 63 | } 64 | 65 | unchangings.forEach { lemmatizer.unchanging(word: $0) } 66 | uncountables.forEach { lemmatizer.uncountableWord(word: $0) } 67 | 68 | return lemmatizer 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | 92 | .DS_Store 93 | -------------------------------------------------------------------------------- /Tests/CookInSwiftTests/generate_canonical_tests.rb: -------------------------------------------------------------------------------- 1 | require "yaml" 2 | 3 | definitions = YAML.load_file(ARGV[0]) 4 | 5 | output = <<-SWIFT 6 | // file is autogenerated from canonical_tests.yaml 7 | // 8 | // $ ruby Tests/CookInSwiftTests/generate_canonical_tests.rb ../spec/tests/canonical.yaml 9 | // 10 | // don't edit this file 11 | // 12 | // version: #{definitions["version"]} 13 | // 14 | 15 | import Foundation 16 | import XCTest 17 | @testable import CookInSwift 18 | 19 | class ParserCanonicalTests: XCTestCase { 20 | SWIFT 21 | 22 | class String 23 | def indent!(amount) 24 | gsub!(/^(?!$)/, " " * amount) 25 | end 26 | end 27 | 28 | 29 | definitions["tests"].each do |name, definition| 30 | metadata = "[" 31 | 32 | definition["result"]["metadata"].each do |key, value| 33 | # TODO values which are numbers 34 | metadata << %Q| 35 | MetadataNode("#{key}", "#{value}"),| 36 | end 37 | 38 | metadata << "]" 39 | 40 | steps = "" 41 | 42 | definition["result"]["steps"].each do |step| 43 | directions = "" 44 | 45 | step.each do |direction| 46 | directions << case direction["type"] 47 | when "text" 48 | %Q| 49 | DirectionNode("#{direction["value"]}"),| 50 | when "ingredient" 51 | quantity = direction["quantity"] 52 | 53 | if quantity.kind_of?(String) 54 | # wrap into quotes 55 | quantity = %Q("#{quantity}") 56 | end 57 | %Q| 58 | IngredientNode(name: "#{direction["name"]}", amount: AmountNode(quantity: #{quantity}, units: "#{direction["units"]}")),| 59 | when "cookware" 60 | quantity = direction["quantity"] 61 | 62 | if quantity.kind_of?(String) 63 | # wrap into quotes 64 | quantity = %Q("#{quantity}") 65 | end 66 | # TODO add amount: AmountNode(quantity: #{quantity}) 67 | %Q| 68 | EquipmentNode(name: "#{direction["name"]}"),| 69 | when "timer" 70 | quantity = direction["quantity"] 71 | 72 | if quantity.kind_of?(String) 73 | # wrap into quotes 74 | quantity = %Q("#{quantity}") 75 | end 76 | # TODO add 77 | %Q| 78 | TimerNode(quantity: #{quantity}, units: "#{direction["units"]}", name: "#{direction["name"]}"),| 79 | else 80 | "" 81 | end 82 | end 83 | 84 | steps << %Q| 85 | StepNode(instructions: [#{directions} 86 | ]),| 87 | end 88 | 89 | output << %Q| 90 | func #{name}() { 91 | let recipe = 92 | """ 93 | #{definition["source"].strip.indent!(16, )} 94 | """ 95 | 96 | let result = try! Parser.parse(recipe) as! RecipeNode 97 | 98 | let steps: [StepNode] = [#{steps} 99 | ] 100 | 101 | let metadata: [MetadataNode] = #{metadata} 102 | 103 | let node = RecipeNode(steps: steps, metadata: metadata) 104 | 105 | XCTAssertEqual(result, node) 106 | } 107 | | 108 | end 109 | 110 | output << "}" 111 | 112 | 113 | File.write(File.join(File.dirname(__FILE__), "ParserCanonicalTests.swift"), output) 114 | -------------------------------------------------------------------------------- /Sources/i18n/Pluralizer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Pluralize.swift 3 | // link: 4 | // https://github.com/joshualat/Pluralize.swift 5 | // 6 | // usage: 7 | // "Tooth".pluralize 8 | // "Nutrtion".pluralize 9 | // "House".pluralize(count: 1) 10 | // "Person".pluralize(count: 2, with: "Persons") 11 | // 12 | // Copyright (c) 2014 Joshua Arvin Lat 13 | // 14 | // MIT License 15 | // 16 | // Permission is hereby granted, free of charge, to any person obtaining 17 | // a copy of this software and associated documentation files (the 18 | // "Software"), to deal in the Software without restriction, including 19 | // without limitation the rights to use, copy, modify, merge, publish, 20 | // distribute, sublicense, and/or sell copies of the Software, and to 21 | // permit persons to whom the Software is furnished to do so, subject to 22 | // the following conditions: 23 | // 24 | // The above copyright notice and this permission notice shall be 25 | // included in all copies or substantial portions of the Software. 26 | // 27 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 28 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 29 | // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 30 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 31 | // LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 32 | // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 33 | // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 34 | import Foundation 35 | 36 | struct InflectorRule { 37 | var rule: String 38 | var replacement: String 39 | } 40 | 41 | public class Pluralizer { 42 | 43 | private var pluralRules: [InflectorRule] = [] 44 | private var words: Set = Set() 45 | private var regexCache: [String: NSRegularExpression] = [:] 46 | 47 | 48 | public func pluralize(string: String) -> String { 49 | return apply(rules: pluralRules, forString: string) 50 | } 51 | 52 | public func pluralize(string: String, count: Int) -> String { 53 | if count == 1 { return string } 54 | return pluralize(string: string) 55 | } 56 | 57 | func addIrregularRule(singular: String, andPlural plural: String) { 58 | let pluralRule: String = "\(singular)$" 59 | addPluralRule(rule: pluralRule, forReplacement: plural) 60 | } 61 | 62 | func addPluralRule(rule: String, forReplacement replacement: String) { 63 | pluralRules.append(InflectorRule(rule: rule, replacement: replacement)) 64 | regexCache[rule] = try! NSRegularExpression(pattern: rule, options: .caseInsensitive) 65 | } 66 | 67 | func uncountableWord(word: String) { 68 | words.insert(word) 69 | } 70 | 71 | func unchanging(word: String) { 72 | words.insert(word) 73 | } 74 | 75 | private func apply(rules: [InflectorRule], forString string: String) -> String { 76 | if string == "" { 77 | return "" 78 | } 79 | 80 | if words.contains(string) { 81 | return string 82 | } else { 83 | for rule in rules { 84 | let range = NSMakeRange(0, string.count) 85 | let regex: NSRegularExpression = regexCache[rule.rule]! 86 | if let _ = regex.firstMatch(in: string, options: [], range: range) { 87 | return regex.stringByReplacingMatches(in: string, options: [], range: range, withTemplate: rule.replacement) 88 | } 89 | } 90 | } 91 | return string 92 | } 93 | } 94 | 95 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/CookInSwift.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 44 | 45 | 47 | 53 | 54 | 55 | 56 | 57 | 67 | 68 | 74 | 75 | 81 | 82 | 83 | 84 | 86 | 87 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /Sources/CookInSwift/Semantic Analyzer/SemanticModel+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Symbol+Extensions.swift 3 | // SwiftCookInSwift 4 | // 5 | // Created by Alexey Dubovskoy on 10/12/2020. 6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import i18n 11 | 12 | 13 | extension Recipe: Equatable { 14 | public static func == (lhs: Recipe, rhs: Recipe) -> Bool { 15 | return lhs.steps == rhs.steps && lhs.metadata == rhs.metadata 16 | } 17 | } 18 | 19 | 20 | 21 | extension Step: CustomStringConvertible { 22 | public var description: String { 23 | return """ 24 | Directions: 25 | > \(directions.description) 26 | Ingredients: 27 | > \(ingredientsTable.description) 28 | """ 29 | } 30 | } 31 | 32 | extension Step: Equatable { 33 | public static func ==(lhs: Step, rhs: Step) -> Bool { 34 | // TODO too expensive 35 | return lhs.directions.map { $0.description }.joined() == rhs.directions.map { $0.description }.joined() 36 | } 37 | } 38 | 39 | 40 | 41 | extension TextItem: CustomStringConvertible { 42 | public var description: String { 43 | return value 44 | } 45 | } 46 | 47 | extension TextItem: Equatable { 48 | public static func == (lhs: TextItem, rhs: TextItem) -> Bool { 49 | return lhs.value == rhs.value 50 | } 51 | } 52 | 53 | 54 | 55 | extension Equipment: Equatable { 56 | public static func == (lhs: Equipment, rhs: Equipment) -> Bool { 57 | return lhs.name == rhs.name 58 | } 59 | } 60 | 61 | extension Equipment: CustomStringConvertible { 62 | public var description: String { 63 | return name 64 | } 65 | } 66 | 67 | 68 | 69 | 70 | extension Timer: Equatable { 71 | public static func == (lhs: Timer, rhs: Timer) -> Bool { 72 | switch lhs.quantity.self { 73 | case let lhsValue as Int: 74 | if type(of: rhs.quantity) == Int.self { 75 | return lhsValue == (rhs.quantity as! Int) 76 | } else { 77 | return false 78 | } 79 | case let lhsValue as Decimal: 80 | if type(of: rhs.quantity) == Decimal.self { 81 | return lhsValue == (rhs.quantity as! Decimal) 82 | } else { 83 | return false 84 | } 85 | case let lhsValue as String: 86 | if type(of: rhs.quantity) == String.self { 87 | return lhsValue == (rhs.quantity as! String) 88 | } else { 89 | return false 90 | } 91 | default: 92 | fatalError("Unrecognised value type") 93 | } 94 | } 95 | } 96 | 97 | extension Timer: CustomStringConvertible { 98 | public var description: String { 99 | return displayAmount(quantity: quantity, units: units) 100 | } 101 | } 102 | 103 | 104 | 105 | extension Ingredient: Equatable { 106 | public static func == (lhs: Ingredient, rhs: Ingredient) -> Bool { 107 | return lhs.name == rhs.name 108 | } 109 | } 110 | 111 | 112 | extension Ingredient: CustomStringConvertible { 113 | public var description: String { 114 | return name 115 | } 116 | } 117 | 118 | 119 | extension Recipe { 120 | public static func from(text: String) throws -> Recipe { 121 | RuntimeSupport.setLemmatizer(EnLemmatizerFactory.create()) 122 | RuntimeSupport.setPluralizer(EnPluralizerFactory.create()) 123 | 124 | let analyzer = SemanticAnalyzer() 125 | 126 | let node = try Parser.parse(text) as! RecipeNode 127 | 128 | return analyzer.analyze(node: node) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /Tests/CookInSwiftTests/IngredientModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IngredientModelTests.swift 3 | // CookInSwiftTests 4 | // 5 | // Created by Alexey Dubovskoy on 16/02/2021. 6 | // Copyright © 2021 Alexey Dubovskoy. All rights reserved. 7 | // 8 | 9 | 10 | import Foundation 11 | @testable import CookInSwift 12 | import XCTest 13 | 14 | class IngredientModelTests: XCTestCase { 15 | 16 | func testIngredientTableAddition() { 17 | var table1 = IngredientTable() 18 | 19 | table1.add(name: "chilli", amount: IngredientAmount(3, "items")) 20 | table1.add(name: "chilli", amount: IngredientAmount(1, "medium")) 21 | 22 | var table2 = IngredientTable() 23 | 24 | table2.add(name: "chilli", amount: IngredientAmount(5, "items")) 25 | table1.add(name: "chilli", amount: IngredientAmount(3, "small")) 26 | 27 | XCTAssertEqual(mergeIngredientTables(table1, table2).description, "chilli: 8 items, 1 medium, 3 small") 28 | } 29 | 30 | func testSameUnitsAndType() { 31 | var collection = IngredientAmountCollection() 32 | 33 | collection.add(IngredientAmount(1, "g")) 34 | collection.add(IngredientAmount(3, "g")) 35 | 36 | 37 | XCTAssertEqual(collection.description, "4 g") 38 | } 39 | 40 | func testDifferentUnits() { 41 | var collection = IngredientAmountCollection() 42 | 43 | collection.add(IngredientAmount(50, "g")) 44 | collection.add(IngredientAmount(50, "g")) 45 | collection.add(IngredientAmount(1, "kg")) 46 | 47 | 48 | XCTAssertEqual(collection.description, "100 g, 1 kg") 49 | } 50 | 51 | func testDifferenQuantityTypes() { 52 | var collection = IngredientAmountCollection() 53 | 54 | collection.add(IngredientAmount(500, "g")) 55 | collection.add(IngredientAmount(1.5, "kg")) 56 | collection.add(IngredientAmount(Decimal(1), "kg")) 57 | 58 | 59 | XCTAssertEqual(collection.description, "500 g, 2.5 kg") 60 | } 61 | 62 | func testFractionsQuantityTypes() { 63 | var collection = IngredientAmountCollection() 64 | 65 | collection.add(IngredientAmount(0.5, "cup")) 66 | collection.add(IngredientAmount(1, "cup")) 67 | 68 | 69 | XCTAssertEqual(collection.description, "1.5 cups") 70 | } 71 | 72 | func testFractionsQuantityDecimalTypes() { 73 | var collection = IngredientAmountCollection() 74 | 75 | collection.add(IngredientAmount(Decimal(1) / Decimal(3), "cup")) 76 | collection.add(IngredientAmount(1, "cup")) 77 | 78 | 79 | XCTAssertEqual(collection.description, "1.3 cups") 80 | } 81 | 82 | func testWithPluralUnits() { 83 | var collection = IngredientAmountCollection() 84 | 85 | collection.add(IngredientAmount(1, "cup")) 86 | collection.add(IngredientAmount(2, "cups")) 87 | 88 | 89 | XCTAssertEqual(collection.description, "3 cups") 90 | } 91 | 92 | func testWithPluralAndSingularIngredient() { 93 | var collection = IngredientAmountCollection() 94 | 95 | collection.add(IngredientAmount(1, "onion")) 96 | collection.add(IngredientAmount(2, "onions")) 97 | 98 | XCTAssertEqual(collection.description, "3 onions") 99 | } 100 | 101 | func testWithTextQuantity() { 102 | var collection = IngredientAmountCollection() 103 | 104 | collection.add(IngredientAmount("few", "springs")) 105 | 106 | XCTAssertEqual(collection.description, "few springs") 107 | } 108 | 109 | 110 | // test valid ingridient: when only units, but no name of in 111 | 112 | 113 | } 114 | -------------------------------------------------------------------------------- /Sources/CookInSwift/Parser/AST.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AST.swift 3 | // SwiftCookInSwift 4 | // 5 | // Created by Alexey Dubovskoy on 07/12/2020. 6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol AST {} 12 | 13 | enum ValueNode: AST { 14 | case integer(Int) 15 | case decimal(Decimal) 16 | case string(String) 17 | } 18 | 19 | struct DirectionNode: AST { 20 | let value: String 21 | 22 | init(_ value: String) { 23 | self.value = value 24 | } 25 | } 26 | 27 | 28 | struct MetadataNode: AST { 29 | let key: String 30 | let value: ValueNode 31 | 32 | init(_ key: String, _ value: ValueNode) { 33 | self.key = key 34 | self.value = value 35 | } 36 | 37 | init(_ key: String, _ value: String) { 38 | self.value = ValueNode.string(value) 39 | self.key = key 40 | } 41 | 42 | init(_ key: String, _ value: Int) { 43 | self.value = ValueNode.integer(value) 44 | self.key = key 45 | } 46 | 47 | init(_ key: String, _ value: Decimal) { 48 | self.value = ValueNode.decimal(value) 49 | self.key = key 50 | } 51 | 52 | } 53 | 54 | 55 | struct AmountNode: AST { 56 | let quantity: ValueNode 57 | let units: String 58 | 59 | init(quantity: ValueNode, units: String = "") { 60 | self.quantity = quantity 61 | self.units = units 62 | } 63 | 64 | init(quantity: String, units: String = "") { 65 | self.quantity = ValueNode.string(quantity) 66 | self.units = units 67 | } 68 | 69 | init(quantity: Int, units: String = "") { 70 | self.quantity = ValueNode.integer(quantity) 71 | self.units = units 72 | } 73 | 74 | init(quantity: Decimal, units: String = "") { 75 | self.quantity = ValueNode.decimal(quantity) 76 | self.units = units 77 | } 78 | 79 | } 80 | 81 | struct IngredientNode: AST { 82 | let name: String 83 | let amount: AmountNode 84 | 85 | init(name: String, amount: AmountNode) { 86 | self.name = name 87 | self.amount = amount 88 | } 89 | } 90 | 91 | struct EquipmentNode: AST { 92 | let name: String 93 | let quantity: ValueNode? 94 | 95 | init(name: String, quantity: ValueNode? = nil) { 96 | self.name = name 97 | self.quantity = quantity 98 | } 99 | } 100 | 101 | struct TimerNode: AST { 102 | let quantity: ValueNode 103 | let units: String 104 | let name: String 105 | 106 | init(quantity: ValueNode, units: String, name: String = "") { 107 | self.quantity = quantity 108 | self.units = units 109 | self.name = name 110 | } 111 | 112 | init(quantity: String, units: String, name: String = "") { 113 | self.quantity = ValueNode.string(quantity) 114 | self.units = units 115 | self.name = name 116 | } 117 | 118 | init(quantity: Int, units: String, name: String = "") { 119 | self.quantity = ValueNode.integer(quantity) 120 | self.units = units 121 | self.name = name 122 | } 123 | 124 | init(quantity: Decimal, units: String, name: String = "") { 125 | self.quantity = ValueNode.decimal(quantity) 126 | self.units = units 127 | self.name = name 128 | } 129 | } 130 | 131 | struct StepNode: AST { 132 | let instructions: [AST] 133 | 134 | init(instructions: [AST]) { 135 | self.instructions = instructions 136 | } 137 | } 138 | 139 | struct RecipeNode: AST { 140 | let steps: [StepNode] 141 | let metadata: [MetadataNode] 142 | 143 | init(steps: [StepNode], metadata: [MetadataNode]) { 144 | self.steps = steps 145 | self.metadata = metadata 146 | } 147 | 148 | init(steps: [StepNode]) { 149 | self.steps = steps 150 | self.metadata = [] 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Getting Started 4 | 5 | ### Some Ways to Contribute 6 | 7 | * Report potential bugs. 8 | * Suggest parser enhancements. 9 | * Increase our test coverage. 10 | * Fix a [bug](https://github.com/cooklang/cooklang-swift/labels/bug). 11 | * Implement a requested [enhancement](https://github.com/cooklang/cooklang-swift/labels/enhancement). 12 | 13 | ### Reporting an Issue 14 | 15 | > Note: Issues on GitHub for `cooklang-swift` are intended to be related to bugs or feature requests. 16 | > Questions should be directed to [Spec Discussions](https://github.com/cooklang/spec/discussions). 17 | 18 | * Check existing issues (both open and closed) to make sure it has not been 19 | reported previously. 20 | 21 | * Provide a reproducible test case. If a contributor can't reproduce an issue, 22 | then it dramatically lowers the chances it'll get fixed. 23 | 24 | * Aim to respond promptly to any questions made by the `cooklang-swift` team on your 25 | issue. Stale issues will be closed. 26 | 27 | ### Issue Lifecycle 28 | 29 | 1. The issue is reported. 30 | 31 | 2. The issue is verified and categorized by a `cooklang-swift` maintainer. 32 | Categorization is done via tags. For example, bugs are tagged as "bug". 33 | 34 | 3. Unless it is critical, the issue is left for a period of time (sometimes many 35 | weeks), giving outside contributors a chance to address the issue. 36 | 37 | 4. The issue is addressed in a pull request or commit. The issue will be 38 | referenced in the commit message so that the code that fixes it is clearly 39 | linked. Any change a `cooklang-swift` user might need to know about will include a 40 | changelog entry in the PR. 41 | 42 | 5. The issue is closed. 43 | 44 | ## Making Changes to `cooklang-swift` 45 | 46 | ### Prerequisites 47 | 48 | If you wish to work on `cooklang-swift` itself, you'll first need to: 49 | - install [Swift](https://www.swift.org/download/#releases) for macOS, Linux or Windows. 50 | - [fork the `cooklang-swift` repo](../Docs/Forking.md) 51 | 52 | ### Building `cooklang-swift` 53 | 54 | `cooklang-swift` is a library intended to used by other programs. Sometimes to validate you might want to build `cooklang-swift`, do it by running `swift build`. 55 | 56 | >Note: `swift build` will build for your local machine's os/architecture. 57 | 58 | ### Testing 59 | 60 | Examples (run from the repository root): 61 | - `swift test` will run all tests 62 | - `swift test --filter CookInSwiftTests.LexerTests` will run all tests in `Lexer` module. 63 | 64 | All available options for skipping and filtering tests available via `swift test --help`. 65 | 66 | When a pull request is opened CI will run all tests to verify the change. 67 | 68 | ### Canonical tests 69 | 70 | If your changes of the parser should be generalized (for other parsers too), consider updating [Canonical tests](https://github.com/cooklang/spec/tree/main/tests). 71 | 72 | ### Submitting a Pull Request 73 | 74 | Before writing any code, we recommend: 75 | - Create a Github issue if none already exists for the code change you'd like to make. 76 | - Write a comment on the Github issue indicating you're interested in contributing so 77 | maintainers can provide their perspective if needed. 78 | 79 | Keep your pull requests (PRs) small and open them early so you can get feedback on 80 | approach from maintainers before investing your time in larger changes. 81 | 82 | When you're ready to submit a pull request: 83 | 1. Include evidence that your changes work as intended (e.g., add/modify unit tests; 84 | describe manual tests you ran, in what environment, 85 | and the results including screenshots or terminal output). 86 | 2. Open the PR from your fork against base repository `cooklang/cooklang-swift` and branch `main`. 87 | - [Link the PR to its associated issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue). 88 | 3. Include any specific questions that you have for the reviewer in the PR description 89 | or as a PR comment in Github. 90 | - If there's anything you find the need to explain or clarify in the PR, consider 91 | whether that explanation should be added in the source code as comments. 92 | - You can submit a [draft PR](https://github.blog/2019-02-14-introducing-draft-pull-requests/) 93 | if your changes aren't finalized but would benefit from in-process feedback. 94 | 6. After you submit, the `cooklang-swift` maintainers team needs time to carefully review your 95 | contribution and ensure it is production-ready, considering factors such as: correctness, 96 | backwards-compatibility, potential regressions, etc. 97 | 7. After you address `cooklang-swift` maintainer feedback and the PR is approved, a `cooklang-swift` maintainer 98 | will merge it. Your contribution will be available from the next minor release. 99 | -------------------------------------------------------------------------------- /Sources/CookInSwift/Semantic Analyzer/SemanticModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Symbol.swift 3 | // SwiftCookInSwift 4 | // 5 | // Created by Alexey Dubovskoy on 10/12/2020. 6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import i18n 11 | 12 | public class RuntimeSupport { 13 | static var pluralizer: Pluralizer = EnPluralizerFactory.create() 14 | static var lemmatizer: Lemmatizer = EnLemmatizerFactory.create() 15 | 16 | static func setPluralizer(_ p: Pluralizer) { 17 | pluralizer = p 18 | } 19 | 20 | static func setLemmatizer(_ l: Lemmatizer) { 21 | lemmatizer = l 22 | } 23 | } 24 | 25 | public struct Recipe { 26 | public var ingredientsTable: IngredientTable = IngredientTable() 27 | public var steps: [Step] = [] 28 | public var equipment: [Equipment] = [] 29 | public var metadata: [String: String] = [:] 30 | 31 | mutating func addStep(_ step: Step) { 32 | steps.append(step) 33 | ingredientsTable = mergeIngredientTables(ingredientsTable, step.ingredientsTable) 34 | } 35 | 36 | mutating func addEquipment(_ e: EquipmentNode) { 37 | equipment.append(Equipment(e.name)) 38 | } 39 | 40 | mutating func addMetadata(_ m: [MetadataNode]) { 41 | m.forEach{ item in 42 | metadata[item.key] = item.value.description 43 | } 44 | } 45 | } 46 | 47 | public struct Step { 48 | public var ingredientsTable: IngredientTable = IngredientTable() 49 | public var directions: [DirectionItem] = [] 50 | public var timers: [Timer] = [] 51 | public var equipments: [Equipment] = [] 52 | 53 | mutating func addIngredient(_ ingredient: IngredientNode) { 54 | let name = ingredient.name 55 | var quantity: ValueProtocol 56 | 57 | switch ingredient.amount.quantity { 58 | case let .integer(value): 59 | quantity = value 60 | case let .decimal(value): 61 | quantity = value 62 | case let .string(value): 63 | quantity = value 64 | } 65 | 66 | let amount = IngredientAmount(quantity, ingredient.amount.units) 67 | let ingredient = Ingredient(name, amount) 68 | 69 | ingredientsTable.add(name: name, amount: amount) 70 | directions.append(ingredient) 71 | } 72 | 73 | mutating func addTimer(_ timer: TimerNode) { 74 | var quantity: ValueProtocol 75 | 76 | switch timer.quantity { 77 | case let .integer(value): 78 | quantity = value 79 | case let .decimal(value): 80 | quantity = value 81 | case let .string(value): 82 | quantity = value 83 | } 84 | 85 | let timer = Timer(quantity, timer.units, timer.name) 86 | 87 | timers.append(timer) 88 | directions.append(timer) 89 | } 90 | 91 | mutating func addText(_ direction: DirectionNode) { 92 | let text = TextItem(direction.value.description) 93 | 94 | directions.append(text) 95 | } 96 | 97 | mutating func addEquipment(_ equipment: EquipmentNode) { 98 | let equipment = Equipment(equipment.name) 99 | 100 | equipments.append(equipment) 101 | directions.append(equipment) 102 | } 103 | } 104 | 105 | public struct TextItem: DirectionItem { 106 | public var value: String 107 | 108 | init(_ value: String) { 109 | self.value = value 110 | } 111 | } 112 | 113 | 114 | public struct Ingredient: DirectionItem { 115 | public var name: String 116 | public var amount: IngredientAmount 117 | 118 | init(_ name: String, _ amount: IngredientAmount) { 119 | self.name = name 120 | self.amount = amount 121 | } 122 | } 123 | 124 | public struct Equipment: DirectionItem { 125 | public var name: String 126 | 127 | init(_ name: String) { 128 | self.name = name 129 | } 130 | } 131 | 132 | public struct Timer: DirectionItem { 133 | public var quantity: ValueProtocol 134 | public var units: String 135 | public var name: String 136 | 137 | init(_ quantity: ValueProtocol, _ units: String, _ name: String = "") { 138 | self.quantity = quantity 139 | self.units = units 140 | self.name = name 141 | } 142 | 143 | init(_ quantity: Int, _ units: String, _ name: String = "") { 144 | self.quantity = quantity 145 | self.units = units 146 | self.name = name 147 | } 148 | 149 | init(_ quantity: String, _ units: String, _ name: String = "") { 150 | self.quantity = quantity 151 | self.units = units 152 | self.name = name 153 | } 154 | 155 | init(_ quantity: Decimal, _ units: String, _ name: String = "") { 156 | self.quantity = quantity 157 | self.units = units 158 | self.name = name 159 | } 160 | } 161 | 162 | public protocol DirectionItem { 163 | var description: String { get } 164 | } 165 | -------------------------------------------------------------------------------- /Sources/CookInSwift/Lexer/Token+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Token+Extensions.swift 3 | // SwiftCookInSwift 4 | // 5 | // Created by Alexey Dubovskoy on 09/12/2020. 6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol Literal { 12 | var literal: String { get } 13 | } 14 | 15 | extension Token: Equatable { 16 | static func == (lhs: Token, rhs: Token) -> Bool { 17 | switch (lhs, rhs) { 18 | case (.eof, .eof): 19 | return true 20 | case (.eol, .eol): 21 | return true 22 | case (.at, .at): 23 | return true 24 | case (.percent, .percent): 25 | return true 26 | case (.chevron, .chevron): 27 | return true 28 | case (.colon, .colon): 29 | return true 30 | case (.pipe, .pipe): 31 | return true 32 | case (.tilde, .tilde): 33 | return true 34 | case (.hash, .hash): 35 | return true 36 | case let (.constant(left), .constant(right)): 37 | return left == right 38 | case let (.braces(left), .braces(right)): 39 | return left == right 40 | default: 41 | return false 42 | } 43 | } 44 | } 45 | 46 | extension Constant: Equatable { 47 | static func == (lhs: Constant, rhs: Constant) -> Bool { 48 | switch (lhs, rhs) { 49 | case let (.integer(left), .integer(right)): 50 | return left == right 51 | case let (.decimal(left), .decimal(right)): 52 | return left == right 53 | case let (.fractional((leftN, leftD)), .fractional((rightN, rightD))): 54 | return leftN == rightN && leftD == rightD 55 | case let (.string(left), .string(right)): 56 | return left == right 57 | case (.space, .space): 58 | return true 59 | default: 60 | return false 61 | } 62 | } 63 | } 64 | 65 | extension Token: Literal { 66 | var literal: String { 67 | get { 68 | switch self { 69 | case .at: 70 | return "@" 71 | case .eof: 72 | return "" 73 | case .eol: 74 | return "" 75 | case .percent: 76 | return "%" 77 | case .hash: 78 | return "#" 79 | case .tilde: 80 | return "~" 81 | case .chevron: 82 | return ">" 83 | case .colon: 84 | return ":" 85 | case .pipe: 86 | return "|" 87 | case let .braces(braces): 88 | return braces.literal 89 | case let .constant(constant): 90 | return constant.literal 91 | } 92 | } 93 | } 94 | } 95 | 96 | extension Braces: Literal { 97 | var literal: String { 98 | get { 99 | switch self { 100 | case .left: 101 | return "{" 102 | case .right: 103 | return "}" 104 | } 105 | } 106 | } 107 | } 108 | 109 | extension Constant: Literal { 110 | var literal: String { 111 | get { 112 | switch self { 113 | case let .integer(value): 114 | return String(value) 115 | case let .decimal(value): 116 | return "\(value)" 117 | case let .fractional((nominator, denominator)): 118 | return "\(nominator)/\(denominator)" 119 | case let .string(value): 120 | return value 121 | case .space: 122 | return " " 123 | } 124 | } 125 | } 126 | } 127 | 128 | extension Braces: CustomStringConvertible { 129 | var description: String { 130 | switch self { 131 | case .left: 132 | return "LBRACE" 133 | case .right: 134 | return "RBRACE" 135 | } 136 | } 137 | } 138 | 139 | extension Constant: CustomStringConvertible { 140 | var description: String { 141 | switch self { 142 | case let .integer(value): 143 | return "INTEGER_CONST(\(value))" 144 | case let .decimal(value): 145 | return "DECIMAL_CONST(\(value))" 146 | case let .fractional((nominator, denominator)): 147 | return "FRACTIONAL_CONST(\(nominator)/\(denominator)" 148 | case let .string(value): 149 | return "STRING_CONST(\(value))" 150 | case .space: 151 | return "SPACE_CONST" 152 | } 153 | } 154 | } 155 | 156 | extension Token: CustomStringConvertible { 157 | var description: String { 158 | switch self { 159 | case .eof: 160 | return "EOF" 161 | case .eol: 162 | return "EOL" 163 | case .at: 164 | return "@" 165 | case .percent: 166 | return "%" 167 | case .hash: 168 | return "#" 169 | case .tilde: 170 | return "~" 171 | case .chevron: 172 | return ">" 173 | case .colon: 174 | return ":" 175 | case .pipe: 176 | return "|" 177 | case let .constant(constant): 178 | return constant.description 179 | case let .braces(braces): 180 | return braces.description 181 | } 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /Sources/CookInSwift/Parser/AST+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AST+Extensions.swift 3 | // SwiftCookInSwift 4 | // 5 | // Created by Alexey Dubovskoy on 09/12/2020. 6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Decimal { 12 | var cleanValue: String { 13 | let formatter = NumberFormatter() 14 | formatter.maximumSignificantDigits = 2 15 | return formatter.string(from: self as NSDecimalNumber)! 16 | } 17 | } 18 | 19 | 20 | extension ValueNode { 21 | init(_ value: Int) { 22 | self = .integer(value) 23 | } 24 | 25 | init(_ value: Decimal) { 26 | self = .decimal(value) 27 | } 28 | 29 | init(_ value: String) { 30 | self = .string(value) 31 | } 32 | } 33 | 34 | 35 | extension ValueNode: CustomStringConvertible { 36 | var description: String { 37 | switch self { 38 | case let .integer(value): 39 | return "\(value)" 40 | case let .decimal(value): 41 | return value.cleanValue 42 | case let .string(value): 43 | return "\(value)" 44 | } 45 | } 46 | } 47 | 48 | extension ValueNode: Equatable { 49 | static func == (lhs: ValueNode, rhs: ValueNode) -> Bool { 50 | switch (lhs, rhs) { 51 | case let (.integer(left), .integer(right)): 52 | return left == right 53 | case let (.decimal(left), .decimal(right)): 54 | return left == right 55 | case let (.decimal(left), .integer(right)): 56 | return left == Decimal(right) 57 | case let (.integer(left), .decimal(right)): 58 | return Decimal(left) == right 59 | case let (.string(left), .string(right)): 60 | return left == right 61 | case (.string(_), _): 62 | return false 63 | case (_, .string(_)): 64 | return false 65 | } 66 | } 67 | } 68 | 69 | extension RecipeNode: Equatable { 70 | static func == (lhs: RecipeNode, rhs: RecipeNode) -> Bool { 71 | return lhs.steps == rhs.steps && lhs.metadata == rhs.metadata 72 | } 73 | } 74 | 75 | extension StepNode: Equatable { 76 | static func == (lhs: StepNode, rhs: StepNode) -> Bool { 77 | return lhs.instructions.map{ ($0.value ) } == rhs.instructions.map{ ($0.value ) } 78 | } 79 | } 80 | 81 | 82 | extension MetadataNode: Equatable { 83 | static func == (lhs: MetadataNode, rhs: MetadataNode) -> Bool { 84 | return lhs.key == rhs.key && lhs.value == rhs.value 85 | } 86 | } 87 | 88 | extension EquipmentNode: Equatable { 89 | static func == (lhs: EquipmentNode, rhs: EquipmentNode) -> Bool { 90 | return lhs.name == rhs.name 91 | } 92 | } 93 | 94 | extension TimerNode: Equatable { 95 | static func == (lhs: TimerNode, rhs: TimerNode) -> Bool { 96 | return lhs.quantity == rhs.quantity && lhs.units == rhs.units 97 | } 98 | } 99 | 100 | extension AST { 101 | var value: String { 102 | switch self { 103 | case let v as ValueNode: 104 | 105 | switch v { 106 | case let .string(v): 107 | return "\(v)" 108 | case let .integer(v): 109 | return "\(v)" 110 | case let .decimal(v): 111 | return "\(v.cleanValue)" 112 | } 113 | 114 | case is RecipeNode: 115 | return "recipe" 116 | case is StepNode: 117 | return "step" 118 | case let m as MetadataNode: 119 | return "\(m.key) => \(m.value)" 120 | case let direction as DirectionNode: 121 | return direction.value 122 | case let ingredient as IngredientNode: 123 | return "ING: \(ingredient.name) [\(ingredient.amount.value)]" 124 | case let equipment as EquipmentNode: 125 | return "EQ: \(equipment.name)" 126 | case let timer as TimerNode: 127 | return "TIMER(\(timer.name)): \(timer.quantity) \(timer.units)" 128 | case let amount as AmountNode: 129 | return displayAmount(quantity: amount.quantity.value, units: amount.units) 130 | default: 131 | fatalError("Missed AST case \(self)") 132 | } 133 | } 134 | 135 | var children: [AST] { 136 | switch self { 137 | case is ValueNode: 138 | return [] 139 | case is String: 140 | return [] 141 | case let recipe as RecipeNode: 142 | return recipe.steps + recipe.metadata 143 | case let step as StepNode: 144 | return step.instructions 145 | case is DirectionNode: 146 | return [] 147 | case is IngredientNode: 148 | return [] 149 | case is TimerNode: 150 | return [] 151 | case is EquipmentNode: 152 | return [] 153 | case is AmountNode: 154 | return [] 155 | case is MetadataNode: 156 | return [] 157 | default: 158 | fatalError("Missed AST case \(self)") 159 | } 160 | } 161 | 162 | func treeLines(_ nodeIndent: String = "", _ childIndent: String = "") -> [String] { 163 | return [nodeIndent + value] 164 | + children.enumerated().map { ($0 < children.count - 1, $1) } 165 | .flatMap { $0 ? $1.treeLines("┣╸", "┃ ") : $1.treeLines("┗╸", " ") } 166 | .map { childIndent + $0 } 167 | } 168 | 169 | func printTree() -> String { return treeLines().joined(separator: "\n") } 170 | } 171 | 172 | -------------------------------------------------------------------------------- /.devcontainer/library-scripts/node-debian.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | #------------------------------------------------------------------------------------------------------------- 3 | # Copyright (c) Microsoft Corporation. All rights reserved. 4 | # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. 5 | #------------------------------------------------------------------------------------------------------------- 6 | # 7 | # Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/node.md 8 | # Maintainer: The VS Code and Codespaces Teams 9 | # 10 | # Syntax: ./node-debian.sh [directory to install nvm] [node version to install (use "none" to skip)] [non-root user] [Update rc files flag] [install node-gyp deps] 11 | 12 | export NVM_DIR=${1:-"/usr/local/share/nvm"} 13 | export NODE_VERSION=${2:-"lts"} 14 | USERNAME=${3:-"automatic"} 15 | UPDATE_RC=${4:-"true"} 16 | INSTALL_TOOLS_FOR_NODE_GYP="${5:-true}" 17 | export NVM_VERSION="0.38.0" 18 | 19 | set -e 20 | 21 | if [ "$(id -u)" -ne 0 ]; then 22 | echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' 23 | exit 1 24 | fi 25 | 26 | # Ensure that login shells get the correct path if the user updated the PATH using ENV. 27 | rm -f /etc/profile.d/00-restore-env.sh 28 | echo "export PATH=${PATH//$(sh -lc 'echo $PATH')/\$PATH}" > /etc/profile.d/00-restore-env.sh 29 | chmod +x /etc/profile.d/00-restore-env.sh 30 | 31 | # Determine the appropriate non-root user 32 | if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then 33 | USERNAME="" 34 | POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") 35 | for CURRENT_USER in ${POSSIBLE_USERS[@]}; do 36 | if id -u ${CURRENT_USER} > /dev/null 2>&1; then 37 | USERNAME=${CURRENT_USER} 38 | break 39 | fi 40 | done 41 | if [ "${USERNAME}" = "" ]; then 42 | USERNAME=root 43 | fi 44 | elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then 45 | USERNAME=root 46 | fi 47 | 48 | updaterc() { 49 | if [ "${UPDATE_RC}" = "true" ]; then 50 | echo "Updating /etc/bash.bashrc and /etc/zsh/zshrc..." 51 | if [[ "$(cat /etc/bash.bashrc)" != *"$1"* ]]; then 52 | echo -e "$1" >> /etc/bash.bashrc 53 | fi 54 | if [ -f "/etc/zsh/zshrc" ] && [[ "$(cat /etc/zsh/zshrc)" != *"$1"* ]]; then 55 | echo -e "$1" >> /etc/zsh/zshrc 56 | fi 57 | fi 58 | } 59 | 60 | # Function to run apt-get if needed 61 | apt_get_update_if_needed() 62 | { 63 | if [ ! -d "/var/lib/apt/lists" ] || [ "$(ls /var/lib/apt/lists/ | wc -l)" = "0" ]; then 64 | echo "Running apt-get update..." 65 | apt-get update 66 | else 67 | echo "Skipping apt-get update." 68 | fi 69 | } 70 | 71 | # Checks if packages are installed and installs them if not 72 | check_packages() { 73 | if ! dpkg -s "$@" > /dev/null 2>&1; then 74 | apt_get_update_if_needed 75 | apt-get -y install --no-install-recommends "$@" 76 | fi 77 | } 78 | 79 | # Ensure apt is in non-interactive to avoid prompts 80 | export DEBIAN_FRONTEND=noninteractive 81 | 82 | # Install dependencies 83 | check_packages apt-transport-https curl ca-certificates tar gnupg2 dirmngr 84 | 85 | # Install yarn 86 | if type yarn > /dev/null 2>&1; then 87 | echo "Yarn already installed." 88 | else 89 | # Import key safely (new method rather than deprecated apt-key approach) and install 90 | curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor > /usr/share/keyrings/yarn-archive-keyring.gpg 91 | echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/yarn-archive-keyring.gpg] https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list 92 | apt-get update 93 | apt-get -y install --no-install-recommends yarn 94 | fi 95 | 96 | # Adjust node version if required 97 | if [ "${NODE_VERSION}" = "none" ]; then 98 | export NODE_VERSION= 99 | elif [ "${NODE_VERSION}" = "lts" ]; then 100 | export NODE_VERSION="lts/*" 101 | fi 102 | 103 | # Create a symlink to the installed version for use in Dockerfile PATH statements 104 | export NVM_SYMLINK_CURRENT=true 105 | 106 | # Install the specified node version if NVM directory already exists, then exit 107 | if [ -d "${NVM_DIR}" ]; then 108 | echo "NVM already installed." 109 | if [ "${NODE_VERSION}" != "" ]; then 110 | su ${USERNAME} -c ". $NVM_DIR/nvm.sh && nvm install ${NODE_VERSION} && nvm clear-cache" 111 | fi 112 | exit 0 113 | fi 114 | 115 | # Create nvm group, nvm dir, and set sticky bit 116 | if ! cat /etc/group | grep -e "^nvm:" > /dev/null 2>&1; then 117 | groupadd -r nvm 118 | fi 119 | umask 0002 120 | usermod -a -G nvm ${USERNAME} 121 | mkdir -p ${NVM_DIR} 122 | chown :nvm ${NVM_DIR} 123 | chmod g+s ${NVM_DIR} 124 | su ${USERNAME} -c "$(cat << EOF 125 | set -e 126 | umask 0002 127 | # Do not update profile - we'll do this manually 128 | export PROFILE=/dev/null 129 | curl -so- https://raw.githubusercontent.com/nvm-sh/nvm/v${NVM_VERSION}/install.sh | bash 130 | source ${NVM_DIR}/nvm.sh 131 | if [ "${NODE_VERSION}" != "" ]; then 132 | nvm alias default ${NODE_VERSION} 133 | fi 134 | nvm clear-cache 135 | EOF 136 | )" 2>&1 137 | # Update rc files 138 | if [ "${UPDATE_RC}" = "true" ]; then 139 | updaterc "$(cat < /dev/null 2>&1; then 152 | to_install="${to_install} make" 153 | fi 154 | if ! type gcc > /dev/null 2>&1; then 155 | to_install="${to_install} gcc" 156 | fi 157 | if ! type g++ > /dev/null 2>&1; then 158 | to_install="${to_install} g++" 159 | fi 160 | if ! type python3 > /dev/null 2>&1; then 161 | to_install="${to_install} python3-minimal" 162 | fi 163 | if [ ! -z "${to_install}" ]; then 164 | apt_get_update_if_needed 165 | apt-get -y install ${to_install} 166 | fi 167 | fi 168 | 169 | echo "Done!" 170 | -------------------------------------------------------------------------------- /Sources/CookInSwift/Utils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Utils.swift 3 | // SwiftCookInSwift 4 | // 5 | // Created by Alexey Dubovskoy on 07/12/2020. 6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import i18n 11 | 12 | /** 13 | Generic tree formatting code from https://stackoverflow.com/a/43903427/581164 14 | */ 15 | func treeString(_ node: T, using nodeInfo: (T) -> (String, T?, T?)) -> String { 16 | // node value string and sub nodes 17 | let (stringValue, leftNode, rightNode) = nodeInfo(node) 18 | 19 | let stringValueWidth = stringValue.count 20 | 21 | // recurse to sub nodes to obtain line blocks on left and right 22 | let leftTextBlock = leftNode == nil ? [] 23 | : treeString(leftNode!, using: nodeInfo) 24 | .components(separatedBy: "\n") 25 | 26 | let rightTextBlock = rightNode == nil ? [] 27 | : treeString(rightNode!, using: nodeInfo) 28 | .components(separatedBy: "\n") 29 | 30 | // count common and maximum number of sub node lines 31 | let commonLines = min(leftTextBlock.count, rightTextBlock.count) 32 | let subLevelLines = max(rightTextBlock.count, leftTextBlock.count) 33 | 34 | // extend lines on shallower side to get same number of lines on both sides 35 | let leftSubLines = leftTextBlock 36 | + Array(repeating: "", count: subLevelLines - leftTextBlock.count) 37 | let rightSubLines = rightTextBlock 38 | + Array(repeating: "", count: subLevelLines - rightTextBlock.count) 39 | 40 | // compute location of value or link bar for all left and right sub nodes 41 | // * left node's value ends at line's width 42 | // * right node's value starts after initial spaces 43 | let leftLineWidths = leftSubLines.map { $0.count } 44 | let rightLineIndents = rightSubLines.map { $0.prefix { $0 == " " }.count } 45 | 46 | // top line value locations, will be used to determine position of current node & link bars 47 | let firstLeftWidth = leftLineWidths.first ?? 0 48 | let firstRightIndent = rightLineIndents.first ?? 0 49 | 50 | // width of sub node link under node value (i.e. with slashes if any) 51 | // aims to center link bars under the value if value is wide enough 52 | // 53 | // ValueLine: v vv vvvvvv vvvvv 54 | // LinkLine: / \ / \ / \ / \ 55 | // 56 | let linkSpacing = min(stringValueWidth, 2 - stringValueWidth % 2) 57 | let leftLinkBar = leftNode == nil ? 0 : 1 58 | let rightLinkBar = rightNode == nil ? 0 : 1 59 | let minLinkWidth = leftLinkBar + linkSpacing + rightLinkBar 60 | let valueOffset = (stringValueWidth - linkSpacing) / 2 61 | 62 | // find optimal position for right side top node 63 | // * must allow room for link bars above and between left and right top nodes 64 | // * must not overlap lower level nodes on any given line (allow gap of minSpacing) 65 | // * can be offset to the left if lower subNodes of right node 66 | // have no overlap with subNodes of left node 67 | let minSpacing = 2 68 | let rightNodePosition = zip(leftLineWidths, rightLineIndents[0 ..< commonLines]) 69 | .reduce(firstLeftWidth + minLinkWidth) { max($0, $1.0 + minSpacing + firstRightIndent - $1.1) } 70 | 71 | // extend basic link bars (slashes) with underlines to reach left and right 72 | // top nodes. 73 | // 74 | // vvvvv 75 | // __/ \__ 76 | // L R 77 | // 78 | let linkExtraWidth = max(0, rightNodePosition - firstLeftWidth - minLinkWidth) 79 | let rightLinkExtra = linkExtraWidth / 2 80 | let leftLinkExtra = linkExtraWidth - rightLinkExtra 81 | 82 | // build value line taking into account left indent and link bar extension (on left side) 83 | let valueIndent = max(0, firstLeftWidth + leftLinkExtra + leftLinkBar - valueOffset) 84 | let valueLine = String(repeating: " ", count: max(0, valueIndent)) 85 | + stringValue 86 | 87 | // build left side of link line 88 | let leftLink = leftNode == nil ? "" 89 | : String(repeating: " ", count: firstLeftWidth) 90 | + String(repeating: "_", count: leftLinkExtra) 91 | + "/" 92 | 93 | // build right side of link line (includes blank spaces under top node value) 94 | let rightLinkOffset = linkSpacing + valueOffset * (1 - leftLinkBar) 95 | let rightLink = rightNode == nil ? "" 96 | : String(repeating: " ", count: rightLinkOffset) 97 | + "\\" 98 | + String(repeating: "_", count: rightLinkExtra) 99 | 100 | // full link line (will be empty if there are no sub nodes) 101 | let linkLine = leftLink + rightLink 102 | 103 | // will need to offset left side lines if right side sub nodes extend beyond left margin 104 | // can happen if left subtree is shorter (in height) than right side subtree 105 | let leftIndentWidth = max(0, firstRightIndent - rightNodePosition) 106 | let leftIndent = String(repeating: " ", count: leftIndentWidth) 107 | let indentedLeftLines = leftSubLines.map { $0.isEmpty ? $0 : (leftIndent + $0) } 108 | 109 | // compute distance between left and right sublines based on their value position 110 | // can be negative if leading spaces need to be removed from right side 111 | let mergeOffsets = indentedLeftLines 112 | .map { $0.count } 113 | .map { leftIndentWidth + rightNodePosition - firstRightIndent - $0 } 114 | .enumerated() 115 | .map { rightSubLines[$0].isEmpty ? 0 : $1 } 116 | 117 | // combine left and right lines using computed offsets 118 | // * indented left sub lines 119 | // * spaces between left and right lines 120 | // * right sub line with extra leading blanks removed. 121 | let mergedSubLines = zip(mergeOffsets.enumerated(), indentedLeftLines) 122 | .map { ($0.0, $0.1, $1 + String(repeating: " ", count: max(0, $0.1))) } 123 | .map { $2 + String(rightSubLines[$0].dropFirst(max(0, -$1))) } 124 | 125 | // Assemble final result combining 126 | // * node value string 127 | // * link line (if any) 128 | // * merged lines from left and right sub trees (if any) 129 | let result = [leftIndent + valueLine] 130 | + (linkLine.isEmpty ? [] : [leftIndent + linkLine]) 131 | + mergedSubLines 132 | 133 | return result.joined(separator: "\n") 134 | } 135 | 136 | func displayAmount(quantity: ValueProtocol, units: String) -> String { 137 | switch quantity { 138 | case let value as Decimal: 139 | // TODO locale 140 | // when summing up quantities converted to Decimal in IngredientsTable 141 | // here we want to check if value can be converted back to integer before representation 142 | if let v = Int("\(value)") { 143 | if units == "" { 144 | return "\(value)" 145 | } else { 146 | return "\(value) \(RuntimeSupport.pluralizer.pluralize(string: units, count: v))" 147 | } 148 | } else { 149 | if units == "" { 150 | return "\(value.cleanValue)" 151 | } else { 152 | return "\(value.cleanValue) \(RuntimeSupport.pluralizer.pluralize(string: units, count: 2))" 153 | } 154 | } 155 | case is String: 156 | if units == "" { 157 | return "\(quantity)" 158 | } else { 159 | return "\(quantity) \(RuntimeSupport.pluralizer.pluralize(string: units, count: 2))" 160 | } 161 | 162 | case let value as Int: 163 | if units == "" { 164 | return "\(value)" 165 | } else { 166 | return "\(value) \(RuntimeSupport.pluralizer.pluralize(string: units, count: value))" 167 | } 168 | 169 | default: 170 | return "" 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /Sources/CookInSwift/Lexer/Lexer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Lexer.swift 3 | // SwiftCookInSwift 4 | // 5 | // Created by Alexey Dubovskoy on 07/12/2020. 6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum NumberParseStrategy { 12 | case integer 13 | case preSlashFractional 14 | case postSlashFractional 15 | case fractional 16 | case decimal 17 | case string 18 | } 19 | 20 | /** 21 | Basic lexical analyzer converting program text into tokens 22 | */ 23 | class Lexer { 24 | 25 | // MARK: - Fields 26 | private let onlyLetters = CharacterSet.letters.subtracting(CharacterSet.nonBaseCharacters) 27 | private let text: [Unicode.Scalar] 28 | private let count: Int 29 | private var currentPosition: Int 30 | private var currentCharacter: Unicode.Scalar? 31 | 32 | // MARK: - Constants 33 | 34 | init(_ text: String) { 35 | self.text = Array(text.unicodeScalars) 36 | self.count = self.text.count 37 | currentPosition = 0 38 | currentCharacter = text.isEmpty ? nil : self.text[0] 39 | } 40 | 41 | // MARK: - Stream helpers 42 | 43 | /** 44 | Skips all the whitespace 45 | */ 46 | private func skipWhitestace() { 47 | while let character = currentCharacter, CharacterSet.whitespaces.contains(character) { 48 | advance() 49 | } 50 | } 51 | 52 | /** 53 | Skips all the newlines and whitespaces 54 | */ 55 | private func skipNewlines() { 56 | while let character = currentCharacter, CharacterSet.newlines.contains(character) { 57 | advance() 58 | } 59 | } 60 | 61 | 62 | /** 63 | Skips comments 64 | */ 65 | private func skipComment() { 66 | while let character = currentCharacter, !CharacterSet.newlines.contains(character) { 67 | advance() 68 | } 69 | } 70 | 71 | /** 72 | Skips block comments 73 | */ 74 | private func skipBlockComment() { 75 | var prevCharacter = Unicode.Scalar(""); 76 | 77 | while let character = currentCharacter { 78 | if prevCharacter == "-" && character == "]" { 79 | advance() 80 | 81 | return 82 | } 83 | 84 | prevCharacter = character 85 | advance() 86 | } 87 | } 88 | 89 | /** 90 | Advances by one character forward, sets the current character (if still any available) 91 | */ 92 | private func advance() { 93 | currentPosition += 1 94 | 95 | guard currentPosition < count else { 96 | currentCharacter = nil 97 | return 98 | } 99 | 100 | currentCharacter = text[currentPosition] 101 | } 102 | 103 | /** 104 | Returns the next character without advancing 105 | 106 | Returns: Character if not at the end of the text, nil otherwise 107 | */ 108 | private func peek() -> Unicode.Scalar? { 109 | let peekPosition = currentPosition + 1 110 | 111 | guard peekPosition < count else { 112 | return nil 113 | } 114 | 115 | return text[peekPosition] 116 | } 117 | 118 | // MARK: - Parsing helpers 119 | 120 | /** 121 | Reads a possible multidigit integer starting at the current position 122 | */ 123 | private func number() -> Token { 124 | var lexem = "" 125 | 126 | var strategy: NumberParseStrategy = .integer 127 | 128 | var i = currentPosition + 1 129 | 130 | // need to look ahead to define if we can use numbers 131 | strategyLookAhead: while i < count { 132 | let character = text[i] 133 | 134 | switch strategy { 135 | case .integer: 136 | if CharacterSet.decimalDigits.contains(character) { 137 | break 138 | } else if character == "." { 139 | strategy = .decimal 140 | break 141 | } else if character == "/" { 142 | strategy = .postSlashFractional 143 | break 144 | } else if CharacterSet.whitespaces.contains(character) { 145 | strategy = .preSlashFractional 146 | break 147 | } else if CharacterSet.newlines.contains(character) || CharacterSet.punctuationCharacters.contains(character) || CharacterSet.symbols.contains(character) { 148 | break strategyLookAhead 149 | } else { 150 | strategy = .string 151 | break strategyLookAhead 152 | } 153 | case .decimal: 154 | if CharacterSet.decimalDigits.contains(character) { 155 | break 156 | } else if CharacterSet.newlines.contains(character) || CharacterSet.whitespaces.contains(character) || CharacterSet.punctuationCharacters.contains(character) || CharacterSet.symbols.contains(character) { 157 | break strategyLookAhead 158 | } else { 159 | strategy = .string 160 | break strategyLookAhead 161 | } 162 | case .preSlashFractional: 163 | if CharacterSet.whitespaces.contains(character) { 164 | break 165 | } else if character == "/" { 166 | strategy = .postSlashFractional 167 | break 168 | } else { 169 | strategy = .integer 170 | break strategyLookAhead 171 | } 172 | 173 | case .postSlashFractional: 174 | if CharacterSet.decimalDigits.contains(character) { 175 | strategy = .fractional 176 | break 177 | } else if CharacterSet.whitespaces.contains(character) { 178 | break 179 | } else { 180 | strategy = .string 181 | break strategyLookAhead 182 | } 183 | 184 | case .fractional: 185 | if CharacterSet.decimalDigits.contains(character) { 186 | break 187 | } else if CharacterSet.newlines.contains(character) || CharacterSet.whitespaces.contains(character) || CharacterSet.punctuationCharacters.contains(character) || CharacterSet.symbols.contains(character) { 188 | break strategyLookAhead 189 | } else { 190 | strategy = .string 191 | break strategyLookAhead 192 | } 193 | default: 194 | fatalError("Lexer bug, unspecified case for number parsing") 195 | } 196 | 197 | i += 1 198 | } 199 | 200 | switch strategy { 201 | case .decimal: 202 | let nextCharacter = peek() 203 | 204 | if let character = currentCharacter, character == "0" && nextCharacter! != "." { 205 | return word() 206 | } 207 | 208 | while let character = currentCharacter, CharacterSet.decimalDigits.contains(character) { 209 | lexem += String(character) 210 | advance() 211 | } 212 | 213 | if let character = currentCharacter, character == "." { 214 | lexem += "." 215 | advance() 216 | } 217 | 218 | while let character = currentCharacter, CharacterSet.decimalDigits.contains(character) { 219 | lexem += String(character) 220 | advance() 221 | } 222 | 223 | return .constant(.decimal(Decimal(floatLiteral: Double(lexem)!))) 224 | case .integer: 225 | if let character = currentCharacter, character == "0" { 226 | return word() 227 | } 228 | 229 | while let character = currentCharacter, CharacterSet.decimalDigits.contains(character) { 230 | lexem += String(character) 231 | advance() 232 | } 233 | 234 | return .constant(.integer(Int(lexem)!)) 235 | case .fractional: 236 | if let character = currentCharacter, character == "0" { 237 | return word() 238 | } 239 | 240 | var nominator = "" 241 | var denominator = "" 242 | while let character = currentCharacter, CharacterSet.decimalDigits.contains(character) { 243 | nominator += String(character) 244 | advance() 245 | } 246 | 247 | while let character = currentCharacter, CharacterSet.whitespaces.contains(character) { 248 | advance() 249 | } 250 | 251 | if let character = currentCharacter, character == "/" { 252 | advance() 253 | } 254 | 255 | while let character = currentCharacter, CharacterSet.whitespaces.contains(character) { 256 | advance() 257 | } 258 | 259 | while let character = currentCharacter, CharacterSet.decimalDigits.contains(character) { 260 | denominator += String(character) 261 | advance() 262 | } 263 | 264 | return .constant(.fractional((Int(nominator)!, Int(denominator)!))) 265 | case .string: 266 | return word() 267 | default: 268 | fatalError("Oops, something went wrong") 269 | } 270 | } 271 | 272 | private func word() -> Token { 273 | var lexem = "" 274 | while let character = currentCharacter, CharacterSet.alphanumerics.contains(character) { 275 | lexem += String(character) 276 | advance() 277 | } 278 | return .constant(.string(lexem)) 279 | } 280 | 281 | private func punctuation() -> Token { 282 | var lexem = "" 283 | while let character = currentCharacter, !["{", "}", "@", "%", ":", ">", "|"].contains(character) && (CharacterSet.punctuationCharacters.contains(character) || CharacterSet.symbols.contains(character)) { 284 | lexem += String(character) 285 | advance() 286 | } 287 | return .constant(.string(lexem)) 288 | } 289 | 290 | private func whitespace() -> Token { 291 | while let character = currentCharacter, CharacterSet.whitespaces.contains(character) { 292 | advance() 293 | } 294 | return .constant(.space) 295 | } 296 | 297 | // MARK: - Public methods 298 | 299 | /** 300 | Reads the text at current position and returns next token 301 | 302 | - Returns: Next token in text 303 | */ 304 | func getNextToken() -> Token { 305 | while let currentCharacter = currentCharacter { 306 | if CharacterSet.newlines.contains(currentCharacter) { 307 | skipNewlines() 308 | return .eol 309 | } 310 | 311 | if CharacterSet.whitespaces.contains(currentCharacter) { 312 | return whitespace() 313 | } 314 | 315 | // if the character is a digit, convert it to int, create an integer token and move position 316 | if CharacterSet.decimalDigits.contains(currentCharacter) { 317 | return number() 318 | } 319 | 320 | if currentCharacter == "[" { 321 | let nextCharacter = peek() 322 | 323 | advance() 324 | 325 | if let unwrapped = nextCharacter { 326 | if unwrapped == "-" { 327 | advance() 328 | skipBlockComment() 329 | continue 330 | } else { 331 | return .constant(.string("[")) 332 | } 333 | } else { 334 | return .constant(.string("[")) 335 | } 336 | } 337 | 338 | if currentCharacter == "@" { 339 | advance() 340 | return .at 341 | } 342 | 343 | if currentCharacter == "%" { 344 | advance() 345 | return .percent 346 | } 347 | 348 | if currentCharacter == "{" { 349 | advance() 350 | 351 | return .braces(.left) 352 | } 353 | 354 | if currentCharacter == "}" { 355 | advance() 356 | return .braces(.right) 357 | } 358 | 359 | if currentCharacter == ":" { 360 | advance() 361 | return .colon 362 | } 363 | 364 | if currentCharacter == ">" { 365 | advance() 366 | return .chevron 367 | } 368 | 369 | if currentCharacter == "|" { 370 | advance() 371 | return .pipe 372 | } 373 | 374 | if currentCharacter == "#" { 375 | advance() 376 | return .hash 377 | } 378 | 379 | if currentCharacter == "~" { 380 | advance() 381 | return .tilde 382 | } 383 | 384 | if currentCharacter == "-" { 385 | let nextCharacter = peek() 386 | 387 | advance() 388 | 389 | if let unwrapped = nextCharacter { 390 | if unwrapped == "-" { 391 | advance() 392 | skipComment() 393 | continue 394 | } else { 395 | return .constant(.string("-")) 396 | } 397 | } else { 398 | return .constant(.string("-")) 399 | } 400 | } 401 | 402 | if CharacterSet.punctuationCharacters.contains(currentCharacter) || CharacterSet.symbols.contains(currentCharacter) { 403 | return punctuation() 404 | } 405 | 406 | if CharacterSet.alphanumerics.contains(currentCharacter) { 407 | return word() 408 | } 409 | } 410 | 411 | return .eof 412 | } 413 | 414 | func lex() -> [Token] { 415 | var token = getNextToken() 416 | var all = [token] 417 | while token != .eof { 418 | token = getNextToken() 419 | all.append(token) 420 | } 421 | 422 | return all 423 | } 424 | } 425 | -------------------------------------------------------------------------------- /Sources/CookInSwift/Parser/Parser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Parser.swift 3 | // SwiftCookInSwift 4 | // 5 | // Created by Alexey Dubovskoy on 07/12/2020. 6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved. 7 | // 8 | 9 | // GRAMMAR 10 | // recipe: metadata step; 11 | // metadata: key value; 12 | // 13 | 14 | import Foundation 15 | 16 | enum TaggedParseStrategy { 17 | case noBraces 18 | case withBraces 19 | } 20 | 21 | enum QuantityParseStrategy { 22 | case number 23 | case string 24 | } 25 | 26 | enum ParserError: Error { 27 | case terminatorNotFound 28 | } 29 | 30 | class Parser { 31 | 32 | // MARK: - Fields 33 | 34 | private var tokenIndex = 0 35 | private let tokens: [Token] 36 | 37 | private var currentToken: Token { 38 | return tokens[tokenIndex] 39 | } 40 | 41 | private var nextToken: Token { 42 | return tokens[tokenIndex + 1] 43 | } 44 | 45 | init(_ tokens: [Token]) { 46 | self.tokens = tokens 47 | } 48 | 49 | static func parse(_ text: String) throws -> AST { 50 | let lexer = Lexer(text) 51 | let tokens = lexer.lex() 52 | let parser = Parser(tokens) 53 | return parser.parse() 54 | } 55 | 56 | // MARK: - Helpers 57 | 58 | /** 59 | Compares the current token with the given token, if they match, the next token is read, 60 | otherwise an error is thrown 61 | 62 | - Parameter token: Expected token 63 | */ 64 | private func eat(_ token: Token) { 65 | if currentToken == token { 66 | tokenIndex += 1 67 | } else { 68 | fatalError("Syntax error, expected \(token), got \(currentToken)") 69 | } 70 | } 71 | 72 | // MARK: - Grammar rules 73 | 74 | /** 75 | 76 | */ 77 | private func stringUntilTerminator(terminators: [Token]) throws -> String { 78 | var parts: [String] = [] 79 | 80 | while true { 81 | if terminators.contains(currentToken) { 82 | break 83 | // TODO should really be configurable, because in some cases we consider next @ to be a stopper 84 | } else if currentToken == .eol || currentToken == .eof { 85 | throw ParserError.terminatorNotFound 86 | } else { 87 | parts.append(currentToken.literal) 88 | eat(currentToken) 89 | } 90 | } 91 | 92 | return parts.joined().trimmingCharacters(in: CharacterSet.whitespaces) 93 | } 94 | 95 | /** 96 | 97 | */ 98 | private func direction() -> DirectionNode { 99 | var items: [String] = [] 100 | 101 | while true { 102 | switch currentToken { 103 | case .eof, .eol: 104 | return DirectionNode(items.joined()) 105 | case let .constant(.string(value)): 106 | eat(.constant(.string(value))) 107 | items.append(value) 108 | case let .constant(.integer(value)): 109 | eat(.constant(.integer(value))) 110 | items.append(String(value)) 111 | case let .constant(.decimal(value)): 112 | eat(.constant(.decimal(value))) 113 | items.append("\(value)") 114 | case let .constant(.fractional((nom, denom))): 115 | eat(.constant(.fractional((nom, denom)))) 116 | items.append("\(nom)/\(denom)") 117 | case .tilde: 118 | if case .constant(.string) = nextToken { 119 | return DirectionNode(items.joined()) 120 | } else if nextToken == .braces(.left) { 121 | return DirectionNode(items.joined()) 122 | } else { 123 | items.append(currentToken.literal) 124 | eat(currentToken) 125 | } 126 | case .at, .hash: 127 | if case .constant(.string) = nextToken { 128 | return DirectionNode(items.joined()) 129 | } else if case .constant(.integer) = nextToken { 130 | return DirectionNode(items.joined()) 131 | } else { 132 | items.append(currentToken.literal) 133 | eat(currentToken) 134 | } 135 | default: 136 | items.append(currentToken.literal) 137 | eat(currentToken) 138 | } 139 | } 140 | } 141 | 142 | private func ignoreWhitespace() { 143 | while currentToken == .constant(.space) { 144 | eat(.constant(.space)) 145 | } 146 | } 147 | 148 | /** 149 | 150 | */ 151 | private func values(defaultValue: ValueNode) -> ValueNode { 152 | var v = defaultValue 153 | 154 | var strategy: QuantityParseStrategy = .string 155 | var i = tokenIndex 156 | 157 | // need to look ahead to define if we can use numbers 158 | strategyLookAhead: while i < tokens.count { 159 | switch tokens[i] { 160 | case .percent, .braces(.right): 161 | break strategyLookAhead 162 | case .constant(.decimal): 163 | strategy = .number 164 | case .constant(.integer): 165 | strategy = .number 166 | case .constant(.fractional): 167 | strategy = .number 168 | case .constant(.space): 169 | break 170 | case let .constant(.string(value)): 171 | // TODO not 100% right as this is valid only for fractional 172 | if CharacterSet.whitespaces.contains(value.unicodeScalars.first!) { 173 | break 174 | } else { 175 | strategy = .string 176 | break strategyLookAhead 177 | } 178 | default: 179 | strategy = .string 180 | break strategyLookAhead 181 | } 182 | 183 | i += 1 184 | } 185 | 186 | if strategy == .number { 187 | ignoreWhitespace() 188 | 189 | switch currentToken { 190 | 191 | case let .constant(.decimal(value)): 192 | v = ValueNode.decimal(value) 193 | eat(.constant(.decimal(value))) 194 | 195 | case let .constant(.integer(value)): 196 | eat(.constant(.integer(value))) 197 | v = ValueNode.integer(value) 198 | 199 | case let .constant(.fractional((n, d))): 200 | eat(.constant(.fractional((n, d)))) 201 | v = ValueNode.decimal(Decimal(n) / Decimal(d)) 202 | 203 | default: 204 | if !(currentToken == .braces(.right) || currentToken == .percent) { 205 | fatalError("Number or word is expected, got \(currentToken)") 206 | } 207 | } 208 | 209 | } else { 210 | var value = "" 211 | 212 | do { 213 | value = try stringUntilTerminator(terminators: [.percent, .braces(.right)]) 214 | } catch ParserError.terminatorNotFound { 215 | print("woops") 216 | } catch { 217 | fatalError("Unexpected exception") 218 | } 219 | 220 | if value != "" { 221 | v = ValueNode.string(value) 222 | } 223 | } 224 | 225 | ignoreWhitespace() 226 | 227 | return v 228 | } 229 | 230 | /** 231 | 232 | */ 233 | private func amount() throws -> AmountNode { 234 | eat(.braces(.left)) 235 | 236 | var q = values(defaultValue: ValueNode.string("")) 237 | 238 | var units = "" 239 | 240 | if currentToken == .percent { 241 | eat(.percent) 242 | 243 | units = try stringUntilTerminator(terminators: [.braces(.right)]) 244 | } 245 | 246 | eat(.braces(.right)) 247 | 248 | if q.value == "" { 249 | if units.isEmpty { 250 | q = ValueNode.string("some") 251 | } 252 | } 253 | 254 | return AmountNode(quantity: q, units: units) 255 | } 256 | 257 | 258 | 259 | /** 260 | 261 | */ 262 | private func taggedName() -> String { 263 | var i = tokenIndex 264 | var strategy: TaggedParseStrategy? 265 | 266 | // need to look ahead to define if we need to wait for braces or not 267 | while i < tokens.count { 268 | if tokens[i] == .braces(.left) { 269 | strategy = .withBraces 270 | break 271 | } 272 | 273 | if tokens[i] == .eof || tokens[i] == .eol || tokens[i] == .at || tokens[i] == .hash || tokens[i] == .tilde { 274 | strategy = .noBraces 275 | break 276 | } 277 | 278 | i += 1 279 | } 280 | 281 | switch strategy { 282 | case .withBraces: 283 | var value = "" 284 | 285 | do { 286 | value = try stringUntilTerminator(terminators: [.braces(.left)]) 287 | } catch ParserError.terminatorNotFound { 288 | print("woops") 289 | } catch { 290 | fatalError("Unexpected exception") 291 | } 292 | 293 | return value 294 | case .noBraces: 295 | // TODO not stable place 296 | guard case let .constant(.string(value)) = currentToken else { 297 | fatalError("String expected, got \(currentToken)") 298 | } 299 | 300 | eat(.constant(.string(value))) 301 | 302 | return value 303 | default: 304 | fatalError("Can't understand strategy") 305 | } 306 | } 307 | 308 | /** 309 | 310 | */ 311 | private func ingredient() -> IngredientNode { 312 | eat(.at) 313 | 314 | let name = taggedName() 315 | var ingridientAmount = AmountNode(quantity: "some", units: "") 316 | 317 | if currentToken == .braces(.left) { 318 | do { 319 | ingridientAmount = try amount() 320 | } catch ParserError.terminatorNotFound { 321 | print("Warning: expected '}' but got end of line") 322 | } catch { 323 | fatalError("Unexpected exception") 324 | } 325 | } 326 | 327 | return IngredientNode(name: name, amount: ingridientAmount) 328 | } 329 | 330 | /** 331 | 332 | */ 333 | private func equipment() -> EquipmentNode { 334 | eat(.hash) 335 | 336 | let name = taggedName() 337 | 338 | if currentToken == .braces(.left) { 339 | eat(.braces(.left)) 340 | ignoreWhitespace() 341 | 342 | if currentToken == .braces(.right) { 343 | eat(.braces(.right)) 344 | } else { 345 | print("Warning: expected '}' but got \(currentToken.literal)") 346 | } 347 | } 348 | 349 | return EquipmentNode(name: name) 350 | } 351 | 352 | /** 353 | 354 | */ 355 | private func timer() -> TimerNode { 356 | eat(.tilde) 357 | let name = taggedName() 358 | eat(.braces(.left)) 359 | let quantity = values(defaultValue: ValueNode.integer(0)) 360 | var units = "" 361 | if currentToken == .percent { 362 | eat(.percent) 363 | 364 | do { 365 | units = try stringUntilTerminator(terminators: [.braces(.right)]) 366 | } catch ParserError.terminatorNotFound { 367 | print("Warning: expected '}' but got end of line") 368 | return TimerNode(quantity: "", units: "", name: "Invalid timer syntax") 369 | } catch { 370 | fatalError("Unexpected exception") 371 | } 372 | } 373 | 374 | eat(.braces(.right)) 375 | 376 | return TimerNode(quantity: quantity, units: units, name: name) 377 | } 378 | 379 | /** 380 | >> key: value 381 | 382 | */ 383 | private func metadata() -> MetadataNode { 384 | eat(.chevron) 385 | eat(.chevron) 386 | 387 | var key = "" 388 | 389 | do { 390 | key = try stringUntilTerminator(terminators: [.colon]) 391 | } catch ParserError.terminatorNotFound { 392 | return MetadataNode("Invalid key syntax", "Invalid value syntax") 393 | } catch { 394 | fatalError("Unexpected exception") 395 | } 396 | 397 | eat(.colon) 398 | 399 | var value = "" 400 | 401 | do { 402 | value = try stringUntilTerminator(terminators: [.eol, .eof]) 403 | } catch ParserError.terminatorNotFound { 404 | // TODO this is redundant 405 | } catch { 406 | fatalError("Unexpected exception") 407 | } 408 | 409 | if currentToken == .eol { 410 | eat(.eol) 411 | } 412 | 413 | return MetadataNode(key, value) 414 | } 415 | 416 | /** 417 | 418 | */ 419 | private func step() -> StepNode { 420 | var instructions: [AST] = [] 421 | 422 | while true { 423 | switch currentToken { 424 | case .eol: 425 | eat(.eol) 426 | 427 | if !instructions.isEmpty { 428 | return StepNode(instructions: instructions) 429 | } 430 | case .eof: 431 | return StepNode(instructions: instructions) 432 | case .at: 433 | switch nextToken { 434 | case .constant(.string), .constant(.integer): 435 | instructions.append(ingredient()) 436 | default: 437 | instructions.append(direction()) 438 | } 439 | case .hash: 440 | switch nextToken { 441 | case .constant(.string), .constant(.integer): 442 | instructions.append(equipment()) 443 | default: 444 | instructions.append(direction()) 445 | } 446 | case .tilde: 447 | switch nextToken { 448 | case .constant(.string), .braces(.left): 449 | instructions.append(timer()) 450 | default: 451 | instructions.append(direction()) 452 | } 453 | default: 454 | instructions.append(direction()) 455 | } 456 | } 457 | } 458 | 459 | /** 460 | 461 | */ 462 | private func recipe() -> RecipeNode { 463 | var steps: [StepNode] = [] 464 | var meta: [MetadataNode] = [] 465 | 466 | while currentToken != .eof { 467 | if currentToken == .chevron && nextToken == .chevron { 468 | meta.append(metadata()) 469 | } else { 470 | steps.append(step()) 471 | } 472 | } 473 | 474 | let node = RecipeNode(steps: steps, metadata: meta) 475 | 476 | return node 477 | } 478 | 479 | // MARK: - Public methods. 480 | 481 | /** 482 | 483 | */ 484 | func parse() -> AST { 485 | let node = recipe() 486 | 487 | if currentToken != .eof { 488 | fatalError("Syntax error, end of file expected") 489 | } 490 | 491 | return node 492 | } 493 | } 494 | 495 | 496 | -------------------------------------------------------------------------------- /Tests/CookInSwiftTests/SemanticAnalyzerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SymbolTableTests.swift 3 | // SwiftCookInSwiftTests 4 | // 5 | // Created by Alexey Dubovskoy on 10/12/2020. 6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | @testable import CookInSwift 11 | import XCTest 12 | 13 | class SemanticAnalyzerTests: XCTestCase { 14 | func testSemanticAnalyzer() { 15 | let program = 16 | """ 17 | >> cooking time: 30 min 18 | Add @chilli{3}, @ginger{10%g} and @milk{1%litre} place in #oven and cook for ~{10%minutes} 19 | """ 20 | 21 | let parsedRecipe = try! Recipe.from(text: program) 22 | 23 | var recipe = Recipe() 24 | recipe.metadata["cooking time"] = "30 min" 25 | 26 | var step = Step() 27 | step.directions = [ 28 | TextItem("Add "), 29 | Ingredient("chilli", IngredientAmount(3, "items")), 30 | TextItem(", "), 31 | Ingredient("ginger", IngredientAmount(10, "g")), 32 | TextItem(" and "), 33 | Ingredient("milk", IngredientAmount(1, "litre")), 34 | TextItem(" place in "), 35 | Equipment("oven"), 36 | TextItem(" and cook for "), 37 | Timer(10, "minutes") 38 | ] 39 | recipe.steps = [step] 40 | 41 | XCTAssertEqual(parsedRecipe, recipe) 42 | 43 | let text = parsedRecipe.steps.map{ step in 44 | step.directions.map { $0.description }.joined() 45 | } 46 | XCTAssertEqual(text, ["Add chilli, ginger and milk place in oven and cook for 10 minutes"]) 47 | } 48 | 49 | 50 | func testShoppingListCombination() { 51 | let recipe1 = 52 | """ 53 | Add @chilli{3}, @ginger{10%g} and @milk{1%litre} place in #oven and cook for ~{10%minutes}. Add a bit of @cinnamon. 54 | """ 55 | 56 | let recipe2 = 57 | """ 58 | Simmer @milk{250%ml} with @honey{2%tbsp} for ~{20%minutes}. Add a bit of @cinnamon. 59 | """ 60 | 61 | let parsedRecipe1 = try! Recipe.from(text: recipe1) 62 | let parsedRecipe2 = try! Recipe.from(text: recipe2) 63 | 64 | var table = IngredientTable() 65 | 66 | table = mergeIngredientTables(parsedRecipe1.ingredientsTable, parsedRecipe2.ingredientsTable) 67 | 68 | XCTAssertEqual(table.description, "chilli: 3; cinnamon: some; ginger: 10 g; honey: 2 tbsp; milk: 1 litre, 250 ml") 69 | } 70 | 71 | // test valid ingridient: when only units, but no name of in 72 | 73 | func testBurrito() { 74 | 75 | let recipe = 76 | """ 77 | Preheat your oven to the lowest setting. Drain the @cannellini beans{2%tins} in a sieve. Place a saucepan on a medium heat. 78 | 79 | Peel and dinely slice the @garlic clove{2}. add the @olive oil{1%tbsp} and sliced garlic to the hot pan. 80 | 81 | Crubmle the @red chilli{1%item} into the pan, then stir and fry until the grlic turns golden. 82 | 83 | Add the @tinned tomatoes{2%tins} and drained cannellini beans to the pan, reduce to a low heat and simmer gently for around 20 minutes, or until reduced and nice and thick. Meanwhile... 84 | 85 | Peel, halve and finely chop the @red onion{1}. Roughly chop the @cherry tomatoes{10}. Finely chop the @coriander{1%bunch} stalks and roughly chop the leaves. 86 | 87 | Coarsely grate the @cheddar cheese{75%g}. Cut @lime{1} in half and the other @lime{1} into six wedges. 88 | 89 | Cut the @avocados{2} in half lengthways, use a spppon to sccoop out and dicard the stone, then scoop the fles into a bowl to make your guacamole. 90 | 91 | Roughly mash the avocado with the back of a fork, then add the onion, cherry tomatoes, coriander stalks and @ground cumin{1%pinch}. Season with @sea salt{} and @black pepper{} and squeeze in the juice from one of the lime halves. 92 | 93 | Mix well then have a taste of your guacoamole and tweak with more salt, pepper and lime jouice until you've got a good balance of flovours and its tasing delicious. Set aside. 94 | 95 | Loosely wrap the @tortillas{6%large} in tin foil then pop in the hot oven to warm through, along with two plates. Finely chop the @fresh red chilli{2} and put it aside for later. 96 | 97 | Make your table look respectable - get the cutlery, salt and pepper and drinks laid out nicely. 98 | 99 | By now your beans should be done, so have a taste and season with salt and pepper. Turn the heat off and pop a lid on th pan sothey stay nice and warm. 100 | 101 | Put a small non-stick saucepan on a low heat. Add the @butter{30%g} and leave to melt. Meanwhile... 102 | 103 | Crack the @eggs{8%large} into a bowl, add a pinch of @salt{} and @black pepper{} and beat with a fork. When the buter has melted, add the eggs to the pan. Stir the eggs slowly with a spatula, getting right into the sides of the pan. Cook gently for 5 to 10 minutes until they just start to scramble then turn the heat off - they'll continute to cook on their own. 104 | 105 | Get two plates and pop a warm tortilla on each one. Divide the scrambled eggs between them then top with a good spoonful of you home-made beans. 106 | 107 | Scatter each portion with grated cheese and as much chilli as youdare, then roll each tortilla up. 108 | 109 | Spoon guacamole and @sour cream{200%ml} on top of each one, scatter with coriander leaves and dust with a little @smoked paprika{1%pinch}. Serve each portion with wedge of lime for squeezing over, and tuck in. 110 | 111 | """ 112 | 113 | 114 | 115 | measure { 116 | let parsedRecipe = try! Recipe.from(text: recipe) 117 | 118 | XCTAssertEqual(parsedRecipe.ingredientsTable.description, "avocados: 2; black pepper: some; butter: 30 g; cannellini beans: 2 tins; cheddar cheese: 75 g; cherry tomatoes: 10; coriander: 1 bunch; eggs: 8 large; fresh red chilli: 2; garlic clove: 2; ground cumin: 1 pinch; lime: 2; olive oil: 1 tbsp; red chilli: 1 item; red onion: 1; salt: some; sea salt: some; smoked paprika: 1 pinch; sour cream: 200 ml; tinned tomatoes: 2 tins; tortillas: 6 large") 119 | } 120 | 121 | } 122 | 123 | func testLagman() { 124 | let recipe = 125 | """ 126 | >> Prep Time: 15 minutes 127 | >> Cook Time: 30 minutes 128 | >> Total Time: 45 minutes 129 | >> Servings: 6 servings 130 | 131 | Cook the @linguine pasta{230%g} according to the instructions. Drain and rinse with cold water. Keep covered until ready to use so it does not dry out. 132 | 133 | Cube the @lamb{450%g} into small cubes. In a dutch oven, heat @oil. Once hot add meat, cook about ~{5%minutes}. 134 | 135 | Meanwhile, finely chop the @onion{1%medium}. Finely dice @tomatoes{2%large}. Cube @carrots{1%large} and @red peppers{1/2%large} into even sizes. Cube @potatoes{420%g} into small cubes. 136 | 137 | Add onions to the meat in the Dutch oven. Turn heat down to medium heat, cook until onions are tender. 138 | 139 | Add tomatoes and @garlic{1%glove}, cook ~{2%minutes}, stirring as needed. 140 | 141 | Add potatoes, carrots, peppers and mix well. 142 | 143 | Pour in @water{1.4%kg}, @black pepper{1%tsp}, @ground coriander{1%tsp}, @ground cumin{1%tsp} and @bay leaves{2}, @star anise{1%small} and cook about ~{20%minutes}, until all of the vegetables are tender. Remove star anise once the soup is cooked. 144 | 145 | Meanwhile, cook pasta. Drain and return to pot, cover to prevent from drying out. 146 | 147 | Serve hearty soup over pasta. 148 | 149 | """ 150 | 151 | let parsedRecipe = try! Recipe.from(text: recipe) 152 | 153 | XCTAssertEqual(parsedRecipe.ingredientsTable.description, "bay leaves: 2; black pepper: 1 tsp; carrots: 1 large; garlic: 1 glove; ground coriander: 1 tsp; ground cumin: 1 tsp; lamb: 450 g; linguine pasta: 230 g; oil: some; onion: 1 medium; potatoes: 420 g; red peppers: 0.5 large; star anise: 1 small; tomatoes: 2 large; water: 1.4 kg") 154 | 155 | } 156 | 157 | func testManti() { 158 | let recipe = 159 | """ 160 | Attach the dough hook to the Kitchen Aid mixer. Mix the @milk{160%g}, @salt{1%tsp}, @egg{1%large} and @water{180%g}. 161 | 162 | Add the @flour{530%g}, continue mixing until the dough is smooth and flour is well incorporated. (Dough should not stick to your hands.) 163 | 164 | Keep the dough covered until ready to use. 165 | 166 | Place @butter{110%g} into freezer. Cube @chicken thighs{450%g} into small pieces. Chop @onion{1%small} really fine. Peel @potatoes{450%g} and cube into small pieces, place in bowl and cover with cold water, set aside. 167 | 168 | Divide dough into two. Return the one half to the bowl and cover. Lightly flour the working surface. Roll out the dough into about 20" to 22" circle, adding flour as needed, it needs to be really thin. Fold the circle as if it's and accordion going back and forth. With a sharp knife cut about every 2 1/2 inches. Take the strips and stack them. When stacking, the strips will all be different lengths, stack them all starting at one end, not in the middle. Cut again about every 2 2/12 inches. 169 | 170 | Lay out the squares. Some pieces may not be complete squares, edges, just take two pieces and stick them together. 171 | 172 | Finish the filling. Take out the butter from the freezer and either grate or cube. Drain potatoes add to the bowl, add @ground coriander{1/2%tsp}, @ground cumin{1/4%tsp}, @black pepper{1/4%tsp}, minced @garlic{2%cloves} add chopped @parsley{1%bunch}. Mix everything. 173 | 174 | Take spoonfuls of the filling and add to the center of the squares. 175 | 176 | Take a square, place one corner over the other and pinch together. Take the other corner and repeat. Pinch together the four openings. Now take the two edges and pinch them together as well, placing over edge over the other. 177 | 178 | Add water to a tiered steamer. Lay out Manti on tiers. Cover steamer and cook 20-25 minutes. All steamers are different, check a Manti to see if it's ready. If using different meat, it'll need about 40 - 60 minutes. 179 | 180 | Enjoy with butter and sour cream. 181 | """ 182 | 183 | let parsedRecipe = try! Recipe.from(text: recipe) 184 | 185 | XCTAssertEqual(parsedRecipe.ingredientsTable.description, "black pepper: 0.25 tsp; butter: 110 g; chicken thighs: 450 g; egg: 1 large; flour: 530 g; garlic: 2 cloves; ground coriander: 0.5 tsp; ground cumin: 0.25 tsp; milk: 160 g; onion: 1 small; parsley: 1 bunch; potatoes: 450 g; salt: 1 tsp; water: 180 g") 186 | 187 | } 188 | 189 | func testBrokenManti() { 190 | let recipe = 191 | """ 192 | > sdfsf: fff 193 | >> fsdfd::: 194 | >>:fdsfd 195 | >>sfsdfd: 196 | > > fsdf 197 | Attach the dough @ hook > to the Kitchen Aid mixer. Mix the @milk{160g}, @ salt{1%tsp}, @egg{%large} and @water{180%}. 198 | 199 | Add the @flour{530%g, continue mixing until the dough is smooth and flour is well incorporated. (Dough should not stick to your hands.) 200 | 201 | Keep the dough --covered until ready to use. 202 | 203 | Place @butter{%} into freezer. Cube @chicken > thighs{450%g} into small pieces. Chop @onion{1 really fine. Peel @potatoes{450%g} and cube into small pieces, place in bowl and cover with cold water, set aside. 204 | 205 | Divide dough into two. Return # the one half | to the bowl : and cover >. Lightly flour the working surface. Roll out the dough into about 20" to 22" circle, adding flour as needed, it needs to be really thin. Fold the circle as if it's and accordion going back and forth. With a sharp knife cut about every 2 1/2 inches. Take the strips and stack them. When stacking, the strips will all be different lengths, stack them all starting at one end, not in the middle. Cut again about every 2 2/12 #inches{. 206 | 207 | Lay out the squares. Some pieces may not be complete squares, edges, }just take{ two pieces and stick them together. 208 | 209 | Finish the filling. Take out the butter from the freezer and either % grate or cube. Drain potatoes add to the bowl, add @ground coriander{1/2%tsp}, @ground cumin{1/4%tsp}, @black pepper{1/4%tsp}, minced @garlic{2%cloves} add chopped @parsley{1%bunch}. Mix everything. 210 | 211 | >> Take spoonfuls of the filling and add to the ~center of the squares. 212 | 213 | Hehe, ~ and # and @ here. 214 | 215 | @ 216 | 217 | ~ 218 | 219 | # 220 | 221 | Take a square, place one corner over the other ~and{%} pinch together. Take the other corner and repeat. Pinch together the four openings. Now take the two edges and pinch them together as well, placing over edge over the other. 222 | 223 | Add water to a tiered steamer. Lay out Manti on tiers. Cover steamer and cook ~{20-25%minutes}. All steamers are different, check a Manti to see if it's ready. If using different meat, it'll need about 40 - 60 minutes. 224 | 225 | Enjoy with butter and sour cream. 226 | """ 227 | 228 | let parsedRecipe = try! Recipe.from(text: recipe) 229 | 230 | let text = parsedRecipe.steps.map{ step in 231 | step.directions.map { $0.description }.joined() 232 | } 233 | 234 | XCTAssertEqual(parsedRecipe.metadata, ["sfsdfd": "", 235 | "Invalid key syntax": "Invalid value syntax", 236 | "": "fdsfd", 237 | "fsdfd": "::"]) 238 | 239 | XCTAssertEqual(text, ["> sdfsf: fff", 240 | "> > fsdf", 241 | "Attach the dough @ hook > to the Kitchen Aid mixer. Mix the milk, @ salt{1%tsp}, egg and water.", 242 | "Add the flour", 243 | "Keep the dough ", 244 | "Place butter into freezer. Cube chicken > thighs into small pieces. Chop onion and cube into small pieces, place in bowl and cover with cold water, set aside.", 245 | "Divide dough into two. Return # the one half | to the bowl : and cover >. Lightly flour the working surface. Roll out the dough into about 20\" to 22\" circle, adding flour as needed, it needs to be really thin. Fold the circle as if it\'s and accordion going back and forth. With a sharp knife cut about every 2 1/2 inches. Take the strips and stack them. When stacking, the strips will all be different lengths, stack them all starting at one end, not in the middle. Cut again about every 2 2/12 inches.", 246 | "Lay out the squares. Some pieces may not be complete squares, edges, }just take{ two pieces and stick them together.", 247 | "Finish the filling. Take out the butter from the freezer and either % grate or cube. Drain potatoes add to the bowl, add ground coriander, ground cumin, black pepper, minced garlic add chopped parsley. Mix everything.", 248 | "Hehe, ~ and # and @ here.", 249 | "@", 250 | "~", 251 | "#", 252 | "Take a square, place one corner over the other 0 pinch together. Take the other corner and repeat. Pinch together the four openings. Now take the two edges and pinch them together as well, placing over edge over the other.", 253 | "Add water to a tiered steamer. Lay out Manti on tiers. Cover steamer and cook 20-25 minutes. All steamers are different, check a Manti to see if it\'s ready. If using different meat, it\'ll need about 40 - 60 minutes.", 254 | "Enjoy with butter and sour cream."] 255 | 256 | ) 257 | 258 | } 259 | 260 | 261 | 262 | } 263 | -------------------------------------------------------------------------------- /.devcontainer/library-scripts/common-debian.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | #------------------------------------------------------------------------------------------------------------- 3 | # Copyright (c) Microsoft Corporation. All rights reserved. 4 | # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. 5 | #------------------------------------------------------------------------------------------------------------- 6 | # 7 | # Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/common.md 8 | # Maintainer: The VS Code and Codespaces Teams 9 | # 10 | # Syntax: ./common-debian.sh [install zsh flag] [username] [user UID] [user GID] [upgrade packages flag] [install Oh My Zsh! flag] [Add non-free packages] 11 | 12 | set -e 13 | 14 | INSTALL_ZSH=${1:-"true"} 15 | USERNAME=${2:-"automatic"} 16 | USER_UID=${3:-"automatic"} 17 | USER_GID=${4:-"automatic"} 18 | UPGRADE_PACKAGES=${5:-"true"} 19 | INSTALL_OH_MYS=${6:-"true"} 20 | ADD_NON_FREE_PACKAGES=${7:-"false"} 21 | SCRIPT_DIR="$(cd $(dirname "${BASH_SOURCE[0]}") && pwd)" 22 | MARKER_FILE="/usr/local/etc/vscode-dev-containers/common" 23 | 24 | 25 | if [ "$(id -u)" -ne 0 ]; then 26 | echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' 27 | exit 1 28 | fi 29 | 30 | # Ensure that login shells get the correct path if the user updated the PATH using ENV. 31 | rm -f /etc/profile.d/00-restore-env.sh 32 | echo "export PATH=${PATH//$(sh -lc 'echo $PATH')/\$PATH}" > /etc/profile.d/00-restore-env.sh 33 | chmod +x /etc/profile.d/00-restore-env.sh 34 | 35 | # If in automatic mode, determine if a user already exists, if not use vscode 36 | if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then 37 | USERNAME="" 38 | POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") 39 | for CURRENT_USER in ${POSSIBLE_USERS[@]}; do 40 | if id -u ${CURRENT_USER} > /dev/null 2>&1; then 41 | USERNAME=${CURRENT_USER} 42 | break 43 | fi 44 | done 45 | if [ "${USERNAME}" = "" ]; then 46 | USERNAME=vscode 47 | fi 48 | elif [ "${USERNAME}" = "none" ]; then 49 | USERNAME=root 50 | USER_UID=0 51 | USER_GID=0 52 | fi 53 | 54 | # Load markers to see which steps have already run 55 | if [ -f "${MARKER_FILE}" ]; then 56 | echo "Marker file found:" 57 | cat "${MARKER_FILE}" 58 | source "${MARKER_FILE}" 59 | fi 60 | 61 | # Ensure apt is in non-interactive to avoid prompts 62 | export DEBIAN_FRONTEND=noninteractive 63 | 64 | # Function to call apt-get if needed 65 | apt_get_update_if_needed() 66 | { 67 | if [ ! -d "/var/lib/apt/lists" ] || [ "$(ls /var/lib/apt/lists/ | wc -l)" = "0" ]; then 68 | echo "Running apt-get update..." 69 | apt-get update 70 | else 71 | echo "Skipping apt-get update." 72 | fi 73 | } 74 | 75 | # Run install apt-utils to avoid debconf warning then verify presence of other common developer tools and dependencies 76 | if [ "${PACKAGES_ALREADY_INSTALLED}" != "true" ]; then 77 | 78 | package_list="apt-utils \ 79 | openssh-client \ 80 | gnupg2 \ 81 | dirmngr \ 82 | iproute2 \ 83 | procps \ 84 | lsof \ 85 | htop \ 86 | net-tools \ 87 | psmisc \ 88 | curl \ 89 | wget \ 90 | rsync \ 91 | ca-certificates \ 92 | unzip \ 93 | zip \ 94 | nano \ 95 | vim-tiny \ 96 | less \ 97 | jq \ 98 | lsb-release \ 99 | apt-transport-https \ 100 | dialog \ 101 | libc6 \ 102 | libgcc1 \ 103 | libkrb5-3 \ 104 | libgssapi-krb5-2 \ 105 | libicu[0-9][0-9] \ 106 | liblttng-ust0 \ 107 | libstdc++6 \ 108 | zlib1g \ 109 | locales \ 110 | sudo \ 111 | ncdu \ 112 | man-db \ 113 | strace \ 114 | manpages \ 115 | manpages-dev \ 116 | init-system-helpers" 117 | 118 | # Needed for adding manpages-posix and manpages-posix-dev which are non-free packages in Debian 119 | if [ "${ADD_NON_FREE_PACKAGES}" = "true" ]; then 120 | # Bring in variables from /etc/os-release like VERSION_CODENAME 121 | . /etc/os-release 122 | sed -i -E "s/deb http:\/\/(deb|httpredir)\.debian\.org\/debian ${VERSION_CODENAME} main/deb http:\/\/\1\.debian\.org\/debian ${VERSION_CODENAME} main contrib non-free/" /etc/apt/sources.list 123 | sed -i -E "s/deb-src http:\/\/(deb|httredir)\.debian\.org\/debian ${VERSION_CODENAME} main/deb http:\/\/\1\.debian\.org\/debian ${VERSION_CODENAME} main contrib non-free/" /etc/apt/sources.list 124 | sed -i -E "s/deb http:\/\/(deb|httpredir)\.debian\.org\/debian ${VERSION_CODENAME}-updates main/deb http:\/\/\1\.debian\.org\/debian ${VERSION_CODENAME}-updates main contrib non-free/" /etc/apt/sources.list 125 | sed -i -E "s/deb-src http:\/\/(deb|httpredir)\.debian\.org\/debian ${VERSION_CODENAME}-updates main/deb http:\/\/\1\.debian\.org\/debian ${VERSION_CODENAME}-updates main contrib non-free/" /etc/apt/sources.list 126 | sed -i "s/deb http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}\/updates main/deb http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}\/updates main contrib non-free/" /etc/apt/sources.list 127 | sed -i "s/deb-src http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}\/updates main/deb http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}\/updates main contrib non-free/" /etc/apt/sources.list 128 | sed -i "s/deb http:\/\/deb\.debian\.org\/debian ${VERSION_CODENAME}-backports main/deb http:\/\/deb\.debian\.org\/debian ${VERSION_CODENAME}-backports main contrib non-free/" /etc/apt/sources.list 129 | sed -i "s/deb-src http:\/\/deb\.debian\.org\/debian ${VERSION_CODENAME}-backports main/deb http:\/\/deb\.debian\.org\/debian ${VERSION_CODENAME}-backports main contrib non-free/" /etc/apt/sources.list 130 | # Handle bullseye location for security https://www.debian.org/releases/bullseye/amd64/release-notes/ch-information.en.html 131 | sed -i "s/deb http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}-security main/deb http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}-security main contrib non-free/" /etc/apt/sources.list 132 | sed -i "s/deb-src http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}-security main/deb http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}-security main contrib non-free/" /etc/apt/sources.list 133 | echo "Running apt-get update..." 134 | apt-get update 135 | package_list="${package_list} manpages-posix manpages-posix-dev" 136 | else 137 | apt_get_update_if_needed 138 | fi 139 | 140 | # Install libssl1.1 if available 141 | if [[ ! -z $(apt-cache --names-only search ^libssl1.1$) ]]; then 142 | package_list="${package_list} libssl1.1" 143 | fi 144 | 145 | # Install appropriate version of libssl1.0.x if available 146 | libssl_package=$(dpkg-query -f '${db:Status-Abbrev}\t${binary:Package}\n' -W 'libssl1\.0\.?' 2>&1 || echo '') 147 | if [ "$(echo "$LIlibssl_packageBSSL" | grep -o 'libssl1\.0\.[0-9]:' | uniq | sort | wc -l)" -eq 0 ]; then 148 | if [[ ! -z $(apt-cache --names-only search ^libssl1.0.2$) ]]; then 149 | # Debian 9 150 | package_list="${package_list} libssl1.0.2" 151 | elif [[ ! -z $(apt-cache --names-only search ^libssl1.0.0$) ]]; then 152 | # Ubuntu 18.04, 16.04, earlier 153 | package_list="${package_list} libssl1.0.0" 154 | fi 155 | fi 156 | 157 | echo "Packages to verify are installed: ${package_list}" 158 | apt-get -y install --no-install-recommends ${package_list} 2> >( grep -v 'debconf: delaying package configuration, since apt-utils is not installed' >&2 ) 159 | 160 | # Install git if not already installed (may be more recent than distro version) 161 | if ! type git > /dev/null 2>&1; then 162 | apt-get -y install --no-install-recommends git 163 | fi 164 | 165 | PACKAGES_ALREADY_INSTALLED="true" 166 | fi 167 | 168 | # Get to latest versions of all packages 169 | if [ "${UPGRADE_PACKAGES}" = "true" ]; then 170 | apt_get_update_if_needed 171 | apt-get -y upgrade --no-install-recommends 172 | apt-get autoremove -y 173 | fi 174 | 175 | # Ensure at least the en_US.UTF-8 UTF-8 locale is available. 176 | # Common need for both applications and things like the agnoster ZSH theme. 177 | if [ "${LOCALE_ALREADY_SET}" != "true" ] && ! grep -o -E '^\s*en_US.UTF-8\s+UTF-8' /etc/locale.gen > /dev/null; then 178 | echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen 179 | locale-gen 180 | LOCALE_ALREADY_SET="true" 181 | fi 182 | 183 | # Create or update a non-root user to match UID/GID. 184 | if id -u ${USERNAME} > /dev/null 2>&1; then 185 | # User exists, update if needed 186 | if [ "${USER_GID}" != "automatic" ] && [ "$USER_GID" != "$(id -G $USERNAME)" ]; then 187 | groupmod --gid $USER_GID $USERNAME 188 | usermod --gid $USER_GID $USERNAME 189 | fi 190 | if [ "${USER_UID}" != "automatic" ] && [ "$USER_UID" != "$(id -u $USERNAME)" ]; then 191 | usermod --uid $USER_UID $USERNAME 192 | fi 193 | else 194 | # Create user 195 | if [ "${USER_GID}" = "automatic" ]; then 196 | groupadd $USERNAME 197 | else 198 | groupadd --gid $USER_GID $USERNAME 199 | fi 200 | if [ "${USER_UID}" = "automatic" ]; then 201 | useradd -s /bin/bash --gid $USERNAME -m $USERNAME 202 | else 203 | useradd -s /bin/bash --uid $USER_UID --gid $USERNAME -m $USERNAME 204 | fi 205 | fi 206 | 207 | # Add add sudo support for non-root user 208 | if [ "${USERNAME}" != "root" ] && [ "${EXISTING_NON_ROOT_USER}" != "${USERNAME}" ]; then 209 | echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME 210 | chmod 0440 /etc/sudoers.d/$USERNAME 211 | EXISTING_NON_ROOT_USER="${USERNAME}" 212 | fi 213 | 214 | # ** Shell customization section ** 215 | if [ "${USERNAME}" = "root" ]; then 216 | user_rc_path="/root" 217 | else 218 | user_rc_path="/home/${USERNAME}" 219 | fi 220 | 221 | # Restore user .bashrc defaults from skeleton file if it doesn't exist or is empty 222 | if [ ! -f "${user_rc_path}/.bashrc" ] || [ ! -s "${user_rc_path}/.bashrc" ] ; then 223 | cp /etc/skel/.bashrc "${user_rc_path}/.bashrc" 224 | fi 225 | 226 | # Restore user .profile defaults from skeleton file if it doesn't exist or is empty 227 | if [ ! -f "${user_rc_path}/.profile" ] || [ ! -s "${user_rc_path}/.profile" ] ; then 228 | cp /etc/skel/.profile "${user_rc_path}/.profile" 229 | fi 230 | 231 | # .bashrc/.zshrc snippet 232 | rc_snippet="$(cat << 'EOF' 233 | 234 | if [ -z "${USER}" ]; then export USER=$(whoami); fi 235 | if [[ "${PATH}" != *"$HOME/.local/bin"* ]]; then export PATH="${PATH}:$HOME/.local/bin"; fi 236 | 237 | # Display optional first run image specific notice if configured and terminal is interactive 238 | if [ -t 1 ] && [[ "${TERM_PROGRAM}" = "vscode" || "${TERM_PROGRAM}" = "codespaces" ]] && [ ! -f "$HOME/.config/vscode-dev-containers/first-run-notice-already-displayed" ]; then 239 | if [ -f "/usr/local/etc/vscode-dev-containers/first-run-notice.txt" ]; then 240 | cat "/usr/local/etc/vscode-dev-containers/first-run-notice.txt" 241 | elif [ -f "/workspaces/.codespaces/shared/first-run-notice.txt" ]; then 242 | cat "/workspaces/.codespaces/shared/first-run-notice.txt" 243 | fi 244 | mkdir -p "$HOME/.config/vscode-dev-containers" 245 | # Mark first run notice as displayed after 10s to avoid problems with fast terminal refreshes hiding it 246 | ((sleep 10s; touch "$HOME/.config/vscode-dev-containers/first-run-notice-already-displayed") &) 247 | fi 248 | 249 | # Set the default git editor if not already set 250 | if [ -z "$(git config --get core.editor)" ] && [ -z "${GIT_EDITOR}" ]; then 251 | if [ "${TERM_PROGRAM}" = "vscode" ]; then 252 | if [[ -n $(command -v code-insiders) && -z $(command -v code) ]]; then 253 | export GIT_EDITOR="code-insiders --wait" 254 | else 255 | export GIT_EDITOR="code --wait" 256 | fi 257 | fi 258 | fi 259 | 260 | EOF 261 | )" 262 | 263 | # code shim, it fallbacks to code-insiders if code is not available 264 | cat << 'EOF' > /usr/local/bin/code 265 | #!/bin/sh 266 | 267 | get_in_path_except_current() { 268 | which -a "$1" | grep -A1 "$0" | grep -v "$0" 269 | } 270 | 271 | code="$(get_in_path_except_current code)" 272 | 273 | if [ -n "$code" ]; then 274 | exec "$code" "$@" 275 | elif [ "$(command -v code-insiders)" ]; then 276 | exec code-insiders "$@" 277 | else 278 | echo "code or code-insiders is not installed" >&2 279 | exit 127 280 | fi 281 | EOF 282 | chmod +x /usr/local/bin/code 283 | 284 | # systemctl shim - tells people to use 'service' if systemd is not running 285 | cat << 'EOF' > /usr/local/bin/systemctl 286 | #!/bin/sh 287 | set -e 288 | if [ -d "/run/systemd/system" ]; then 289 | exec /bin/systemctl/systemctl "$@" 290 | else 291 | echo '\n"systemd" is not running in this container due to its overhead.\nUse the "service" command to start services intead. e.g.: \n\nservice --status-all' 292 | fi 293 | EOF 294 | chmod +x /usr/local/bin/systemctl 295 | 296 | # Codespaces bash and OMZ themes - partly inspired by https://github.com/ohmyzsh/ohmyzsh/blob/master/themes/robbyrussell.zsh-theme 297 | codespaces_bash="$(cat \ 298 | <<'EOF' 299 | 300 | # Codespaces bash prompt theme 301 | __bash_prompt() { 302 | local userpart='`export XIT=$? \ 303 | && [ ! -z "${GITHUB_USER}" ] && echo -n "\[\033[0;32m\]@${GITHUB_USER} " || echo -n "\[\033[0;32m\]\u " \ 304 | && [ "$XIT" -ne "0" ] && echo -n "\[\033[1;31m\]➜" || echo -n "\[\033[0m\]➜"`' 305 | local gitbranch='`\ 306 | export BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --short HEAD 2>/dev/null); \ 307 | if [ "${BRANCH}" != "" ]; then \ 308 | echo -n "\[\033[0;36m\](\[\033[1;31m\]${BRANCH}" \ 309 | && if git ls-files --error-unmatch -m --directory --no-empty-directory -o --exclude-standard ":/*" > /dev/null 2>&1; then \ 310 | echo -n " \[\033[1;33m\]✗"; \ 311 | fi \ 312 | && echo -n "\[\033[0;36m\]) "; \ 313 | fi`' 314 | local lightblue='\[\033[1;34m\]' 315 | local removecolor='\[\033[0m\]' 316 | PS1="${userpart} ${lightblue}\w ${gitbranch}${removecolor}\$ " 317 | unset -f __bash_prompt 318 | } 319 | __bash_prompt 320 | 321 | EOF 322 | )" 323 | 324 | codespaces_zsh="$(cat \ 325 | <<'EOF' 326 | # Codespaces zsh prompt theme 327 | __zsh_prompt() { 328 | local prompt_username 329 | if [ ! -z "${GITHUB_USER}" ]; then 330 | prompt_username="@${GITHUB_USER}" 331 | else 332 | prompt_username="%n" 333 | fi 334 | PROMPT="%{$fg[green]%}${prompt_username} %(?:%{$reset_color%}➜ :%{$fg_bold[red]%}➜ )" # User/exit code arrow 335 | PROMPT+='%{$fg_bold[blue]%}%(5~|%-1~/…/%3~|%4~)%{$reset_color%} ' # cwd 336 | PROMPT+='$(git_prompt_info)%{$fg[white]%}$ %{$reset_color%}' # Git status 337 | unset -f __zsh_prompt 338 | } 339 | ZSH_THEME_GIT_PROMPT_PREFIX="%{$fg_bold[cyan]%}(%{$fg_bold[red]%}" 340 | ZSH_THEME_GIT_PROMPT_SUFFIX="%{$reset_color%} " 341 | ZSH_THEME_GIT_PROMPT_DIRTY=" %{$fg_bold[yellow]%}✗%{$fg_bold[cyan]%})" 342 | ZSH_THEME_GIT_PROMPT_CLEAN="%{$fg_bold[cyan]%})" 343 | __zsh_prompt 344 | 345 | EOF 346 | )" 347 | 348 | # Add notice that Oh My Bash! has been removed from images and how to provide information on how to install manually 349 | omb_readme="$(cat \ 350 | <<'EOF' 351 | "Oh My Bash!" has been removed from this image in favor of a simple shell prompt. If you 352 | still wish to use it, remove "~/.oh-my-bash" and install it from: https://github.com/ohmybash/oh-my-bash 353 | You may also want to consider "Bash-it" as an alternative: https://github.com/bash-it/bash-it 354 | See here for infomation on adding it to your image or dotfiles: https://aka.ms/codespaces/omb-remove 355 | EOF 356 | )" 357 | omb_stub="$(cat \ 358 | <<'EOF' 359 | #!/usr/bin/env bash 360 | if [ -t 1 ]; then 361 | cat $HOME/.oh-my-bash/README.md 362 | fi 363 | EOF 364 | )" 365 | 366 | # Add RC snippet and custom bash prompt 367 | if [ "${RC_SNIPPET_ALREADY_ADDED}" != "true" ]; then 368 | echo "${rc_snippet}" >> /etc/bash.bashrc 369 | echo "${codespaces_bash}" >> "${user_rc_path}/.bashrc" 370 | echo 'export PROMPT_DIRTRIM=4' >> "${user_rc_path}/.bashrc" 371 | if [ "${USERNAME}" != "root" ]; then 372 | echo "${codespaces_bash}" >> "/root/.bashrc" 373 | echo 'export PROMPT_DIRTRIM=4' >> "/root/.bashrc" 374 | fi 375 | chown ${USERNAME}:${USERNAME} "${user_rc_path}/.bashrc" 376 | RC_SNIPPET_ALREADY_ADDED="true" 377 | fi 378 | 379 | # Add stub for Oh My Bash! 380 | if [ ! -d "${user_rc_path}/.oh-my-bash}" ] && [ "${INSTALL_OH_MYS}" = "true" ]; then 381 | mkdir -p "${user_rc_path}/.oh-my-bash" "/root/.oh-my-bash" 382 | echo "${omb_readme}" >> "${user_rc_path}/.oh-my-bash/README.md" 383 | echo "${omb_stub}" >> "${user_rc_path}/.oh-my-bash/oh-my-bash.sh" 384 | chmod +x "${user_rc_path}/.oh-my-bash/oh-my-bash.sh" 385 | if [ "${USERNAME}" != "root" ]; then 386 | echo "${omb_readme}" >> "/root/.oh-my-bash/README.md" 387 | echo "${omb_stub}" >> "/root/.oh-my-bash/oh-my-bash.sh" 388 | chmod +x "/root/.oh-my-bash/oh-my-bash.sh" 389 | fi 390 | chown -R "${USERNAME}:${USERNAME}" "${user_rc_path}/.oh-my-bash" 391 | fi 392 | 393 | # Optionally install and configure zsh and Oh My Zsh! 394 | if [ "${INSTALL_ZSH}" = "true" ]; then 395 | if ! type zsh > /dev/null 2>&1; then 396 | apt_get_update_if_needed 397 | apt-get install -y zsh 398 | fi 399 | if [ "${ZSH_ALREADY_INSTALLED}" != "true" ]; then 400 | echo "${rc_snippet}" >> /etc/zsh/zshrc 401 | ZSH_ALREADY_INSTALLED="true" 402 | fi 403 | 404 | # Adapted, simplified inline Oh My Zsh! install steps that adds, defaults to a codespaces theme. 405 | # See https://github.com/ohmyzsh/ohmyzsh/blob/master/tools/install.sh for official script. 406 | oh_my_install_dir="${user_rc_path}/.oh-my-zsh" 407 | if [ ! -d "${oh_my_install_dir}" ] && [ "${INSTALL_OH_MYS}" = "true" ]; then 408 | template_path="${oh_my_install_dir}/templates/zshrc.zsh-template" 409 | user_rc_file="${user_rc_path}/.zshrc" 410 | umask g-w,o-w 411 | mkdir -p ${oh_my_install_dir} 412 | git clone --depth=1 \ 413 | -c core.eol=lf \ 414 | -c core.autocrlf=false \ 415 | -c fsck.zeroPaddedFilemode=ignore \ 416 | -c fetch.fsck.zeroPaddedFilemode=ignore \ 417 | -c receive.fsck.zeroPaddedFilemode=ignore \ 418 | "https://github.com/ohmyzsh/ohmyzsh" "${oh_my_install_dir}" 2>&1 419 | echo -e "$(cat "${template_path}")\nDISABLE_AUTO_UPDATE=true\nDISABLE_UPDATE_PROMPT=true" > ${user_rc_file} 420 | sed -i -e 's/ZSH_THEME=.*/ZSH_THEME="codespaces"/g' ${user_rc_file} 421 | 422 | mkdir -p ${oh_my_install_dir}/custom/themes 423 | echo "${codespaces_zsh}" > "${oh_my_install_dir}/custom/themes/codespaces.zsh-theme" 424 | # Shrink git while still enabling updates 425 | cd "${oh_my_install_dir}" 426 | git repack -a -d -f --depth=1 --window=1 427 | # Copy to non-root user if one is specified 428 | if [ "${USERNAME}" != "root" ]; then 429 | cp -rf "${user_rc_file}" "${oh_my_install_dir}" /root 430 | chown -R ${USERNAME}:${USERNAME} "${user_rc_path}" 431 | fi 432 | fi 433 | fi 434 | 435 | # Persist image metadata info, script if meta.env found in same directory 436 | meta_info_script="$(cat << 'EOF' 437 | #!/bin/sh 438 | . /usr/local/etc/vscode-dev-containers/meta.env 439 | 440 | # Minimal output 441 | if [ "$1" = "version" ] || [ "$1" = "image-version" ]; then 442 | echo "${VERSION}" 443 | exit 0 444 | elif [ "$1" = "release" ]; then 445 | echo "${GIT_REPOSITORY_RELEASE}" 446 | exit 0 447 | elif [ "$1" = "content" ] || [ "$1" = "content-url" ] || [ "$1" = "contents" ] || [ "$1" = "contents-url" ]; then 448 | echo "${CONTENTS_URL}" 449 | exit 0 450 | fi 451 | 452 | #Full output 453 | echo 454 | echo "Development container image information" 455 | echo 456 | if [ ! -z "${VERSION}" ]; then echo "- Image version: ${VERSION}"; fi 457 | if [ ! -z "${DEFINITION_ID}" ]; then echo "- Definition ID: ${DEFINITION_ID}"; fi 458 | if [ ! -z "${VARIANT}" ]; then echo "- Variant: ${VARIANT}"; fi 459 | if [ ! -z "${GIT_REPOSITORY}" ]; then echo "- Source code repository: ${GIT_REPOSITORY}"; fi 460 | if [ ! -z "${GIT_REPOSITORY_RELEASE}" ]; then echo "- Source code release/branch: ${GIT_REPOSITORY_RELEASE}"; fi 461 | if [ ! -z "${BUILD_TIMESTAMP}" ]; then echo "- Timestamp: ${BUILD_TIMESTAMP}"; fi 462 | if [ ! -z "${CONTENTS_URL}" ]; then echo && echo "More info: ${CONTENTS_URL}"; fi 463 | echo 464 | EOF 465 | )" 466 | if [ -f "${SCRIPT_DIR}/meta.env" ]; then 467 | mkdir -p /usr/local/etc/vscode-dev-containers/ 468 | cp -f "${SCRIPT_DIR}/meta.env" /usr/local/etc/vscode-dev-containers/meta.env 469 | echo "${meta_info_script}" > /usr/local/bin/devcontainer-info 470 | chmod +x /usr/local/bin/devcontainer-info 471 | fi 472 | 473 | # Write marker file 474 | mkdir -p "$(dirname "${MARKER_FILE}")" 475 | echo -e "\ 476 | PACKAGES_ALREADY_INSTALLED=${PACKAGES_ALREADY_INSTALLED}\n\ 477 | LOCALE_ALREADY_SET=${LOCALE_ALREADY_SET}\n\ 478 | EXISTING_NON_ROOT_USER=${EXISTING_NON_ROOT_USER}\n\ 479 | RC_SNIPPET_ALREADY_ADDED=${RC_SNIPPET_ALREADY_ADDED}\n\ 480 | ZSH_ALREADY_INSTALLED=${ZSH_ALREADY_INSTALLED}" > "${MARKER_FILE}" 481 | 482 | echo "Done!" 483 | -------------------------------------------------------------------------------- /Tests/CookInSwiftTests/LexerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TokenizerTests.swift 3 | // SwiftCookInSwiftTests 4 | // 5 | // Created by Alexey Dubovskoy on 06/12/2020. 6 | // Copyright © 2020 Alexey Dubovskoy. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | @testable import CookInSwift 11 | import XCTest 12 | 13 | class LexerTests: XCTestCase { 14 | 15 | func testEmptyInput() { 16 | let lexer = Lexer("") 17 | XCTAssertEqual(lexer.getNextToken(), .eof) 18 | } 19 | 20 | func testWhitespaceOnlyInput() { 21 | let lexer = Lexer(" ") 22 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 23 | XCTAssertEqual(lexer.getNextToken(), .eof) 24 | } 25 | 26 | func testNewLineInput() { 27 | let input = " \n " 28 | let lexer = Lexer(input) 29 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 30 | XCTAssertEqual(lexer.getNextToken(), .eol) 31 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 32 | XCTAssertEqual(lexer.getNextToken(), .eof) 33 | } 34 | 35 | func testWindowsNewLineInput() { 36 | let input = "abc\r\ndef\r\nghi" 37 | let lexer = Lexer(input) 38 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("abc"))) 39 | XCTAssertEqual(lexer.getNextToken(), .eol) 40 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("def"))) 41 | XCTAssertEqual(lexer.getNextToken(), .eol) 42 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("ghi"))) 43 | XCTAssertEqual(lexer.getNextToken(), .eof) 44 | } 45 | 46 | func testMultipleNewLinesInput() { 47 | let input = " \n\n " 48 | let lexer = Lexer(input) 49 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 50 | XCTAssertEqual(lexer.getNextToken(), .eol) 51 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 52 | XCTAssertEqual(lexer.getNextToken(), .eof) 53 | } 54 | 55 | func testBasicString() { 56 | let input = "abc" 57 | let lexer = Lexer(input) 58 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("abc"))) 59 | XCTAssertEqual(lexer.getNextToken(), .eof) 60 | } 61 | 62 | func testEmoji() { 63 | let input = "🍚" 64 | let lexer = Lexer(input) 65 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("🍚"))) 66 | XCTAssertEqual(lexer.getNextToken(), .eof) 67 | } 68 | 69 | func testConcessiveStrings() { 70 | let input = "abc xyz" 71 | let lexer = Lexer(input) 72 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("abc"))) 73 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 74 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("xyz"))) 75 | XCTAssertEqual(lexer.getNextToken(), .eof) 76 | } 77 | 78 | func testStringWithNumbers() { 79 | let input = "abc 70770 xyz" 80 | let lexer = Lexer(input) 81 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("abc"))) 82 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 83 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(70770))) 84 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 85 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("xyz"))) 86 | XCTAssertEqual(lexer.getNextToken(), .eof) 87 | } 88 | 89 | func testStringWithDecimalNumbers() { 90 | let input = "abc 0.70770 xyz" 91 | let lexer = Lexer(input) 92 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("abc"))) 93 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 94 | XCTAssertEqual(lexer.getNextToken(), .constant(.decimal(0.70770))) 95 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 96 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("xyz"))) 97 | XCTAssertEqual(lexer.getNextToken(), .eof) 98 | } 99 | 100 | func testStringWithDecimalLikeNumbers() { 101 | let input = "abc 01.70770 xyz" 102 | let lexer = Lexer(input) 103 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("abc"))) 104 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 105 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("01"))) 106 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("."))) 107 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(70770))) 108 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 109 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("xyz"))) 110 | XCTAssertEqual(lexer.getNextToken(), .eof) 111 | } 112 | 113 | func testDecimalNumbers() { 114 | let input = "0.70770" 115 | let lexer = Lexer(input) 116 | XCTAssertEqual(lexer.getNextToken(), .constant(.decimal(0.70770))) 117 | XCTAssertEqual(lexer.getNextToken(), .eof) 118 | } 119 | 120 | func testIntegerNumbers() { 121 | let input = "70770" 122 | let lexer = Lexer(input) 123 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(70770))) 124 | XCTAssertEqual(lexer.getNextToken(), .eof) 125 | } 126 | 127 | func testIntegerLikeNumbers() { 128 | let input = "70770hehe" 129 | let lexer = Lexer(input) 130 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("70770hehe"))) 131 | XCTAssertEqual(lexer.getNextToken(), .eof) 132 | } 133 | 134 | 135 | func testFractionalNumbers() { 136 | let input = "1/2" 137 | let lexer = Lexer(input) 138 | XCTAssertEqual(lexer.getNextToken(), .constant(.fractional((1, 2)))) 139 | XCTAssertEqual(lexer.getNextToken(), .eof) 140 | } 141 | 142 | func testFractionalNumbersWithSpaces1() { 143 | let input = "1 / 2" 144 | let lexer = Lexer(input) 145 | XCTAssertEqual(lexer.getNextToken(), .constant(.fractional((1, 2)))) 146 | XCTAssertEqual(lexer.getNextToken(), .eof) 147 | } 148 | 149 | func testFractionalNumbersWithSpaces2() { 150 | let input = " 1 / 2" 151 | let lexer = Lexer(input) 152 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 153 | XCTAssertEqual(lexer.getNextToken(), .constant(.fractional((1, 2)))) 154 | XCTAssertEqual(lexer.getNextToken(), .eof) 155 | } 156 | 157 | func testFractionalNumbersWithSpacesAfter() { 158 | let input = " 1 / 2 " 159 | let lexer = Lexer(input) 160 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 161 | XCTAssertEqual(lexer.getNextToken(), .constant(.fractional((1, 2)))) 162 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 163 | XCTAssertEqual(lexer.getNextToken(), .eof) 164 | } 165 | 166 | func testAlmostFractionalNumbers() { 167 | let input = "01/2" 168 | let lexer = Lexer(input) 169 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("01"))) 170 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("/"))) 171 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(2))) 172 | XCTAssertEqual(lexer.getNextToken(), .eof) 173 | } 174 | 175 | func testStringWithLeadingZeroNumbers() { 176 | let input = "abc 0777 xyz" 177 | let lexer = Lexer(input) 178 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("abc"))) 179 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 180 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("0777"))) 181 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 182 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("xyz"))) 183 | XCTAssertEqual(lexer.getNextToken(), .eof) 184 | } 185 | 186 | func testStringWithLikeNumbers() { 187 | let input = "abc 7peppers xyz" 188 | let lexer = Lexer(input) 189 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("abc"))) 190 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 191 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("7peppers"))) 192 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 193 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("xyz"))) 194 | XCTAssertEqual(lexer.getNextToken(), .eof) 195 | } 196 | 197 | func testStringWithPunctuation() { 198 | let input = "abc – xyz: lol," 199 | let lexer = Lexer(input) 200 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("abc"))) 201 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 202 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("–"))) 203 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 204 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("xyz"))) 205 | XCTAssertEqual(lexer.getNextToken(), .colon) 206 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 207 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("lol"))) 208 | XCTAssertEqual(lexer.getNextToken(), .constant(.string(","))) 209 | XCTAssertEqual(lexer.getNextToken(), .eof) 210 | } 211 | 212 | func testStringWithPunctuationRepeated() { 213 | let input = "abc – ...,,xyz: lol," 214 | let lexer = Lexer(input) 215 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("abc"))) 216 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 217 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("–"))) 218 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 219 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("...,,"))) 220 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("xyz"))) 221 | XCTAssertEqual(lexer.getNextToken(), .colon) 222 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 223 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("lol"))) 224 | XCTAssertEqual(lexer.getNextToken(), .constant(.string(","))) 225 | XCTAssertEqual(lexer.getNextToken(), .eof) 226 | } 227 | 228 | func testIngridientsOneLiner() { 229 | let input = "Add @onions{3%medium} chopped finely" 230 | let lexer = Lexer(input) 231 | 232 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("Add"))) 233 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 234 | XCTAssertEqual(lexer.getNextToken(), .at) 235 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("onions"))) 236 | XCTAssertEqual(lexer.getNextToken(), .braces(.left)) 237 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(3))) 238 | XCTAssertEqual(lexer.getNextToken(), .percent) 239 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("medium"))) 240 | XCTAssertEqual(lexer.getNextToken(), .braces(.right)) 241 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 242 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("chopped"))) 243 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 244 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("finely"))) 245 | XCTAssertEqual(lexer.getNextToken(), .eof) 246 | } 247 | 248 | func testIngridientsDecimal() { 249 | let input = "@onions{3.5%medium}" 250 | let lexer = Lexer(input) 251 | 252 | XCTAssertEqual(lexer.getNextToken(), .at) 253 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("onions"))) 254 | XCTAssertEqual(lexer.getNextToken(), .braces(.left)) 255 | XCTAssertEqual(lexer.getNextToken(), .constant(.decimal(3.5))) 256 | XCTAssertEqual(lexer.getNextToken(), .percent) 257 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("medium"))) 258 | XCTAssertEqual(lexer.getNextToken(), .braces(.right)) 259 | XCTAssertEqual(lexer.getNextToken(), .eof) 260 | } 261 | 262 | func testIngridientsFractions() { 263 | let input = "@onions{1/4%medium}" 264 | let lexer = Lexer(input) 265 | 266 | XCTAssertEqual(lexer.getNextToken(), .at) 267 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("onions"))) 268 | XCTAssertEqual(lexer.getNextToken(), .braces(.left)) 269 | XCTAssertEqual(lexer.getNextToken(), .constant(.fractional((1,4)))) 270 | XCTAssertEqual(lexer.getNextToken(), .percent) 271 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("medium"))) 272 | XCTAssertEqual(lexer.getNextToken(), .braces(.right)) 273 | XCTAssertEqual(lexer.getNextToken(), .eof) 274 | } 275 | 276 | func testIngridientsFractionsWithSpaces() { 277 | let input = "@onions{1 / 4 %medium}" 278 | let lexer = Lexer(input) 279 | 280 | XCTAssertEqual(lexer.getNextToken(), .at) 281 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("onions"))) 282 | XCTAssertEqual(lexer.getNextToken(), .braces(.left)) 283 | XCTAssertEqual(lexer.getNextToken(), .constant(.fractional((1,4)))) 284 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 285 | XCTAssertEqual(lexer.getNextToken(), .percent) 286 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("medium"))) 287 | XCTAssertEqual(lexer.getNextToken(), .braces(.right)) 288 | XCTAssertEqual(lexer.getNextToken(), .eof) 289 | } 290 | 291 | func testComments() { 292 | let input = "-- testing comments" 293 | let lexer = Lexer(input) 294 | 295 | XCTAssertEqual(lexer.getNextToken(), .eof) 296 | } 297 | 298 | func testBlockComments() { 299 | let input = "visible [- hidden -] visible" 300 | let lexer = Lexer(input) 301 | 302 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("visible"))) 303 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 304 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 305 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("visible"))) 306 | XCTAssertEqual(lexer.getNextToken(), .eof) 307 | } 308 | 309 | func testBlockCommentsMultiline() { 310 | let input = """ 311 | visible [- hidden 312 | hidden 313 | hidden -] visible 314 | """ 315 | let lexer = Lexer(input) 316 | 317 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("visible"))) 318 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 319 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 320 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("visible"))) 321 | XCTAssertEqual(lexer.getNextToken(), .eof) 322 | } 323 | 324 | func testBlockCommentsMultilineUnfinished() { 325 | let input = """ 326 | visible [- hidden 327 | hidden 328 | hidden 329 | """ 330 | let lexer = Lexer(input) 331 | 332 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("visible"))) 333 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 334 | XCTAssertEqual(lexer.getNextToken(), .eof) 335 | } 336 | 337 | func testBlockCommentsUnfinished() { 338 | let input = "visible [- hidden" 339 | let lexer = Lexer(input) 340 | 341 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("visible"))) 342 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 343 | XCTAssertEqual(lexer.getNextToken(), .eof) 344 | } 345 | 346 | func testDashLast() { 347 | let input = "onions -" 348 | let lexer = Lexer(input) 349 | 350 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("onions"))) 351 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 352 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("-"))) 353 | XCTAssertEqual(lexer.getNextToken(), .eof) 354 | } 355 | 356 | func testDashInText() { 357 | let input = "Preheat the oven to 200℃-Fan 180°C." 358 | let lexer = Lexer(input) 359 | 360 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("Preheat"))) 361 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 362 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("the"))) 363 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 364 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("oven"))) 365 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 366 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("to"))) 367 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 368 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(200))) 369 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("℃-"))) 370 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("Fan"))) 371 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 372 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(180))) 373 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("°"))) 374 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("C"))) 375 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("."))) 376 | XCTAssertEqual(lexer.getNextToken(), .eof) 377 | } 378 | 379 | func testSquareInText() { 380 | let input = "Preheat the oven to 200℃[Fan] 180°C." 381 | let lexer = Lexer(input) 382 | 383 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("Preheat"))) 384 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 385 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("the"))) 386 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 387 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("oven"))) 388 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 389 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("to"))) 390 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 391 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(200))) 392 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("℃["))) 393 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("Fan"))) 394 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("]"))) 395 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 396 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(180))) 397 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("°"))) 398 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("C"))) 399 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("."))) 400 | XCTAssertEqual(lexer.getNextToken(), .eof) 401 | } 402 | 403 | func testIngridientsNoUnits() { 404 | let input = "@onions{3}" 405 | let lexer = Lexer(input) 406 | 407 | XCTAssertEqual(lexer.getNextToken(), .at) 408 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("onions"))) 409 | XCTAssertEqual(lexer.getNextToken(), .braces(.left)) 410 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(3))) 411 | XCTAssertEqual(lexer.getNextToken(), .braces(.right)) 412 | XCTAssertEqual(lexer.getNextToken(), .eof) 413 | } 414 | 415 | func testIngridientsNoAmount() { 416 | let input = "@onions" 417 | let lexer = Lexer(input) 418 | 419 | XCTAssertEqual(lexer.getNextToken(), .at) 420 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("onions"))) 421 | XCTAssertEqual(lexer.getNextToken(), .eof) 422 | } 423 | 424 | func testIngridientsMultiWordNoAmount() { 425 | let input = "@red onions{}" 426 | let lexer = Lexer(input) 427 | 428 | XCTAssertEqual(lexer.getNextToken(), .at) 429 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("red"))) 430 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 431 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("onions"))) 432 | XCTAssertEqual(lexer.getNextToken(), .braces(.left)) 433 | XCTAssertEqual(lexer.getNextToken(), .braces(.right)) 434 | XCTAssertEqual(lexer.getNextToken(), .eof) 435 | } 436 | 437 | func testIngridientsMultiWordWithPunctuation() { 438 | let input = "@onions, chopped" 439 | let lexer = Lexer(input) 440 | 441 | XCTAssertEqual(lexer.getNextToken(), .at) 442 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("onions"))) 443 | XCTAssertEqual(lexer.getNextToken(), .constant(.string(","))) 444 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 445 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("chopped"))) 446 | XCTAssertEqual(lexer.getNextToken(), .eof) 447 | } 448 | 449 | func testIngridientsWordNoAmount() { 450 | let input = "an @onion finely chopped" 451 | let lexer = Lexer(input) 452 | 453 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("an"))) 454 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 455 | XCTAssertEqual(lexer.getNextToken(), .at) 456 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("onion"))) 457 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 458 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("finely"))) 459 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 460 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("chopped"))) 461 | XCTAssertEqual(lexer.getNextToken(), .eof) 462 | } 463 | 464 | func testEquipment() { 465 | let input = "put into #oven" 466 | let lexer = Lexer(input) 467 | 468 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("put"))) 469 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 470 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("into"))) 471 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 472 | XCTAssertEqual(lexer.getNextToken(), .hash) 473 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("oven"))) 474 | XCTAssertEqual(lexer.getNextToken(), .eof) 475 | } 476 | 477 | func testEquipmentMultiWord() { 478 | let input = "fry on #frying pan{}" 479 | let lexer = Lexer(input) 480 | 481 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("fry"))) 482 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 483 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("on"))) 484 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 485 | XCTAssertEqual(lexer.getNextToken(), .hash) 486 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("frying"))) 487 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 488 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("pan"))) 489 | XCTAssertEqual(lexer.getNextToken(), .braces(.left)) 490 | XCTAssertEqual(lexer.getNextToken(), .braces(.right)) 491 | XCTAssertEqual(lexer.getNextToken(), .eof) 492 | } 493 | 494 | func testTimer() { 495 | let input = "cook for ~{10%minutes}" 496 | let lexer = Lexer(input) 497 | 498 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("cook"))) 499 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 500 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("for"))) 501 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 502 | XCTAssertEqual(lexer.getNextToken(), .tilde) 503 | XCTAssertEqual(lexer.getNextToken(), .braces(.left)) 504 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(10))) 505 | XCTAssertEqual(lexer.getNextToken(), .percent) 506 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("minutes"))) 507 | XCTAssertEqual(lexer.getNextToken(), .braces(.right)) 508 | XCTAssertEqual(lexer.getNextToken(), .eof) 509 | } 510 | 511 | func testBrokenTimer() { 512 | let input = "cook for ~{10 minutes}" 513 | let lexer = Lexer(input) 514 | 515 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("cook"))) 516 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 517 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("for"))) 518 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 519 | XCTAssertEqual(lexer.getNextToken(), .tilde) 520 | XCTAssertEqual(lexer.getNextToken(), .braces(.left)) 521 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(10))) 522 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 523 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("minutes"))) 524 | XCTAssertEqual(lexer.getNextToken(), .braces(.right)) 525 | XCTAssertEqual(lexer.getNextToken(), .eof) 526 | } 527 | 528 | func testIngridientsMultiLiner() { 529 | let input = """ 530 | Add @onions{3%medium} chopped finely 531 | 532 | Bonne appetite! 533 | """ 534 | let lexer = Lexer(input) 535 | 536 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("Add"))) 537 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 538 | XCTAssertEqual(lexer.getNextToken(), .at) 539 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("onions"))) 540 | XCTAssertEqual(lexer.getNextToken(), .braces(.left)) 541 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(3))) 542 | XCTAssertEqual(lexer.getNextToken(), .percent) 543 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("medium"))) 544 | XCTAssertEqual(lexer.getNextToken(), .braces(.right)) 545 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 546 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("chopped"))) 547 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 548 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("finely"))) 549 | XCTAssertEqual(lexer.getNextToken(), .eol) 550 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("Bonne"))) 551 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 552 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("appetite"))) 553 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("!"))) 554 | XCTAssertEqual(lexer.getNextToken(), .eof) 555 | } 556 | 557 | func testMetadata() { 558 | let input = ">> servings: 4|5|6" 559 | let lexer = Lexer(input) 560 | 561 | XCTAssertEqual(lexer.getNextToken(), .chevron) 562 | XCTAssertEqual(lexer.getNextToken(), .chevron) 563 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 564 | XCTAssertEqual(lexer.getNextToken(), .constant(.string("servings"))) 565 | XCTAssertEqual(lexer.getNextToken(), .colon) 566 | XCTAssertEqual(lexer.getNextToken(), .constant(.space)) 567 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(4))) 568 | XCTAssertEqual(lexer.getNextToken(), .pipe) 569 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(5))) 570 | XCTAssertEqual(lexer.getNextToken(), .pipe) 571 | XCTAssertEqual(lexer.getNextToken(), .constant(.integer(6))) 572 | XCTAssertEqual(lexer.getNextToken(), .eof) 573 | } 574 | 575 | } 576 | -------------------------------------------------------------------------------- /Tests/CookInSwiftTests/ParserCanonicalTests.swift: -------------------------------------------------------------------------------- 1 | // file is autogenerated from canonical_tests.yaml 2 | // 3 | // $ ruby Tests/CookInSwiftTests/generate_canonical_tests.rb ../spec/tests/canonical.yaml 4 | // 5 | // don't edit this file 6 | // 7 | // version: 4 8 | // 9 | 10 | import Foundation 11 | import XCTest 12 | @testable import CookInSwift 13 | 14 | class ParserCanonicalTests: XCTestCase { 15 | 16 | func testBasicDirection() { 17 | let recipe = 18 | """ 19 | Add a bit of chilli 20 | """ 21 | 22 | let result = try! Parser.parse(recipe) as! RecipeNode 23 | 24 | let steps: [StepNode] = [ 25 | StepNode(instructions: [ 26 | DirectionNode("Add a bit of chilli"), 27 | ]), 28 | ] 29 | 30 | let metadata: [MetadataNode] = [] 31 | 32 | let node = RecipeNode(steps: steps, metadata: metadata) 33 | 34 | XCTAssertEqual(result, node) 35 | } 36 | 37 | func testComments() { 38 | let recipe = 39 | """ 40 | -- testing comments 41 | """ 42 | 43 | let result = try! Parser.parse(recipe) as! RecipeNode 44 | 45 | let steps: [StepNode] = [ 46 | ] 47 | 48 | let metadata: [MetadataNode] = [] 49 | 50 | let node = RecipeNode(steps: steps, metadata: metadata) 51 | 52 | XCTAssertEqual(result, node) 53 | } 54 | 55 | func testCommentsAfterIngredients() { 56 | let recipe = 57 | """ 58 | @thyme{2%springs} -- testing comments 59 | and some text 60 | """ 61 | 62 | let result = try! Parser.parse(recipe) as! RecipeNode 63 | 64 | let steps: [StepNode] = [ 65 | StepNode(instructions: [ 66 | IngredientNode(name: "thyme", amount: AmountNode(quantity: 2, units: "springs")), 67 | DirectionNode(" "), 68 | ]), 69 | StepNode(instructions: [ 70 | DirectionNode("and some text"), 71 | ]), 72 | ] 73 | 74 | let metadata: [MetadataNode] = [] 75 | 76 | let node = RecipeNode(steps: steps, metadata: metadata) 77 | 78 | XCTAssertEqual(result, node) 79 | } 80 | 81 | func testCommentsWithIngredients() { 82 | let recipe = 83 | """ 84 | -- testing comments 85 | @thyme{2%springs} 86 | """ 87 | 88 | let result = try! Parser.parse(recipe) as! RecipeNode 89 | 90 | let steps: [StepNode] = [ 91 | StepNode(instructions: [ 92 | IngredientNode(name: "thyme", amount: AmountNode(quantity: 2, units: "springs")), 93 | ]), 94 | ] 95 | 96 | let metadata: [MetadataNode] = [] 97 | 98 | let node = RecipeNode(steps: steps, metadata: metadata) 99 | 100 | XCTAssertEqual(result, node) 101 | } 102 | 103 | func testDirectionsWithDegrees() { 104 | let recipe = 105 | """ 106 | Heat oven up to 200°C 107 | """ 108 | 109 | let result = try! Parser.parse(recipe) as! RecipeNode 110 | 111 | let steps: [StepNode] = [ 112 | StepNode(instructions: [ 113 | DirectionNode("Heat oven up to 200°C"), 114 | ]), 115 | ] 116 | 117 | let metadata: [MetadataNode] = [] 118 | 119 | let node = RecipeNode(steps: steps, metadata: metadata) 120 | 121 | XCTAssertEqual(result, node) 122 | } 123 | 124 | func testDirectionsWithNumbers() { 125 | let recipe = 126 | """ 127 | Heat 5L of water 128 | """ 129 | 130 | let result = try! Parser.parse(recipe) as! RecipeNode 131 | 132 | let steps: [StepNode] = [ 133 | StepNode(instructions: [ 134 | DirectionNode("Heat 5L of water"), 135 | ]), 136 | ] 137 | 138 | let metadata: [MetadataNode] = [] 139 | 140 | let node = RecipeNode(steps: steps, metadata: metadata) 141 | 142 | XCTAssertEqual(result, node) 143 | } 144 | 145 | func testDirectionWithIngrident() { 146 | let recipe = 147 | """ 148 | Add @chilli{3%items}, @ginger{10%g} and @milk{1%l}. 149 | """ 150 | 151 | let result = try! Parser.parse(recipe) as! RecipeNode 152 | 153 | let steps: [StepNode] = [ 154 | StepNode(instructions: [ 155 | DirectionNode("Add "), 156 | IngredientNode(name: "chilli", amount: AmountNode(quantity: 3, units: "items")), 157 | DirectionNode(", "), 158 | IngredientNode(name: "ginger", amount: AmountNode(quantity: 10, units: "g")), 159 | DirectionNode(" and "), 160 | IngredientNode(name: "milk", amount: AmountNode(quantity: 1, units: "l")), 161 | DirectionNode("."), 162 | ]), 163 | ] 164 | 165 | let metadata: [MetadataNode] = [] 166 | 167 | let node = RecipeNode(steps: steps, metadata: metadata) 168 | 169 | XCTAssertEqual(result, node) 170 | } 171 | 172 | func testEquipmentMultipleWords() { 173 | let recipe = 174 | """ 175 | Fry in #frying pan{} 176 | """ 177 | 178 | let result = try! Parser.parse(recipe) as! RecipeNode 179 | 180 | let steps: [StepNode] = [ 181 | StepNode(instructions: [ 182 | DirectionNode("Fry in "), 183 | EquipmentNode(name: "frying pan"), 184 | ]), 185 | ] 186 | 187 | let metadata: [MetadataNode] = [] 188 | 189 | let node = RecipeNode(steps: steps, metadata: metadata) 190 | 191 | XCTAssertEqual(result, node) 192 | } 193 | 194 | func testEquipmentMultipleWordsWithLeadingNumber() { 195 | let recipe = 196 | """ 197 | Fry in #7-inch nonstick frying pan{ } 198 | """ 199 | 200 | let result = try! Parser.parse(recipe) as! RecipeNode 201 | 202 | let steps: [StepNode] = [ 203 | StepNode(instructions: [ 204 | DirectionNode("Fry in "), 205 | EquipmentNode(name: "7-inch nonstick frying pan"), 206 | ]), 207 | ] 208 | 209 | let metadata: [MetadataNode] = [] 210 | 211 | let node = RecipeNode(steps: steps, metadata: metadata) 212 | 213 | XCTAssertEqual(result, node) 214 | } 215 | 216 | func testEquipmentMultipleWordsWithSpaces() { 217 | let recipe = 218 | """ 219 | Fry in #frying pan{ } 220 | """ 221 | 222 | let result = try! Parser.parse(recipe) as! RecipeNode 223 | 224 | let steps: [StepNode] = [ 225 | StepNode(instructions: [ 226 | DirectionNode("Fry in "), 227 | EquipmentNode(name: "frying pan"), 228 | ]), 229 | ] 230 | 231 | let metadata: [MetadataNode] = [] 232 | 233 | let node = RecipeNode(steps: steps, metadata: metadata) 234 | 235 | XCTAssertEqual(result, node) 236 | } 237 | 238 | func testEquipmentOneWord() { 239 | let recipe = 240 | """ 241 | Simmer in #pan for some time 242 | """ 243 | 244 | let result = try! Parser.parse(recipe) as! RecipeNode 245 | 246 | let steps: [StepNode] = [ 247 | StepNode(instructions: [ 248 | DirectionNode("Simmer in "), 249 | EquipmentNode(name: "pan"), 250 | DirectionNode(" for some time"), 251 | ]), 252 | ] 253 | 254 | let metadata: [MetadataNode] = [] 255 | 256 | let node = RecipeNode(steps: steps, metadata: metadata) 257 | 258 | XCTAssertEqual(result, node) 259 | } 260 | 261 | func testFractions() { 262 | let recipe = 263 | """ 264 | @milk{1/2%cup} 265 | """ 266 | 267 | let result = try! Parser.parse(recipe) as! RecipeNode 268 | 269 | let steps: [StepNode] = [ 270 | StepNode(instructions: [ 271 | IngredientNode(name: "milk", amount: AmountNode(quantity: 0.5, units: "cup")), 272 | ]), 273 | ] 274 | 275 | let metadata: [MetadataNode] = [] 276 | 277 | let node = RecipeNode(steps: steps, metadata: metadata) 278 | 279 | XCTAssertEqual(result, node) 280 | } 281 | 282 | func testFractionsInDirections() { 283 | let recipe = 284 | """ 285 | knife cut about every 1/2 inches 286 | """ 287 | 288 | let result = try! Parser.parse(recipe) as! RecipeNode 289 | 290 | let steps: [StepNode] = [ 291 | StepNode(instructions: [ 292 | DirectionNode("knife cut about every 1/2 inches"), 293 | ]), 294 | ] 295 | 296 | let metadata: [MetadataNode] = [] 297 | 298 | let node = RecipeNode(steps: steps, metadata: metadata) 299 | 300 | XCTAssertEqual(result, node) 301 | } 302 | 303 | func testFractionsLike() { 304 | let recipe = 305 | """ 306 | @milk{01/2%cup} 307 | """ 308 | 309 | let result = try! Parser.parse(recipe) as! RecipeNode 310 | 311 | let steps: [StepNode] = [ 312 | StepNode(instructions: [ 313 | IngredientNode(name: "milk", amount: AmountNode(quantity: "01/2", units: "cup")), 314 | ]), 315 | ] 316 | 317 | let metadata: [MetadataNode] = [] 318 | 319 | let node = RecipeNode(steps: steps, metadata: metadata) 320 | 321 | XCTAssertEqual(result, node) 322 | } 323 | 324 | func testFractionsWithSpaces() { 325 | let recipe = 326 | """ 327 | @milk{1 / 2 %cup} 328 | """ 329 | 330 | let result = try! Parser.parse(recipe) as! RecipeNode 331 | 332 | let steps: [StepNode] = [ 333 | StepNode(instructions: [ 334 | IngredientNode(name: "milk", amount: AmountNode(quantity: 0.5, units: "cup")), 335 | ]), 336 | ] 337 | 338 | let metadata: [MetadataNode] = [] 339 | 340 | let node = RecipeNode(steps: steps, metadata: metadata) 341 | 342 | XCTAssertEqual(result, node) 343 | } 344 | 345 | func testIngredientMultipleWordsWithLeadingNumber() { 346 | let recipe = 347 | """ 348 | Top with @1000 island dressing{ } 349 | """ 350 | 351 | let result = try! Parser.parse(recipe) as! RecipeNode 352 | 353 | let steps: [StepNode] = [ 354 | StepNode(instructions: [ 355 | DirectionNode("Top with "), 356 | IngredientNode(name: "1000 island dressing", amount: AmountNode(quantity: "some", units: "")), 357 | ]), 358 | ] 359 | 360 | let metadata: [MetadataNode] = [] 361 | 362 | let node = RecipeNode(steps: steps, metadata: metadata) 363 | 364 | XCTAssertEqual(result, node) 365 | } 366 | 367 | func testIngredientWithEmoji() { 368 | let recipe = 369 | """ 370 | Add some @🧂 371 | """ 372 | 373 | let result = try! Parser.parse(recipe) as! RecipeNode 374 | 375 | let steps: [StepNode] = [ 376 | StepNode(instructions: [ 377 | DirectionNode("Add some "), 378 | IngredientNode(name: "🧂", amount: AmountNode(quantity: "some", units: "")), 379 | ]), 380 | ] 381 | 382 | let metadata: [MetadataNode] = [] 383 | 384 | let node = RecipeNode(steps: steps, metadata: metadata) 385 | 386 | XCTAssertEqual(result, node) 387 | } 388 | 389 | func testIngridentExplicitUnits() { 390 | let recipe = 391 | """ 392 | @chilli{3%items} 393 | """ 394 | 395 | let result = try! Parser.parse(recipe) as! RecipeNode 396 | 397 | let steps: [StepNode] = [ 398 | StepNode(instructions: [ 399 | IngredientNode(name: "chilli", amount: AmountNode(quantity: 3, units: "items")), 400 | ]), 401 | ] 402 | 403 | let metadata: [MetadataNode] = [] 404 | 405 | let node = RecipeNode(steps: steps, metadata: metadata) 406 | 407 | XCTAssertEqual(result, node) 408 | } 409 | 410 | func testIngridentExplicitUnitsWithSpaces() { 411 | let recipe = 412 | """ 413 | @chilli{ 3 % items } 414 | """ 415 | 416 | let result = try! Parser.parse(recipe) as! RecipeNode 417 | 418 | let steps: [StepNode] = [ 419 | StepNode(instructions: [ 420 | IngredientNode(name: "chilli", amount: AmountNode(quantity: 3, units: "items")), 421 | ]), 422 | ] 423 | 424 | let metadata: [MetadataNode] = [] 425 | 426 | let node = RecipeNode(steps: steps, metadata: metadata) 427 | 428 | XCTAssertEqual(result, node) 429 | } 430 | 431 | func testIngridentImplicitUnits() { 432 | let recipe = 433 | """ 434 | @chilli{3} 435 | """ 436 | 437 | let result = try! Parser.parse(recipe) as! RecipeNode 438 | 439 | let steps: [StepNode] = [ 440 | StepNode(instructions: [ 441 | IngredientNode(name: "chilli", amount: AmountNode(quantity: 3, units: "")), 442 | ]), 443 | ] 444 | 445 | let metadata: [MetadataNode] = [] 446 | 447 | let node = RecipeNode(steps: steps, metadata: metadata) 448 | 449 | XCTAssertEqual(result, node) 450 | } 451 | 452 | func testIngridentNoUnits() { 453 | let recipe = 454 | """ 455 | @chilli 456 | """ 457 | 458 | let result = try! Parser.parse(recipe) as! RecipeNode 459 | 460 | let steps: [StepNode] = [ 461 | StepNode(instructions: [ 462 | IngredientNode(name: "chilli", amount: AmountNode(quantity: "some", units: "")), 463 | ]), 464 | ] 465 | 466 | let metadata: [MetadataNode] = [] 467 | 468 | let node = RecipeNode(steps: steps, metadata: metadata) 469 | 470 | XCTAssertEqual(result, node) 471 | } 472 | 473 | func testIngridentNoUnitsNotOnlyString() { 474 | let recipe = 475 | """ 476 | @5peppers 477 | """ 478 | 479 | let result = try! Parser.parse(recipe) as! RecipeNode 480 | 481 | let steps: [StepNode] = [ 482 | StepNode(instructions: [ 483 | IngredientNode(name: "5peppers", amount: AmountNode(quantity: "some", units: "")), 484 | ]), 485 | ] 486 | 487 | let metadata: [MetadataNode] = [] 488 | 489 | let node = RecipeNode(steps: steps, metadata: metadata) 490 | 491 | XCTAssertEqual(result, node) 492 | } 493 | 494 | func testIngridentWithNumbers() { 495 | let recipe = 496 | """ 497 | @tipo 00 flour{250%g} 498 | """ 499 | 500 | let result = try! Parser.parse(recipe) as! RecipeNode 501 | 502 | let steps: [StepNode] = [ 503 | StepNode(instructions: [ 504 | IngredientNode(name: "tipo 00 flour", amount: AmountNode(quantity: 250, units: "g")), 505 | ]), 506 | ] 507 | 508 | let metadata: [MetadataNode] = [] 509 | 510 | let node = RecipeNode(steps: steps, metadata: metadata) 511 | 512 | XCTAssertEqual(result, node) 513 | } 514 | 515 | func testIngridentWithoutStopper() { 516 | let recipe = 517 | """ 518 | @chilli cut into pieces 519 | """ 520 | 521 | let result = try! Parser.parse(recipe) as! RecipeNode 522 | 523 | let steps: [StepNode] = [ 524 | StepNode(instructions: [ 525 | IngredientNode(name: "chilli", amount: AmountNode(quantity: "some", units: "")), 526 | DirectionNode(" cut into pieces"), 527 | ]), 528 | ] 529 | 530 | let metadata: [MetadataNode] = [] 531 | 532 | let node = RecipeNode(steps: steps, metadata: metadata) 533 | 534 | XCTAssertEqual(result, node) 535 | } 536 | 537 | func testMetadata() { 538 | let recipe = 539 | """ 540 | >> sourced: babooshka 541 | """ 542 | 543 | let result = try! Parser.parse(recipe) as! RecipeNode 544 | 545 | let steps: [StepNode] = [ 546 | ] 547 | 548 | let metadata: [MetadataNode] = [ 549 | MetadataNode("sourced", "babooshka"),] 550 | 551 | let node = RecipeNode(steps: steps, metadata: metadata) 552 | 553 | XCTAssertEqual(result, node) 554 | } 555 | 556 | func testMetadataBreak() { 557 | let recipe = 558 | """ 559 | hello >> sourced: babooshka 560 | """ 561 | 562 | let result = try! Parser.parse(recipe) as! RecipeNode 563 | 564 | let steps: [StepNode] = [ 565 | StepNode(instructions: [ 566 | DirectionNode("hello >> sourced: babooshka"), 567 | ]), 568 | ] 569 | 570 | let metadata: [MetadataNode] = [] 571 | 572 | let node = RecipeNode(steps: steps, metadata: metadata) 573 | 574 | XCTAssertEqual(result, node) 575 | } 576 | 577 | func testMetadataMultiwordKey() { 578 | let recipe = 579 | """ 580 | >> cooking time: 30 mins 581 | """ 582 | 583 | let result = try! Parser.parse(recipe) as! RecipeNode 584 | 585 | let steps: [StepNode] = [ 586 | ] 587 | 588 | let metadata: [MetadataNode] = [ 589 | MetadataNode("cooking time", "30 mins"),] 590 | 591 | let node = RecipeNode(steps: steps, metadata: metadata) 592 | 593 | XCTAssertEqual(result, node) 594 | } 595 | 596 | func testMetadataMultiwordKeyWithSpaces() { 597 | let recipe = 598 | """ 599 | >>cooking time :30 mins 600 | """ 601 | 602 | let result = try! Parser.parse(recipe) as! RecipeNode 603 | 604 | let steps: [StepNode] = [ 605 | ] 606 | 607 | let metadata: [MetadataNode] = [ 608 | MetadataNode("cooking time", "30 mins"),] 609 | 610 | let node = RecipeNode(steps: steps, metadata: metadata) 611 | 612 | XCTAssertEqual(result, node) 613 | } 614 | 615 | func testMultiLineDirections() { 616 | let recipe = 617 | """ 618 | Add a bit of chilli 619 | 620 | Add a bit of hummus 621 | """ 622 | 623 | let result = try! Parser.parse(recipe) as! RecipeNode 624 | 625 | let steps: [StepNode] = [ 626 | StepNode(instructions: [ 627 | DirectionNode("Add a bit of chilli"), 628 | ]), 629 | StepNode(instructions: [ 630 | DirectionNode("Add a bit of hummus"), 631 | ]), 632 | ] 633 | 634 | let metadata: [MetadataNode] = [] 635 | 636 | let node = RecipeNode(steps: steps, metadata: metadata) 637 | 638 | XCTAssertEqual(result, node) 639 | } 640 | 641 | func testMultipleLines() { 642 | let recipe = 643 | """ 644 | >> Prep Time: 15 minutes 645 | >> Cook Time: 30 minutes 646 | """ 647 | 648 | let result = try! Parser.parse(recipe) as! RecipeNode 649 | 650 | let steps: [StepNode] = [ 651 | ] 652 | 653 | let metadata: [MetadataNode] = [ 654 | MetadataNode("Prep Time", "15 minutes"), 655 | MetadataNode("Cook Time", "30 minutes"),] 656 | 657 | let node = RecipeNode(steps: steps, metadata: metadata) 658 | 659 | XCTAssertEqual(result, node) 660 | } 661 | 662 | func testMultiWordIngrident() { 663 | let recipe = 664 | """ 665 | @hot chilli{3} 666 | """ 667 | 668 | let result = try! Parser.parse(recipe) as! RecipeNode 669 | 670 | let steps: [StepNode] = [ 671 | StepNode(instructions: [ 672 | IngredientNode(name: "hot chilli", amount: AmountNode(quantity: 3, units: "")), 673 | ]), 674 | ] 675 | 676 | let metadata: [MetadataNode] = [] 677 | 678 | let node = RecipeNode(steps: steps, metadata: metadata) 679 | 680 | XCTAssertEqual(result, node) 681 | } 682 | 683 | func testMultiWordIngridentNoAmount() { 684 | let recipe = 685 | """ 686 | @hot chilli{} 687 | """ 688 | 689 | let result = try! Parser.parse(recipe) as! RecipeNode 690 | 691 | let steps: [StepNode] = [ 692 | StepNode(instructions: [ 693 | IngredientNode(name: "hot chilli", amount: AmountNode(quantity: "some", units: "")), 694 | ]), 695 | ] 696 | 697 | let metadata: [MetadataNode] = [] 698 | 699 | let node = RecipeNode(steps: steps, metadata: metadata) 700 | 701 | XCTAssertEqual(result, node) 702 | } 703 | 704 | func testMutipleIngridentsWithoutStopper() { 705 | let recipe = 706 | """ 707 | @chilli cut into pieces and @garlic 708 | """ 709 | 710 | let result = try! Parser.parse(recipe) as! RecipeNode 711 | 712 | let steps: [StepNode] = [ 713 | StepNode(instructions: [ 714 | IngredientNode(name: "chilli", amount: AmountNode(quantity: "some", units: "")), 715 | DirectionNode(" cut into pieces and "), 716 | IngredientNode(name: "garlic", amount: AmountNode(quantity: "some", units: "")), 717 | ]), 718 | ] 719 | 720 | let metadata: [MetadataNode] = [] 721 | 722 | let node = RecipeNode(steps: steps, metadata: metadata) 723 | 724 | XCTAssertEqual(result, node) 725 | } 726 | 727 | func testQuantityAsText() { 728 | let recipe = 729 | """ 730 | @thyme{few%springs} 731 | """ 732 | 733 | let result = try! Parser.parse(recipe) as! RecipeNode 734 | 735 | let steps: [StepNode] = [ 736 | StepNode(instructions: [ 737 | IngredientNode(name: "thyme", amount: AmountNode(quantity: "few", units: "springs")), 738 | ]), 739 | ] 740 | 741 | let metadata: [MetadataNode] = [] 742 | 743 | let node = RecipeNode(steps: steps, metadata: metadata) 744 | 745 | XCTAssertEqual(result, node) 746 | } 747 | 748 | func testQuantityDigitalString() { 749 | let recipe = 750 | """ 751 | @water{7 k } 752 | """ 753 | 754 | let result = try! Parser.parse(recipe) as! RecipeNode 755 | 756 | let steps: [StepNode] = [ 757 | StepNode(instructions: [ 758 | IngredientNode(name: "water", amount: AmountNode(quantity: "7 k", units: "")), 759 | ]), 760 | ] 761 | 762 | let metadata: [MetadataNode] = [] 763 | 764 | let node = RecipeNode(steps: steps, metadata: metadata) 765 | 766 | XCTAssertEqual(result, node) 767 | } 768 | 769 | func testServings() { 770 | let recipe = 771 | """ 772 | >> servings: 1|2|3 773 | """ 774 | 775 | let result = try! Parser.parse(recipe) as! RecipeNode 776 | 777 | let steps: [StepNode] = [ 778 | ] 779 | 780 | let metadata: [MetadataNode] = [ 781 | MetadataNode("servings", "1|2|3"),] 782 | 783 | let node = RecipeNode(steps: steps, metadata: metadata) 784 | 785 | XCTAssertEqual(result, node) 786 | } 787 | 788 | func testSlashInText() { 789 | let recipe = 790 | """ 791 | Preheat the oven to 200℃/Fan 180°C. 792 | """ 793 | 794 | let result = try! Parser.parse(recipe) as! RecipeNode 795 | 796 | let steps: [StepNode] = [ 797 | StepNode(instructions: [ 798 | DirectionNode("Preheat the oven to 200℃/Fan 180°C."), 799 | ]), 800 | ] 801 | 802 | let metadata: [MetadataNode] = [] 803 | 804 | let node = RecipeNode(steps: steps, metadata: metadata) 805 | 806 | XCTAssertEqual(result, node) 807 | } 808 | 809 | func testTimerDecimal() { 810 | let recipe = 811 | """ 812 | Fry for ~{1.5%minutes} 813 | """ 814 | 815 | let result = try! Parser.parse(recipe) as! RecipeNode 816 | 817 | let steps: [StepNode] = [ 818 | StepNode(instructions: [ 819 | DirectionNode("Fry for "), 820 | TimerNode(quantity: 1.5, units: "minutes", name: ""), 821 | ]), 822 | ] 823 | 824 | let metadata: [MetadataNode] = [] 825 | 826 | let node = RecipeNode(steps: steps, metadata: metadata) 827 | 828 | XCTAssertEqual(result, node) 829 | } 830 | 831 | func testTimerFractional() { 832 | let recipe = 833 | """ 834 | Fry for ~{1/2%hour} 835 | """ 836 | 837 | let result = try! Parser.parse(recipe) as! RecipeNode 838 | 839 | let steps: [StepNode] = [ 840 | StepNode(instructions: [ 841 | DirectionNode("Fry for "), 842 | TimerNode(quantity: 0.5, units: "hour", name: ""), 843 | ]), 844 | ] 845 | 846 | let metadata: [MetadataNode] = [] 847 | 848 | let node = RecipeNode(steps: steps, metadata: metadata) 849 | 850 | XCTAssertEqual(result, node) 851 | } 852 | 853 | func testTimerInteger() { 854 | let recipe = 855 | """ 856 | Fry for ~{10%minutes} 857 | """ 858 | 859 | let result = try! Parser.parse(recipe) as! RecipeNode 860 | 861 | let steps: [StepNode] = [ 862 | StepNode(instructions: [ 863 | DirectionNode("Fry for "), 864 | TimerNode(quantity: 10, units: "minutes", name: ""), 865 | ]), 866 | ] 867 | 868 | let metadata: [MetadataNode] = [] 869 | 870 | let node = RecipeNode(steps: steps, metadata: metadata) 871 | 872 | XCTAssertEqual(result, node) 873 | } 874 | 875 | func testTimerWithName() { 876 | let recipe = 877 | """ 878 | Fry for ~potato{42%minutes} 879 | """ 880 | 881 | let result = try! Parser.parse(recipe) as! RecipeNode 882 | 883 | let steps: [StepNode] = [ 884 | StepNode(instructions: [ 885 | DirectionNode("Fry for "), 886 | TimerNode(quantity: 42, units: "minutes", name: "potato"), 887 | ]), 888 | ] 889 | 890 | let metadata: [MetadataNode] = [] 891 | 892 | let node = RecipeNode(steps: steps, metadata: metadata) 893 | 894 | XCTAssertEqual(result, node) 895 | } 896 | } --------------------------------------------------------------------------------