├── src ├── testdata │ ├── file1.scad │ ├── echo_test.scad │ ├── includes │ │ ├── included.scad │ │ └── file.scad │ ├── unknown_module.scad │ ├── function_call_test.scad │ ├── error.scad │ ├── vector_format.scad │ ├── newline_test.scad │ ├── pinpointer_block_test.scad │ ├── comment_break_test.scad │ ├── formattest.scad │ ├── sandbox.scad │ ├── render_test.scad │ ├── modifiers.scad │ ├── dupa_format_error.scad │ ├── completion_crash_fix.scad │ ├── resolver_solution_manager_test.scad │ ├── ddd.scad │ └── hull.scad ├── comments │ ├── DocAnnotationClass.ts │ ├── annotations.ts │ ├── DocComment.test.ts │ └── DocComment.ts ├── errors │ ├── LexingError.ts │ ├── ParsingError.ts │ ├── CodeError.ts │ ├── lexingErrors.ts │ └── parsingErrors.ts ├── __snapshots__ │ └── CodeLocation.test.ts.snap ├── semantic │ ├── NodeWithScope.ts │ ├── CompletionType.ts │ ├── ScadFileProvider.ts │ ├── CompletionSymbol.ts │ ├── unresolvedSymbolErrors.ts │ ├── CompletionProvider.ts │ ├── resolvedNodes.ts │ ├── KeywordsCompletionProvider.ts │ ├── CompletionUtil.ts │ ├── Scope.test.ts │ ├── ASTSymbolLister.test.ts │ ├── CompletionUtil.test.ts │ ├── ScopeSymbolCompletionProvider.ts │ ├── Scope.ts │ ├── FilenameCompletionProvider.test.ts │ ├── ASTScopePopulator.test.ts │ ├── ASTSymbolLister.ts │ ├── SolutionManager.test.ts │ ├── IncludeResolver.ts │ ├── FilenameCompletionProvider.ts │ ├── nodesWithScopes.ts │ ├── SymbolResolver.ts │ ├── SymbolResolver.test.ts │ └── ASTScopePopulator.ts ├── CodeLocation.test.ts ├── LiteralToken.ts ├── FormattingConfiguration.ts ├── CodeFile.test.ts ├── ast │ ├── ASTNode.ts │ ├── ScadFile.ts │ ├── ErrorNode.ts │ ├── AssignmentNode.ts │ ├── ASTVisitor.ts │ ├── statements.ts │ └── expressions.ts ├── ErrorCollector.test.ts ├── extraTokens.ts ├── Parser.incompleteCode.test.ts ├── ErrorCollector.ts ├── CodeFile.ts ├── Token.ts ├── ParsingHelper.ts ├── CodeSpan.ts ├── prelude │ └── PreludeUtil.ts ├── CodeLocation.ts ├── keywords.ts ├── friendlyTokenNames.ts ├── scadfmt.ts ├── TokenType.ts ├── ASTPinpointer.ts ├── index.ts ├── ASTPinpointer.test.ts ├── SolutionManager.ts ├── ASTAssembler.ts └── ASTPrinter.test.ts ├── .prettierrc ├── barrelsby.json ├── jest.config.js ├── typedoc.json ├── now.json ├── tsconfig.json ├── .github └── workflows │ ├── node-test.yml │ └── build-docs.yml ├── package.json ├── LICENSE.md ├── README.md └── .gitignore /src/testdata/file1.scad: -------------------------------------------------------------------------------- 1 | cube([2, 3, 8]); -------------------------------------------------------------------------------- /src/testdata/echo_test.scad: -------------------------------------------------------------------------------- 1 | echo("testing"); 2 | -------------------------------------------------------------------------------- /src/testdata/includes/included.scad: -------------------------------------------------------------------------------- 1 | variable = 8; -------------------------------------------------------------------------------- /src/testdata/unknown_module.scad: -------------------------------------------------------------------------------- 1 | fhjfufhfuijff(); 2 | -------------------------------------------------------------------------------- /src/testdata/function_call_test.scad: -------------------------------------------------------------------------------- 1 | val = sin(10); 2 | -------------------------------------------------------------------------------- /src/testdata/error.scad: -------------------------------------------------------------------------------- 1 | function unfinished() = ; 2 | 3 | module ddd(sdsds = -) {} -------------------------------------------------------------------------------- /src/testdata/vector_format.scad: -------------------------------------------------------------------------------- 1 | a = [ 2 | 1, 3 | 2, 4 | 3, 5 | 4, 6 | 5, 7 | ]; 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "trailingComma": "es5" 5 | } 6 | -------------------------------------------------------------------------------- /src/testdata/includes/file.scad: -------------------------------------------------------------------------------- 1 | include 2 | 3 | module the_mod() { 4 | 5 | } -------------------------------------------------------------------------------- /src/testdata/newline_test.scad: -------------------------------------------------------------------------------- 1 | module testowy() { 2 | 3 | module xD() { 4 | 5 | } 6 | } -------------------------------------------------------------------------------- /barrelsby.json: -------------------------------------------------------------------------------- 1 | { 2 | "delete": true, 3 | "directory": "src/", 4 | "exportDefault": true, 5 | "exclude": ".test.ts$" 6 | } 7 | -------------------------------------------------------------------------------- /src/testdata/pinpointer_block_test.scad: -------------------------------------------------------------------------------- 1 | module dupsgo() { 2 | ddd = 18; 3 | module xd() { 4 | 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ["/src"], 3 | transform: { 4 | "^.+\\.tsx?$": "ts-jest" 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /src/testdata/comment_break_test.scad: -------------------------------------------------------------------------------- 1 | 2 | uuuu(); // ? 3 | 4 | function x() = 10; // ddd 5 | 6 | aaa = 9; // xD? 7 | 8 | $fn = 128; // cylinder resolution -------------------------------------------------------------------------------- /src/comments/DocAnnotationClass.ts: -------------------------------------------------------------------------------- 1 | export default interface DocAnnotationClass { 2 | new (contents: string[]): Object; 3 | annotationTag: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/errors/LexingError.ts: -------------------------------------------------------------------------------- 1 | import CodeError from "./CodeError"; 2 | 3 | /** 4 | * @category Error 5 | */ 6 | export default class LexingError extends CodeError {} 7 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "openscad-parser", 3 | "entryPoints": ["./src/index.ts"], 4 | "out": "docs/", 5 | "exclude": "**/*+(.test|.spec|.e2e).ts" 6 | } 7 | -------------------------------------------------------------------------------- /src/errors/ParsingError.ts: -------------------------------------------------------------------------------- 1 | import CodeError from "./CodeError"; 2 | 3 | /** 4 | * @category Error 5 | */ 6 | export default class ParsingError extends CodeError {} 7 | -------------------------------------------------------------------------------- /src/__snapshots__/CodeLocation.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`CodeLocation stringifies itself and matches snapshot 1`] = `"file 's' line 5 column 9'"`; 4 | -------------------------------------------------------------------------------- /src/semantic/NodeWithScope.ts: -------------------------------------------------------------------------------- 1 | import ASTNode from "../ast/ASTNode"; 2 | import Scope from "./Scope"; 3 | 4 | export default interface NodeWithScope extends ASTNode { 5 | scope: Scope; 6 | } 7 | -------------------------------------------------------------------------------- /src/semantic/CompletionType.ts: -------------------------------------------------------------------------------- 1 | enum CompletionType { 2 | VARIABLE, 3 | FUNCTION, 4 | MODULE, 5 | KEYWORD, 6 | FILE, 7 | DIRECTORY, 8 | } 9 | 10 | export default CompletionType; 11 | -------------------------------------------------------------------------------- /src/testdata/formattest.scad: -------------------------------------------------------------------------------- 1 | 2 | translate([0.75 * outer_radius, 1, 0.5 * depth]) 3 | rotate([90, 0, 180]) 4 | cylinder(r = inlet_inner_radius, h = 1.5 * outer_radius); 5 | 6 | 7 | 8 | % cube([10, 10, 10]); 9 | -------------------------------------------------------------------------------- /src/testdata/sandbox.scad: -------------------------------------------------------------------------------- 1 | use 2 | 3 | module asdf() { 4 | module kek(arg = 10) { 5 | a = a; 6 | d = b; 7 | 8 | } 9 | 10 | x = 20; 11 | huba = s; 12 | 13 | d = 10; 14 | 15 | } -------------------------------------------------------------------------------- /src/semantic/ScadFileProvider.ts: -------------------------------------------------------------------------------- 1 | import Scope from "./Scope"; 2 | export interface WithExportedScopes { 3 | getExportedScopes(): Scope[]; 4 | } 5 | 6 | export default interface ScadFileProvider { 7 | provideScadFile(filePath: string): Promise; 8 | } 9 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "name": "openscad-parser", 4 | "alias": "openscad-parser.albert-koczy.com", 5 | "builds": [ 6 | { 7 | "src": "./build-docs.sh", 8 | "use": "@now/static-build", 9 | "config": { "distDir": "docs" } 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /src/testdata/render_test.scad: -------------------------------------------------------------------------------- 1 | render(convexity = 2) difference() { 2 | cube([20, 20, 150], center = true); 3 | translate([-10, -10, 0]) 4 | cylinder(h = 80, r = 10, center = true); 5 | translate([-10, -10, +40]) 6 | sphere(r = 10); 7 | translate([-10, -10, -40]) 8 | sphere(r = 10); 9 | } 10 | -------------------------------------------------------------------------------- /src/testdata/modifiers.scad: -------------------------------------------------------------------------------- 1 | difference() { 2 | cube(10, center = true); 3 | translate([0, 0, 5]) { 4 | rotate([0, 90, 0]) { 5 | cylinder(r = 2, h = 20, center = true, $fn = 40); 6 | } 7 | * rotate([90, 0, 0]) { 8 | # cylinder(r = 2, h = 20, center = true, $fn = 40); 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/errors/CodeError.ts: -------------------------------------------------------------------------------- 1 | import CodeLocation from "../CodeLocation"; 2 | 3 | /** 4 | * A root class for all the errors generated during parsing and lexing. 5 | * @category Error 6 | */ 7 | export default abstract class CodeError extends Error { 8 | constructor(public codeLocation: CodeLocation, message: string) { 9 | super(message); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/CodeLocation.test.ts: -------------------------------------------------------------------------------- 1 | import CodeFile from "./CodeFile"; 2 | import CodeLocation from "./CodeLocation"; 3 | 4 | describe("CodeLocation", () => { 5 | it("stringifies itself and matches snapshot", () => { 6 | const str = new CodeLocation(new CodeFile("s", "d"), 34, 4, 8).toString(); 7 | expect(str).toMatchSnapshot(); 8 | }); 9 | it("constructs", () => { 10 | new CodeLocation(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/LiteralToken.ts: -------------------------------------------------------------------------------- 1 | import CodeSpan from "./CodeSpan"; 2 | import Token from "./Token"; 3 | import TokenType from "./TokenType"; 4 | 5 | /** 6 | * This represents a token which contains a literal value (e.g. string literal, number literal and identifiers.). 7 | */ 8 | export default class LiteralToken extends Token { 9 | constructor( 10 | type: TokenType, 11 | span: CodeSpan, 12 | lexeme: string, 13 | public value: ValueT 14 | ) { 15 | super(type, span, lexeme); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/FormattingConfiguration.ts: -------------------------------------------------------------------------------- 1 | export default class FormattingConfiguration { 2 | indentChar = " "; 3 | indentCount = 4; 4 | moduleInstantiationBreakLength = 40; 5 | 6 | /** 7 | * When sets to true the printer does not print bodies of functions and modules. 8 | * Used for generating focumentation stubs. 9 | */ 10 | definitionsOnly = false; 11 | 12 | /** 13 | * When set to true the formatter adds a comment to each newline describing its purpose. 14 | */ 15 | debugNewlines = false; 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/semantic/CompletionSymbol.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AssignmentNode, 3 | DocComment, 4 | FunctionDeclarationStmt, 5 | ModuleDeclarationStmt, 6 | } from ".."; 7 | import CompletionType from "./CompletionType"; 8 | 9 | export type Declaration = 10 | | AssignmentNode 11 | | ModuleDeclarationStmt 12 | | FunctionDeclarationStmt; 13 | 14 | export default class CompletionSymbol { 15 | constructor( 16 | public type: CompletionType, 17 | public name: string, 18 | public decl?: Declaration 19 | ) {} 20 | } 21 | -------------------------------------------------------------------------------- /src/CodeFile.test.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "url"; 2 | import CodeFile from "./CodeFile"; 3 | 4 | describe("CodeFile", () => { 5 | it("loads files from disk", async () => { 6 | const path = resolve(__dirname, "src/testdata/file1.scad"); 7 | const file = await CodeFile.load(path); 8 | expect(file.code).toEqual("cube([2, 3, 8]);"); 9 | expect(file.filename).toEqual("file1.scad"); 10 | }); 11 | it("rejects on error when loading", () => { 12 | return expect(CodeFile.load("/i/dont/exist")).rejects.toBeTruthy(); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/ast/ASTNode.ts: -------------------------------------------------------------------------------- 1 | import CodeLocation from "../CodeLocation"; 2 | import CodeSpan from "../CodeSpan"; 3 | import Token from "../Token"; 4 | import ASTVisitor from "./ASTVisitor"; 5 | 6 | /** 7 | * @category AST 8 | */ 9 | export default abstract class ASTNode { 10 | constructor() {} 11 | 12 | abstract tokens: { [key: string]: Token | Token[] | null }; 13 | 14 | abstract accept(visitor: ASTVisitor): R; 15 | 16 | get span(): CodeSpan { 17 | return CodeSpan.combine(...Object.values(this.tokens).flat().map((t) => t?.span)); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "declaration": true, 5 | "moduleResolution": "Node", 6 | "target": "esnext", 7 | "allowJs": false, 8 | "noImplicitAny": true, 9 | "experimentalDecorators": true, 10 | "allowSyntheticDefaultImports": false, 11 | "allowUnreachableCode": false, 12 | "allowUnusedLabels": false, 13 | "forceConsistentCasingInFileNames": true, 14 | "sourceMap": true, 15 | "strict": true, 16 | "outDir": "dist" 17 | }, 18 | "include": ["src/**/*.ts"] 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/node-test.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [16.x, 18.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm install 21 | - run: npm run build --if-present 22 | - run: npm test 23 | env: 24 | CI: true 25 | -------------------------------------------------------------------------------- /src/testdata/dupa_format_error.scad: -------------------------------------------------------------------------------- 1 | function convexhull2d(points) = 2 | len(points) < 3 ? [] : let( 3 | a = 0, b = 1, 4 | 5 | c = find_first_noncollinear([a, b], points, 2) 6 | 7 | ) c == len(points) ? convexhull_collinear(points) : let( 8 | 9 | remaining = [for(i = [2 : len(points) - 1]) if(i != c) i], 10 | 11 | polygon = area_2d(points[a], points[b], points[c]) > 0 ? [a, b, c] : [b, a, c] 12 | 13 | ) convex_hull_iterative_2d(points, polygon, remaining); 14 | 15 | 16 | x = 0 ? 1 : let(a = 5) a; 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/ast/ScadFile.ts: -------------------------------------------------------------------------------- 1 | import CodeLocation from "../CodeLocation"; 2 | import Token from "../Token"; 3 | import ASTNode from "./ASTNode"; 4 | import ASTVisitor from "./ASTVisitor"; 5 | import { Statement } from "./statements"; 6 | 7 | /** 8 | * The root node of any AST tree. 9 | * 10 | * Contains top-level statements including the use statements. 11 | * 12 | * @category AST 13 | */ 14 | export default class ScadFile extends ASTNode { 15 | constructor( 16 | public statements: Statement[], 17 | public tokens: { 18 | eot: Token; 19 | } 20 | ) { 21 | super(); 22 | } 23 | accept(visitor: ASTVisitor): R { 24 | return visitor.visitScadFile(this); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/semantic/unresolvedSymbolErrors.ts: -------------------------------------------------------------------------------- 1 | import CodeLocation from "../CodeLocation"; 2 | import CodeError from "../errors/CodeError"; 3 | 4 | export class UnresolvedFunctionError extends CodeError { 5 | constructor(pos: CodeLocation, functionName: string) { 6 | super(pos, `Unresolved function '${functionName}'.`); 7 | } 8 | } 9 | 10 | export class UnresolvedModuleError extends CodeError { 11 | constructor(pos: CodeLocation, functionName: string) { 12 | super(pos, `Unresolved module '${functionName}'.`); 13 | } 14 | } 15 | 16 | export class UnresolvedVariableError extends CodeError { 17 | constructor(pos: CodeLocation, functionName: string) { 18 | super(pos, `Unresolved variable '${functionName}'.`); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/ErrorCollector.test.ts: -------------------------------------------------------------------------------- 1 | import CodeFile from "./CodeFile"; 2 | import CodeLocation from "./CodeLocation"; 3 | import ErrorCollector from "./ErrorCollector"; 4 | import { UnexpectedCharacterLexingError } from "./errors/lexingErrors"; 5 | 6 | describe("ErrorCollector", () => { 7 | it("prints the errors to the console", () => { 8 | const ec = new ErrorCollector(); 9 | ec.reportError( 10 | new UnexpectedCharacterLexingError( 11 | new CodeLocation(new CodeFile("/test.scad", "test"), 21, 37), 12 | "^" 13 | ) 14 | ); 15 | const spy = jest.spyOn(console, "log").mockImplementation(() => {}); 16 | ec.printErrors(); 17 | expect(spy).toHaveBeenCalledWith(expect.stringContaining("^")); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/ast/ErrorNode.ts: -------------------------------------------------------------------------------- 1 | import CodeLocation from "../CodeLocation"; 2 | import Token from "../Token"; 3 | import ASTNode from "./ASTNode"; 4 | import ASTVisitor from "./ASTVisitor"; 5 | 6 | /** 7 | * Is put into the AST after it failed to parse something. Such an AST is invalid, and an error must have been generated. 8 | * It is generated during synchronisation, which occurs on every statement, but you should expect it everywhere when handling the AST. 9 | * @category AST 10 | */ 11 | export default class ErrorNode extends ASTNode { 12 | constructor( 13 | public tokens: { 14 | tokens: Token[]; 15 | } 16 | ) { 17 | super(); 18 | } 19 | accept(visitor: ASTVisitor): R { 20 | return visitor.visitErrorNode(this); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/extraTokens.ts: -------------------------------------------------------------------------------- 1 | import CodeLocation from "./CodeLocation"; 2 | 3 | /** 4 | * An extra tolen is a parto of the source file that doesn't directly influence the AST, but it should be preserved when foromatting the code. 5 | */ 6 | export abstract class ExtraToken { 7 | constructor(public pos: CodeLocation) {} 8 | } 9 | 10 | /** 11 | * A new line between two other tokens. 12 | */ 13 | export class NewLineExtraToken extends ExtraToken {} 14 | 15 | export class SingleLineComment extends ExtraToken { 16 | constructor(pos: CodeLocation, public contents: string) { 17 | super(pos); 18 | } 19 | } 20 | 21 | export class MultiLineComment extends ExtraToken { 22 | constructor(pos: CodeLocation, public contents: string) { 23 | super(pos); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/semantic/CompletionProvider.ts: -------------------------------------------------------------------------------- 1 | import ASTNode from "../ast/ASTNode"; 2 | import CodeLocation from "../CodeLocation"; 3 | import CompletionSymbol from "./CompletionSymbol"; 4 | 5 | export default interface CompletionProvider { 6 | /** 7 | * WHen set to true it means that this Completion source can work without an AST, for example when the file is syntactically invalid. 8 | */ 9 | textOnly: boolean; 10 | 11 | /** 12 | * When set to true it means that no other completion source should activate when this one activates, 13 | */ 14 | exclusive: boolean; 15 | 16 | shouldActivate(ast: ASTNode, loc: CodeLocation): boolean; 17 | getSymbolsAtLocation( 18 | ast: ASTNode, 19 | loc: CodeLocation 20 | ): Promise; 21 | } 22 | -------------------------------------------------------------------------------- /src/semantic/resolvedNodes.ts: -------------------------------------------------------------------------------- 1 | import AssignmentNode from "../ast/AssignmentNode"; 2 | import { FunctionCallExpr, LookupExpr } from "../ast/expressions"; 3 | import { 4 | FunctionDeclarationStmt, 5 | ModuleDeclarationStmt, 6 | ModuleInstantiationStmt, 7 | } from "../ast/statements"; 8 | 9 | /** 10 | * Represents a resolved lookup expression. It can either 11 | * point to an assignment node, or to a named function declaration. 12 | * 13 | * resolvedDeclaration must be set by the instantiating class. 14 | */ 15 | export class ResolvedLookupExpr extends LookupExpr { 16 | resolvedDeclaration!: AssignmentNode | FunctionDeclarationStmt; 17 | } 18 | 19 | export class ResolvedModuleInstantiationStmt extends ModuleInstantiationStmt { 20 | resolvedDeclaration!: ModuleDeclarationStmt; 21 | } 22 | -------------------------------------------------------------------------------- /src/Parser.incompleteCode.test.ts: -------------------------------------------------------------------------------- 1 | import AssignmentNode from "./ast/AssignmentNode"; 2 | import ScadFile from "./ast/ScadFile"; 3 | import CodeFile from "./CodeFile"; 4 | import ParsingHelper from "./ParsingHelper"; 5 | 6 | describe("parser - tests when the code is incomplete", () => { 7 | function doParse(source: string): ScadFile { 8 | const [ast, errorCollector] = ParsingHelper.parseFile( 9 | new CodeFile("", source) 10 | ); 11 | return ast!; 12 | } 13 | it("manages to extract variable declarations when there is a semicolon missing", () => { 14 | const ast = doParse(` 15 | x = 8; 16 | foo = 17 | `); 18 | expect(ast.statements[0]).toBeInstanceOf(AssignmentNode); 19 | expect(ast.statements[1]).not.toBeUndefined(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/semantic/KeywordsCompletionProvider.ts: -------------------------------------------------------------------------------- 1 | import CompletionProvider from "./CompletionProvider"; 2 | import CompletionSymbol from "./CompletionSymbol"; 3 | import ASTNode from "../ast/ASTNode"; 4 | import CodeLocation from "../CodeLocation"; 5 | import keywords from "../keywords"; 6 | import CompletionType from "./CompletionType"; 7 | 8 | export default class KeywordsCompletionProvider implements CompletionProvider { 9 | textOnly = true; 10 | exclusive = false; 11 | shouldActivate(ast: ASTNode, loc: CodeLocation): boolean { 12 | return true; 13 | } 14 | async getSymbolsAtLocation( 15 | ast: ASTNode, 16 | loc: CodeLocation 17 | ): Promise { 18 | return Object.keys(keywords).map( 19 | (kwrd) => new CompletionSymbol(CompletionType.KEYWORD, kwrd) 20 | ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "openscad-parser", 3 | "version": "0.6.3", 4 | "devDependencies": { 5 | "@types/jest": "^29.5.12", 6 | "@types/mock-fs": "^4.13.4", 7 | "@types/node": "^22.3.0", 8 | "barrelsby": "^2.8.1", 9 | "jest": "^29.7.0", 10 | "mock-fs": "^5.2.0", 11 | "prettier": "^3.3.3", 12 | "ts-jest": "^29.2.4", 13 | "ts-node": "^10.9.2", 14 | "typedoc": "^0.26.5", 15 | "typescript": "^5.5.4" 16 | }, 17 | "source": "src/index.ts", 18 | "main": "dist/index.js", 19 | "types": "dist/index.d.ts", 20 | "scripts": { 21 | "prepare": "npm run build", 22 | "test": "jest", 23 | "generate-barrels": "barrelsby -c barrelsby.json", 24 | "build": "tsc --module commonjs && cp src/prelude/prelude.scad dist/prelude/prelude.scad" 25 | }, 26 | "bin": { 27 | "scadfmt": "./dist/scadfmt.js" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/ErrorCollector.ts: -------------------------------------------------------------------------------- 1 | import CodeError from "./errors/CodeError"; 2 | 3 | export default class ErrorCollector { 4 | errors: CodeError[] = []; 5 | reportError(err: ET): ET { 6 | this.errors.push(err); 7 | return err; 8 | } 9 | printErrors() { 10 | const msgs = this.errors.reduce((prev, e) => { 11 | return ( 12 | prev + 13 | e.codeLocation.formatWithContext() + 14 | Object.getPrototypeOf(e).constructor.name + 15 | ": " + 16 | e.message + 17 | "\n" 18 | ); 19 | }, ""); 20 | console.log(msgs); 21 | } 22 | hasErrors() { 23 | return this.errors.length > 0; 24 | } 25 | /** 26 | * Throws the first error on the list. Used to simplify testing. 27 | */ 28 | throwIfAny() { 29 | if (this.errors.length > 0) { 30 | throw this.errors[0]; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/CodeFile.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | 4 | export default class CodeFile { 5 | constructor(public path: string, public code: string) {} 6 | 7 | get filename() { 8 | return path.basename(this.path); 9 | } 10 | 11 | /** 12 | * Loads an openscad file from the filesystem. 13 | */ 14 | static async load(pathToLoad: string): Promise { 15 | pathToLoad = path.resolve(pathToLoad); // normalize the path 16 | const contents = await new Promise((res, rej) => { 17 | fs.readFile( 18 | pathToLoad, 19 | { 20 | encoding: "utf8", 21 | }, 22 | (err, data) => { 23 | if (err) { 24 | rej(err); 25 | return; 26 | } 27 | res(data); 28 | } 29 | ); 30 | }); 31 | return new CodeFile(pathToLoad, contents); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/build-docs.yml: -------------------------------------------------------------------------------- 1 | name: Build docs 2 | on: [push] 3 | permissions: 4 | contents: write 5 | jobs: 6 | build-and-deploy: 7 | concurrency: ci-${{ github.ref }} # Recommended if you intend to make multiple deployments in quick succession. 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 🛎️ 11 | uses: actions/checkout@v3 12 | 13 | - name: Install and Build 🔧 # This example project is built using npm and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built. 14 | run: | 15 | npm ci 16 | npx typedoc 17 | 18 | - name: Deploy 🚀 19 | uses: JamesIves/github-pages-deploy-action@v4.3.3 20 | with: 21 | branch: gh-pages # The branch the action should deploy to. 22 | folder: docs # The folder the action should deploy. 23 | -------------------------------------------------------------------------------- /src/Token.ts: -------------------------------------------------------------------------------- 1 | import CodeLocation from "./CodeLocation"; 2 | import CodeSpan from "./CodeSpan"; 3 | import { ExtraToken, NewLineExtraToken } from "./extraTokens"; 4 | import TokenType from "./TokenType"; 5 | 6 | export default class Token { 7 | /** 8 | * All the newlines and comments that appear before this token and should be preserved when printing the AST. 9 | */ 10 | public extraTokens: ExtraToken[] = []; 11 | 12 | /** 13 | * Start of this token, including all the whitespace before it. 14 | * 15 | * Set externally in the lexer. 16 | */ 17 | public startWithWhitespace!: CodeLocation; 18 | 19 | constructor( 20 | public type: TokenType, 21 | public span: CodeSpan, 22 | public lexeme: string 23 | ) {} 24 | 25 | toString(): string { 26 | return `token ${TokenType[this.type]} ${this.span.toString()}`; 27 | } 28 | 29 | hasNewlineInExtraTokens() { 30 | return this.extraTokens.some((t) => t instanceof NewLineExtraToken); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/ParsingHelper.ts: -------------------------------------------------------------------------------- 1 | import ScadFile from "./ast/ScadFile"; 2 | import CodeFile from "./CodeFile"; 3 | import ErrorCollector from "./ErrorCollector"; 4 | import Lexer from "./Lexer"; 5 | import Parser from "./Parser"; 6 | import Token from "./Token"; 7 | 8 | export default class ParsingHelper { 9 | static parseFile(f: CodeFile): [ScadFile | null, ErrorCollector] { 10 | const errorCollector = new ErrorCollector(); 11 | const lexer = new Lexer(f, errorCollector); 12 | let tokens: Token[] | undefined; 13 | try { 14 | tokens = lexer.scan(); 15 | } catch (e) {} 16 | if (errorCollector.hasErrors()) { 17 | return [null, errorCollector]; 18 | } 19 | if (!tokens) { 20 | throw new Error("No tokens returned from lexer, and no errors were reported"); 21 | } 22 | const parser = new Parser(f, tokens, errorCollector); 23 | let ast: ScadFile | null = null; 24 | try { 25 | ast = parser.parse(); 26 | } catch (e) {} 27 | return [ast, errorCollector]; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/CodeSpan.ts: -------------------------------------------------------------------------------- 1 | import CodeLocation from "./CodeLocation"; 2 | 3 | export default class CodeSpan { 4 | constructor(public start: CodeLocation, public end: CodeLocation) {} 5 | 6 | toString() { 7 | return `${this.start.toString()} - ${this.end.toString()}`; 8 | } 9 | 10 | static combine(...rawSpans: (CodeSpan | null | undefined)[]) { 11 | let spans = rawSpans.filter((s) => s != null) as CodeSpan[]; 12 | if (spans.length === 0) { 13 | throw new Error("Cannot combine zero spans"); 14 | } 15 | if (spans.length === 1) { 16 | return spans[0]; 17 | } 18 | let min: CodeSpan = spans[0]; 19 | let max: CodeSpan = spans[0]; 20 | for (let span of spans) { 21 | if (span.start.char < min.start.char) { 22 | min = span; 23 | } 24 | if (span.end.char > max.end.char) { 25 | max = span; 26 | } 27 | } 28 | return new CodeSpan(min.start, max.end); 29 | } 30 | 31 | static combineObject(spans: { [key: string]: CodeSpan }) { 32 | return CodeSpan.combine(...Object.values(spans)); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/prelude/PreludeUtil.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "fs"; 2 | import { join } from "path"; 3 | import ScadFile from "../ast/ScadFile"; 4 | import CodeFile from "../CodeFile"; 5 | import ParsingHelper from "../ParsingHelper"; 6 | import ASTScopePopulator from "../semantic/ASTScopePopulator"; 7 | import Scope from "../semantic/Scope"; 8 | 9 | export default class PreludeUtil { 10 | private static _cachedPreludeScope: Scope | null = null; 11 | public static get preludeScope() { 12 | if (!this._cachedPreludeScope) { 13 | const preludeLocation = join(__dirname, "prelude.scad"); 14 | let [ast, ec] = ParsingHelper.parseFile( 15 | new CodeFile(preludeLocation, readFileSync(preludeLocation, "utf8")) 16 | ); 17 | ec.throwIfAny(); 18 | this._cachedPreludeScope = new Scope(); 19 | const pop = new ASTScopePopulator(this._cachedPreludeScope); 20 | if(!ast) { 21 | throw new Error("prelude ast is null"); 22 | } 23 | ast = ast.accept(pop) as ScadFile; 24 | } 25 | 26 | return this._cachedPreludeScope; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/testdata/completion_crash_fix.scad: -------------------------------------------------------------------------------- 1 | pole_diameter = 27.5; 2 | ring_width = 4; 3 | ring_height = 13; 4 | 5 | teeth_spacing = 2; 6 | teeth_size = 0.5; 7 | 8 | gap_width = 4; 9 | 10 | module ring() { 11 | $fn = 100; 12 | union() { 13 | difference() { 14 | cylinder(h = ring_height, d = pole_diameter + 2 * ring_width); 15 | translate([0, 0, -1]) { 16 | cylinder(h = ring_height + 2, d = pole_diameter); 17 | } 18 | } 19 | 20 | for(ang = [0 : 360 / ((PI * pole_diameter) / teeth_spacing) : 355]) { 21 | rotate([0, 0, ang]) 22 | translate([0, pole_diameter / 2, 0]) 23 | // rotate([0, 0, ang]) 24 | rotate([0, 0, 45]) 25 | translate([-teeth_size / 2, -teeth_size / 2]) 26 | cube([teeth_size, teeth_size, ring_height]); 27 | } 28 | } 29 | } 30 | 31 | * difference() { 32 | ring(); 33 | translate([-gap_width / 2, 0, -1]) 34 | cube([gap_width, pole_diameter, ring_height + 2]); 35 | } 36 | 37 | 38 | circle(d ) -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | ===================== 3 | 4 | Copyright © 2020 Albert Koczy 5 | 6 | Permission is hereby granted, free of charge, to any person 7 | obtaining a copy of this software and associated documentation 8 | files (the “Software”), to deal in the Software without 9 | restriction, including without limitation the rights to use, 10 | copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the 12 | Software is furnished to do so, subject to the following 13 | conditions: 14 | 15 | The above copyright notice and this permission notice shall be 16 | included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 22 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 23 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 24 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 25 | OTHER DEALINGS IN THE SOFTWARE. 26 | 27 | -------------------------------------------------------------------------------- /src/semantic/CompletionUtil.ts: -------------------------------------------------------------------------------- 1 | import ASTNode from "../ast/ASTNode"; 2 | import CodeLocation from "../CodeLocation"; 3 | import CompletionProvider from "./CompletionProvider"; 4 | import FilenameCompletionProvider from "./FilenameCompletionProvider"; 5 | import KeywordsCompletionProvider from "./KeywordsCompletionProvider"; 6 | import ScopeSymbolCompletionProvider from "./ScopeSymbolCompletionProvider"; 7 | import CompletionSymbol from "./CompletionSymbol"; 8 | 9 | export default class CompletionUtil { 10 | static completionProviders: CompletionProvider[] = [ 11 | new FilenameCompletionProvider(), 12 | new KeywordsCompletionProvider(), 13 | new ScopeSymbolCompletionProvider(), 14 | ]; 15 | static async getSymbolsAtLocation( 16 | ast: ASTNode, 17 | loc: CodeLocation 18 | ): Promise { 19 | let symbols: CompletionSymbol[] = []; 20 | for (const cp of this.completionProviders) { 21 | if (!cp.textOnly && !ast) continue; 22 | if (cp.shouldActivate(ast, loc)) { 23 | symbols = [...symbols, ...(await cp.getSymbolsAtLocation(ast, loc))]; 24 | if (cp.exclusive) { 25 | break; 26 | } 27 | } 28 | } 29 | return symbols; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/semantic/Scope.test.ts: -------------------------------------------------------------------------------- 1 | import AssignmentNode, { AssignmentNodeRole } from "../ast/AssignmentNode"; 2 | import { LiteralExpr } from "../ast/expressions"; 3 | import Scope from "./Scope"; 4 | 5 | describe("Scope", () => { 6 | it("resolves local variables", () => { 7 | const ass = new AssignmentNode( 8 | "testVar", 9 | new LiteralExpr(20, { literalToken: null as unknown as any }), 10 | AssignmentNodeRole.VARIABLE_DECLARATION, 11 | null as unknown as any 12 | ); 13 | const scope = new Scope(); 14 | scope.variables.set("testVar", ass); 15 | expect(scope.lookupVariable("testVar")).toEqual(ass); 16 | }); 17 | it("resolves variables in parent scopes", () => { 18 | const ass = new AssignmentNode( 19 | "testVar", 20 | new LiteralExpr(20, { literalToken: null as unknown as any }), 21 | AssignmentNodeRole.VARIABLE_DECLARATION, 22 | null as unknown as any 23 | ); 24 | const scope = new Scope(); 25 | scope.variables.set("testVar", ass); 26 | const childScope = new Scope(); 27 | childScope.parent = scope; 28 | expect(childScope.lookupVariable("testVar")).toEqual(ass); 29 | }); 30 | it("returns null when no variable was found", () => { 31 | expect(new Scope().lookupVariable("notFound")).toBeNull(); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/semantic/ASTSymbolLister.test.ts: -------------------------------------------------------------------------------- 1 | import CodeFile from "../CodeFile"; 2 | import ParsingHelper from "../ParsingHelper"; 3 | import ASTSymbolLister, { SymbolKind } from "./ASTSymbolLister"; 4 | 5 | describe("ASTSymbolLister", () => { 6 | it("returns module symbols", () => { 7 | const [ast, e] = ParsingHelper.parseFile( 8 | new CodeFile( 9 | "", 10 | ` 11 | module testMod() {} 12 | ` 13 | ) 14 | ); 15 | e.throwIfAny(); 16 | const symCb = jest.fn(); 17 | const symbols = new ASTSymbolLister( 18 | (name, kind, fullRange, nameRange, children: void[]) => { 19 | symCb(); 20 | expect(kind).toEqual(SymbolKind.MODULE); 21 | } 22 | ).doList(ast!); 23 | expect(symCb).toHaveBeenCalled(); 24 | }); 25 | it("returns variable symbols", () => { 26 | const [ast, e] = ParsingHelper.parseFile( 27 | new CodeFile( 28 | "", 29 | ` 30 | abrakadabra = 10; 31 | ` 32 | ) 33 | ); 34 | e.throwIfAny(); 35 | const symCb = jest.fn(); 36 | const symbols = new ASTSymbolLister( 37 | (name, kind, fullRange, nameRange, children: void[]) => { 38 | symCb(); 39 | expect(kind).toEqual(SymbolKind.VARIABLE); 40 | expect(name).toEqual("abrakadabra"); 41 | } 42 | ).doList(ast!); 43 | expect(symCb).toHaveBeenCalled(); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Node.js CI](https://github.com/alufers/openscad-parser/workflows/Node.js%20CI/badge.svg)](https://github.com/alufers/openscad-parser/actions?query=workflow%3A%22Node.js+CI%22) 2 | [![License](https://img.shields.io/github/license/alufers/openscad-parser)](https://github.com/alufers/openscad-parser/blob/master/LICENSE.md) 3 | [![NPM package](https://badge.fury.io/js/openscad-parser.svg)](https://www.npmjs.com/package/openscad-parser) 4 | 5 | # openscad-parser 6 | 7 | This package facilitates parsing, formatting and validating the OpenSCAD language using TypeScript and JavaScript. 8 | 9 | # Installation (formatter) 10 | 11 | ```sh 12 | $ npm i -g openscad-parser 13 | ``` 14 | 15 | Usage: 16 | ```sh 17 | $ scadfmt # outputs the formatted OpenSCAD code to stdout 18 | ``` 19 | 20 | # Installation (as a node module) 21 | 22 | ```sh 23 | npm install openscad-parser 24 | ``` 25 | 26 | # Features 27 | 28 | - [x] Parsing and full error reporting (reports even better errors than the default OpenSCAD parser) 29 | - [x] Symbol tree generation (VSCode "Outline" view) 30 | - [x] Formatting (fully AST-aware, needs some more work with breaking up large vectors) 31 | - [x] Semantic code completions (provides code completions for VSCode) 32 | - [x] Jump to definition (provides a "Go to definition" context menu item) 33 | 34 | I will soon release a vscode extension with full OpenSCAD support, it just needs some more work. 35 | 36 | # Documentation 37 | 38 | The API documentation is available [here](https://alufers.github.io/openscad-parser/). 39 | 40 | # License 41 | 42 | [MIT](https://github.com/alufers/openscad-parser/blob/master/LICENSE.md) 43 | -------------------------------------------------------------------------------- /src/ast/AssignmentNode.ts: -------------------------------------------------------------------------------- 1 | import CodeLocation from "../CodeLocation"; 2 | import DocComment from "../comments/DocComment"; 3 | import Token from "../Token"; 4 | import ASTNode from "./ASTNode"; 5 | import ASTVisitor from "./ASTVisitor"; 6 | import { Expression } from "./expressions"; 7 | 8 | export enum AssignmentNodeRole { 9 | VARIABLE_DECLARATION, 10 | ARGUMENT_DECLARATION, 11 | ARGUMENT_ASSIGNMENT, 12 | } 13 | 14 | /** 15 | * Represents a value being assigned to a name. Used when declaring and calling modules or functions. 16 | * It is also used in control flow structures such as for loops and let expressions. 17 | * @category AST 18 | */ 19 | export default class AssignmentNode extends ASTNode { 20 | /** 21 | * The name of the value being assigned. 22 | * The name field may be empty when it represents a positional argument in a call. 23 | */ 24 | name: string; 25 | 26 | /** 27 | * THe value of the name being assigned. 28 | * It can be null when the AssignmentNode is used as a function parameter without a default value. 29 | */ 30 | value: Expression | null; 31 | 32 | /** 33 | * The documentation and annotations connected with this variable. 34 | */ 35 | docComment: DocComment | null = null; 36 | 37 | constructor( 38 | name: string, 39 | value: Expression | null, 40 | public role: AssignmentNodeRole, 41 | public tokens: { 42 | name: Token | null; 43 | equals: Token | null; 44 | trailingCommas: Token[] | null; 45 | semicolon: Token | null; 46 | } 47 | ) { 48 | super(); 49 | this.name = name; 50 | this.value = value; 51 | } 52 | accept(visitor: ASTVisitor): R { 53 | return visitor.visitAssignmentNode(this); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/semantic/CompletionUtil.test.ts: -------------------------------------------------------------------------------- 1 | import ScadFile from "../ast/ScadFile"; 2 | import CodeFile from "../CodeFile"; 3 | import CodeLocation from "../CodeLocation"; 4 | import ParsingHelper from "../ParsingHelper"; 5 | import ASTScopePopulator from "./ASTScopePopulator"; 6 | import CompletionUtil from "./CompletionUtil"; 7 | import Scope from "./Scope"; 8 | 9 | describe("CompletionUtil", () => { 10 | async function doComplete(source: string, charOffset: number) { 11 | let [ast, errorCollector] = ParsingHelper.parseFile( 12 | new CodeFile("", source) 13 | ); 14 | errorCollector.throwIfAny(); 15 | ast = new ASTScopePopulator(new Scope()).populate(ast!) as ScadFile; // populating the scopes should not change anything 16 | return await CompletionUtil.getSymbolsAtLocation( 17 | ast, 18 | new CodeLocation(ast.span.start.file, charOffset) 19 | ); 20 | } 21 | it("provides completions in the global scope, at the end of the file", async () => { 22 | const s = ` 23 | the_var = 10; 24 | 25 | `; 26 | const results = await doComplete(s, s.length - 1); 27 | expect(results.length).toBeGreaterThan(0); 28 | expect(results.find((r) => r.name === "the_var")).toBeTruthy(); 29 | }); 30 | 31 | it("does not crash when completing inside an incomplete module instantation", async () => { 32 | let [ast, errorCollector] = ParsingHelper.parseFile( 33 | new CodeFile("", `circle(d )`) 34 | ); 35 | ast = new ASTScopePopulator(new Scope()).populate(ast!) as ScadFile; // populating the scopes should not change anything 36 | expect(async () => { 37 | await CompletionUtil.getSymbolsAtLocation( 38 | ast!, 39 | new CodeLocation(ast!.span.start.file, 9) 40 | ); 41 | }).not.toThrow(); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/semantic/ScopeSymbolCompletionProvider.ts: -------------------------------------------------------------------------------- 1 | import ASTNode from "../ast/ASTNode"; 2 | import ASTPinpointer from "../ASTPinpointer"; 3 | import CodeLocation from "../CodeLocation"; 4 | import CompletionProvider from "./CompletionProvider"; 5 | import CompletionSymbol from "./CompletionSymbol"; 6 | import CompletionType from "./CompletionType"; 7 | import NodeWithScope from "./NodeWithScope"; 8 | import Scope from "./Scope"; 9 | 10 | export default class ScopeSymbolCompletionProvider 11 | implements CompletionProvider 12 | { 13 | textOnly = false; 14 | exclusive = false; 15 | shouldActivate(ast: ASTNode, loc: CodeLocation): boolean { 16 | return true; 17 | } 18 | async getSymbolsAtLocation( 19 | ast: ASTNode, 20 | loc: CodeLocation 21 | ): Promise { 22 | const pp = new ASTPinpointer(loc); 23 | pp.doPinpoint(ast); 24 | let symbols: CompletionSymbol[] = []; 25 | const scopesToShow: Scope[] = []; 26 | for (const h of pp.bottomUpHierarchy) { 27 | const hh: NodeWithScope = h as NodeWithScope; 28 | if ("scope" in hh && hh.scope instanceof Scope) { 29 | scopesToShow.push(hh.scope); 30 | scopesToShow.push(...hh.scope.siblingScopes); 31 | } 32 | } 33 | for (const scope of scopesToShow) { 34 | for (const v of scope.variables) { 35 | symbols.push( 36 | new CompletionSymbol(CompletionType.VARIABLE, v[1].name, v[1]) 37 | ); 38 | } 39 | for (const f of scope.functions) { 40 | symbols.push( 41 | new CompletionSymbol(CompletionType.FUNCTION, f[1].name, f[1]) 42 | ); 43 | } 44 | for (const m of scope.modules) { 45 | symbols.push( 46 | new CompletionSymbol(CompletionType.MODULE, m[1].name, m[1]) 47 | ); 48 | } 49 | } 50 | 51 | return symbols; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/CodeLocation.ts: -------------------------------------------------------------------------------- 1 | import CodeFile from "./CodeFile"; 2 | 3 | /** 4 | * THe number of lines to display when printing the context of the error. 5 | */ 6 | const CONTEXT_LINES_BEFORE = 5; 7 | 8 | export default class CodeLocation { 9 | constructor( 10 | file: CodeFile | null = null, 11 | char: number = 0, 12 | line: number = 0, 13 | col: number = 0 14 | ) { 15 | this.file = file; 16 | this.char = char; 17 | this.line = line; 18 | this.col = col; 19 | } 20 | 21 | /** 22 | * THe file to which this location points. 23 | */ 24 | readonly file: CodeFile | null; 25 | 26 | /** 27 | * The character offset in the file contents. 28 | */ 29 | readonly char: number = 0; 30 | 31 | /** 32 | * The line number of this location. Zero-indexed. 33 | */ 34 | readonly line: number = 0; 35 | 36 | /** 37 | * The column number of this location. Zero-indexed. 38 | */ 39 | readonly col: number = 0; 40 | 41 | toString(): string { 42 | return `file '${this.filename}' line ${ 43 | this.line + 1 44 | } column ${this.col + 1}'`; 45 | } 46 | 47 | formatWithContext() { 48 | if(!this.file) { 49 | throw new Error("No CodeFile associated with this location"); 50 | } 51 | let outStr = `${this.filename}:${this.line + 1}:${this.col}:\n`; 52 | const sourceLines = this.file.code.split("\n"); 53 | const contextStartIndex = Math.max(0, this.line - CONTEXT_LINES_BEFORE); 54 | 55 | const linesToDisplay = sourceLines.slice(contextStartIndex, this.line + 1); 56 | outStr += linesToDisplay.reduce((prev, line, index) => { 57 | return ( 58 | prev + 59 | ` ${(contextStartIndex + index + 1).toString().padStart(3)}| ${line}\n` 60 | ); 61 | }, ""); 62 | outStr += ""; 63 | for (let i = -5; i < this.col; i++) { 64 | outStr += " "; 65 | } 66 | outStr += "^\n"; 67 | return outStr; 68 | } 69 | 70 | private get filename(): string { 71 | return this?.file?.filename || "" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/node 3 | # Edit at https://www.gitignore.io/?templates=node 4 | 5 | ### Node ### 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | *.lcov 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (https://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # TypeScript v1 declaration files 50 | typings/ 51 | 52 | # TypeScript cache 53 | *.tsbuildinfo 54 | 55 | # Optional npm cache directory 56 | .npm 57 | 58 | # Optional eslint cache 59 | .eslintcache 60 | 61 | # Optional REPL history 62 | .node_repl_history 63 | 64 | # Output of 'npm pack' 65 | *.tgz 66 | 67 | # Yarn Integrity file 68 | .yarn-integrity 69 | 70 | # dotenv environment variables file 71 | .env 72 | .env.test 73 | 74 | # parcel-bundler cache (https://parceljs.org/) 75 | .cache 76 | 77 | # next.js build output 78 | .next 79 | 80 | # nuxt.js build output 81 | .nuxt 82 | 83 | # vuepress build output 84 | .vuepress/dist 85 | 86 | # Serverless directories 87 | .serverless/ 88 | 89 | # FuseBox cache 90 | .fusebox/ 91 | 92 | # DynamoDB Local files 93 | .dynamodb/ 94 | 95 | # End of https://www.gitignore.io/api/node 96 | 97 | 98 | .vscode/ 99 | 100 | docs/ 101 | 102 | dist/ 103 | 104 | .rts2_cache_es/ 105 | 106 | .rts2_cache_cjs/ 107 | 108 | .rts2_cache_umd/ -------------------------------------------------------------------------------- /src/keywords.ts: -------------------------------------------------------------------------------- 1 | import TokenType from "./TokenType"; 2 | 3 | /** 4 | * A dictionary which maps keyword string values to their TokenType. 5 | */ 6 | const keywords: { [x: string]: TokenType } = { 7 | true: TokenType.True, 8 | false: TokenType.False, 9 | undef: TokenType.Undef, 10 | module: TokenType.Module, 11 | function: TokenType.Function, 12 | if: TokenType.If, 13 | else: TokenType.Else, 14 | for: TokenType.For, 15 | assert: TokenType.Assert, 16 | each: TokenType.Each, 17 | echo: TokenType.Echo, 18 | use: TokenType.Use, 19 | let: TokenType.Let, 20 | include: TokenType.Include, 21 | }; 22 | 23 | export const keywordDocumentation: { [x: keyof typeof keywords]: string } = { 24 | true: "Represents the boolean value true.", 25 | false: "Represents the boolean value false.", 26 | undef: `Represents the undefined value. 27 | 28 | It's the initial value of a variable that hasn't been assigned a value, and it is often returned as a result by functions or operations that are passed illegal arguments. `, 29 | module: `Starts a module declaration. 30 | 31 | Usage: 32 | 33 | ${"```scad"} 34 | module my_module(arg = "default") { 35 | // module code 36 | } 37 | ${"```"} 38 | `, 39 | function: `Starts a function declaration. 40 | 41 | Usage: 42 | 43 | ${"```scad"} 44 | function my_function (x) = x * x; 45 | 46 | // or for anonymous functions 47 | square = function (x) x * x; 48 | 49 | ${"```"} 50 | `, 51 | if: `Starts an if statement or expression. 52 | 53 | Usage: 54 | 55 | ${"```scad"} 56 | if (x > 0) { 57 | // do something 58 | } 59 | ${"```"} 60 | `, 61 | else: `Marks the beginning of an else block in an if statement. 62 | 63 | Usage: 64 | ${"```scad"} 65 | if (x > 0) { 66 | // if x is positive 67 | } else { 68 | // if x is zero or negative 69 | } 70 | ${"```"} 71 | `, 72 | for: `Starts a for loop. 73 | 74 | Usage: 75 | 76 | ${"```scad"} 77 | for ( i = [0 : 5] ){ 78 | rotate( i * 60, [1, 0, 0]) 79 | translate([0, 10, 0]) 80 | sphere(r = 10); 81 | } 82 | ${"```"} 83 | `, 84 | assert: `Starts an assert statement. 85 | 86 | Usage: 87 | 88 | ${"```scad"} 89 | assert(x > 0, "x is not positive"); 90 | 91 | ${"```"} 92 | `, 93 | }; 94 | 95 | export default keywords; 96 | -------------------------------------------------------------------------------- /src/ast/ASTVisitor.ts: -------------------------------------------------------------------------------- 1 | import AssignmentNode from "./AssignmentNode"; 2 | import ErrorNode from "./ErrorNode"; 3 | import { 4 | AnonymousFunctionExpr, 5 | ArrayLookupExpr, 6 | AssertExpr, 7 | BinaryOpExpr, 8 | EchoExpr, 9 | FunctionCallExpr, 10 | GroupingExpr, 11 | LcEachExpr, 12 | LcForCExpr, 13 | LcForExpr, 14 | LcIfExpr, 15 | LcLetExpr, 16 | LetExpr, 17 | LiteralExpr, 18 | LookupExpr, 19 | MemberLookupExpr, 20 | RangeExpr, 21 | TernaryExpr, 22 | UnaryOpExpr, 23 | VectorExpr, 24 | } from "./expressions"; 25 | import ScadFile from "./ScadFile"; 26 | import { 27 | BlockStmt, 28 | FunctionDeclarationStmt, 29 | IfElseStatement, 30 | IncludeStmt, 31 | ModuleDeclarationStmt, 32 | ModuleInstantiationStmt, 33 | NoopStmt, 34 | UseStmt, 35 | } from "./statements"; 36 | 37 | export default interface ASTVisitor { 38 | visitScadFile(n: ScadFile): R; 39 | visitAssignmentNode(n: AssignmentNode): R; 40 | visitUnaryOpExpr(n: UnaryOpExpr): R; 41 | visitBinaryOpExpr(n: BinaryOpExpr): R; 42 | visitTernaryExpr(n: TernaryExpr): R; 43 | visitArrayLookupExpr(n: ArrayLookupExpr): R; 44 | visitLiteralExpr(n: LiteralExpr): R; 45 | visitRangeExpr(n: RangeExpr): R; 46 | visitVectorExpr(n: VectorExpr): R; 47 | visitLookupExpr(n: LookupExpr): R; 48 | visitMemberLookupExpr(n: MemberLookupExpr): R; 49 | visitFunctionCallExpr(n: FunctionCallExpr): R; 50 | visitLetExpr(n: LetExpr): R; 51 | visitAssertExpr(n: AssertExpr): R; 52 | visitEchoExpr(n: EchoExpr): R; 53 | visitLcIfExpr(n: LcIfExpr): R; 54 | visitLcEachExpr(n: LcEachExpr): R; 55 | visitLcForExpr(n: LcForExpr): R; 56 | visitLcForCExpr(n: LcForCExpr): R; 57 | visitLcLetExpr(n: LcLetExpr): R; 58 | visitGroupingExpr(n: GroupingExpr): R; 59 | visitAnonymousFunctionExpr(n: AnonymousFunctionExpr): R; 60 | visitUseStmt(n: UseStmt): R; 61 | visitIncludeStmt(n: IncludeStmt): R; 62 | visitModuleInstantiationStmt(n: ModuleInstantiationStmt): R; 63 | visitModuleDeclarationStmt(n: ModuleDeclarationStmt): R; 64 | visitFunctionDeclarationStmt(n: FunctionDeclarationStmt): R; 65 | visitBlockStmt(n: BlockStmt): R; 66 | visitNoopStmt(n: NoopStmt): R; 67 | visitIfElseStatement(n: IfElseStatement): R; 68 | visitErrorNode(n: ErrorNode): R; 69 | } 70 | -------------------------------------------------------------------------------- /src/friendlyTokenNames.ts: -------------------------------------------------------------------------------- 1 | import TokenType from "./TokenType"; 2 | 3 | export default { 4 | [TokenType.AND]: "'&&' (AND)", 5 | [TokenType.Assert]: "'assert' (Assert)", 6 | [TokenType.Bang]: "'!' (Bang)", 7 | [TokenType.BangEqual]: "'!=' (BangEqual)", 8 | [TokenType.Colon]: "':' (Colon)", 9 | [TokenType.Comma]: "',' (Comma)", 10 | [TokenType.Dot]: "'.' (Dot)", 11 | [TokenType.Each]: "'each' (Each)", 12 | [TokenType.Echo]: "'echo' (Echo)", 13 | [TokenType.Else]: "'else' (Else)", 14 | [TokenType.Eot]: "end of file (Eot)", 15 | [TokenType.Equal]: "'=' (Equal)", 16 | [TokenType.EqualEqual]: "'==' (EqualEqual)", 17 | [TokenType.Error]: " (Error)", 18 | [TokenType.False]: "'false' (False)", 19 | [TokenType.For]: "'for' (For)", 20 | [TokenType.Function]: "'function' (Function)", 21 | [TokenType.Greater]: "'>' (Greater)", 22 | [TokenType.GreaterEqual]: "'>=' (GreaterEqual)", 23 | [TokenType.Hash]: "'#' (Hash)", 24 | [TokenType.Identifier]: "identifier (Identifier)", 25 | [TokenType.If]: "'if' (If)", 26 | [TokenType.LeftBrace]: "'{' (LeftBrace)", 27 | [TokenType.LeftBracket]: "'[' (LeftBracket)", 28 | [TokenType.LeftParen]: "'(' (LeftParen)", 29 | [TokenType.Less]: "'<' (Less)", 30 | [TokenType.LessEqual]: "'<=' (LessEqual)", 31 | [TokenType.Let]: "'let' (Let)", 32 | [TokenType.Minus]: "'-' (Minus)", 33 | [TokenType.Module]: "'module' (Module)", 34 | [TokenType.NumberLiteral]: "number literal (NumberLiteral)", 35 | [TokenType.OR]: "'||' (OR)", 36 | [TokenType.Percent]: "'%' (Percent)", 37 | [TokenType.Plus]: "'+' (Plus)", 38 | [TokenType.QuestionMark]: "'?' (QuestionMark)", 39 | [TokenType.RightBrace]: "'}' (RightBrace)", 40 | [TokenType.RightBracket]: "']' (RightBracket)", 41 | [TokenType.RightParen]: "')' (RightParen)", 42 | [TokenType.Semicolon]: "';' (Semicolon)", 43 | [TokenType.Slash]: "'/' (Slash)", 44 | [TokenType.Star]: "'*' (Star)", 45 | [TokenType.Caret]: "'^' (Caret)", 46 | [TokenType.StringLiteral]: "string literal (StringLiteral)", 47 | [TokenType.True]: "'true' (True)", 48 | [TokenType.Undef]: "'undef' (Undef)", 49 | [TokenType.Use]: "'use' (Use)", 50 | [TokenType.FilenameInChevrons]: "filename (FilenameInChevrons)", 51 | [TokenType.Include]: "'include' (Include)", 52 | }; 53 | -------------------------------------------------------------------------------- /src/semantic/Scope.ts: -------------------------------------------------------------------------------- 1 | import AssignmentNode from "../ast/AssignmentNode"; 2 | import { 3 | FunctionDeclarationStmt, 4 | ModuleDeclarationStmt, 5 | } from "../ast/statements"; 6 | 7 | export type KeysOfType = { 8 | [P in keyof T]: T[P] extends TProp ? P : never; 9 | }[keyof T]; 10 | 11 | /** 12 | * Represents a lexical scope, where variables, modules, and functions are resolved. 13 | * It links symbol names with their declarations. 14 | */ 15 | export default class Scope { 16 | /** 17 | * References to other, 'include'd or 'use'd file scopes, filled by the solution manager. 18 | * We can use those scopes to resolve types from those files. 19 | */ 20 | siblingScopes: Scope[] = []; 21 | parent: Scope | null = null; 22 | functions = new Map(); 23 | variables = new Map(); 24 | modules = new Map(); 25 | 26 | copy(): Scope { 27 | const s = new Scope(); 28 | s.siblingScopes = [...this.siblingScopes]; 29 | s.functions = this.functions; 30 | s.variables = this.variables; 31 | s.modules = this.modules; 32 | return s; 33 | } 34 | 35 | lookupVariable(name: string) { 36 | return this.lookup("variables", name) as AssignmentNode; 37 | } 38 | 39 | lookupModule(name: string) { 40 | return this.lookup("modules", name) as ModuleDeclarationStmt; 41 | } 42 | 43 | lookupFunction(name: string) { 44 | return this.lookup("functions", name) as FunctionDeclarationStmt; 45 | } 46 | 47 | private lookup( 48 | x: KeysOfType>, 49 | name: string, 50 | visited: WeakMap = new WeakMap() 51 | ): FunctionDeclarationStmt | AssignmentNode | ModuleDeclarationStmt | null { 52 | if (visited.has(this)) { 53 | return null; 54 | } 55 | visited.set(this, true); 56 | if (this[x].has(name)) { 57 | return this[x].get(name) || null; 58 | } 59 | if (this.parent) { 60 | const val = this.parent.lookup(x, name, visited); 61 | if (val) { 62 | return val; 63 | } 64 | } 65 | for (const ss of this.siblingScopes) { 66 | const val = ss.lookup(x, name, visited); 67 | if (val) { 68 | return val; 69 | } 70 | } 71 | return null; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/errors/lexingErrors.ts: -------------------------------------------------------------------------------- 1 | import CodeLocation from "../CodeLocation"; 2 | import LexingError from "./LexingError"; 3 | 4 | /** 5 | * @category Error 6 | */ 7 | export class UnterminatedMultilineCommentLexingError extends LexingError { 8 | constructor(pos: CodeLocation) { 9 | super(pos, `Unterminated multiline comment.`); 10 | } 11 | } 12 | 13 | /** 14 | * @category Error 15 | */ 16 | export class SingleCharacterNotAllowedLexingError extends LexingError { 17 | constructor(pos: CodeLocation, char: string) { 18 | super(pos, `Single '${char}' is not allowed.`); 19 | } 20 | } 21 | 22 | /** 23 | * @category Error 24 | */ 25 | export class UnexpectedCharacterLexingError extends LexingError { 26 | constructor(pos: CodeLocation, char: string) { 27 | super(pos, `Unexpected character '${char}'.`); 28 | } 29 | } 30 | 31 | /** 32 | * @category Error 33 | */ 34 | export class IllegalStringEscapeSequenceLexingError extends LexingError { 35 | constructor(pos: CodeLocation, sequence: string) { 36 | super(pos, `Illegal string escape sequence '${sequence}'.`); 37 | } 38 | } 39 | 40 | /** 41 | * @category Error 42 | */ 43 | export class UnterminatedStringLiteralLexingError extends LexingError { 44 | constructor(pos: CodeLocation) { 45 | super(pos, `Unterminated string literal.`); 46 | } 47 | } 48 | 49 | /** 50 | * @category Error 51 | */ 52 | export class TooManyDotsInNumberLiteralLexingError extends LexingError { 53 | constructor(pos: CodeLocation, lexeme: string) { 54 | super( 55 | pos, 56 | `Too many dots in number literal ${lexeme}. Number literals must contain zero or one dot.` 57 | ); 58 | } 59 | } 60 | 61 | /** 62 | * @category Error 63 | */ 64 | export class TooManyEInNumberLiteralLexingError extends LexingError { 65 | constructor(pos: CodeLocation, lexeme: string) { 66 | super( 67 | pos, 68 | `Too many 'e' separators in number literal ${lexeme}. Number literals must contain zero or one 'e'.` 69 | ); 70 | } 71 | } 72 | 73 | /** 74 | * @category Error 75 | */ 76 | export class InvalidNumberLiteralLexingError extends LexingError { 77 | constructor(pos: CodeLocation, lexeme: string) { 78 | super(pos, `Invalid number literal ${lexeme}.`); 79 | } 80 | } 81 | 82 | /** 83 | * @category Error 84 | */ 85 | export class UnterminatedFilenameLexingError extends LexingError { 86 | constructor(pos: CodeLocation) { 87 | super(pos, `Unterminated filename.`); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/comments/annotations.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Adds special flags for built in constructs in the language. 3 | * Only for use in the prelude. 4 | * Used to mark the `for` and `intersection_for` modules as loops, and their arguments are in fact variable declarations. 5 | */ 6 | export class IntrinsicAnnotation { 7 | static annotationTag = "intrinsic"; 8 | intrinsicType: string; 9 | constructor(contents: string[]) { 10 | this.intrinsicType = contents[0] || ""; 11 | } 12 | } 13 | 14 | /** 15 | * Renames this symbol to a diffrent name (which for example is a reserved keyword). 16 | * Used by the prelude to define `for` and `intersection_for` so that they can be resolved without errors. 17 | */ 18 | export class IntrinsicRenameAnnotation { 19 | static annotationTag = "intrinsicRename"; 20 | newName: string; 21 | constructor(contents: string[]) { 22 | this.newName = contents[0] || ""; 23 | } 24 | } 25 | 26 | /** 27 | * An annotation with a link to online documentation. 28 | * @todo Add links to other source-code locations 29 | */ 30 | export class SeeAnnotation { 31 | static annotationTag = "see"; 32 | link: string; 33 | constructor(contents: string[]) { 34 | this.link = contents[0] || ""; 35 | } 36 | } 37 | 38 | /** 39 | * Describes a module or function parameter annotation. 40 | * It has the form of `@param name [... optional tags] description` 41 | * The tags either contain a name (`[positional]`) for binary tags or a name and a value (`[conflictsWith=abc,cba]`) 42 | */ 43 | export class ParamAnnotation { 44 | static annotationTag = "param"; 45 | link: string; 46 | description: string; 47 | tags: { 48 | [x: string]: any; 49 | positional: boolean; 50 | named: boolean; 51 | required: boolean; 52 | type: string[]; 53 | conflictsWith: string[]; 54 | possibleValues: string[]; 55 | } = { 56 | positional: false, 57 | named: false, 58 | required: false, 59 | type: [], 60 | conflictsWith: [], 61 | possibleValues: [], 62 | }; 63 | constructor(contents: string[]) { 64 | this.link = contents[0] || ""; 65 | this.description = contents 66 | .slice(1) 67 | .filter((c) => { 68 | let m = c.match(/^\[(.*?)(=(.*))?\]$/); 69 | if (!m) return true; 70 | if (!m[3]) { 71 | // boolean tag, no value 72 | this.tags[m[1]] = true; 73 | } else { 74 | this.tags[m[1]] = m[3].split(","); 75 | } 76 | return false; 77 | }) 78 | .join(" "); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/comments/DocComment.test.ts: -------------------------------------------------------------------------------- 1 | import ScadFile from "../ast/ScadFile"; 2 | import { ModuleDeclarationStmt } from "../ast/statements"; 3 | import CodeFile from "../CodeFile"; 4 | import ParsingHelper from "../ParsingHelper"; 5 | import { IntrinsicAnnotation } from "./annotations"; 6 | import DocComment from "./DocComment"; 7 | 8 | describe("DocComment", () => { 9 | function doParse(source: string):ScadFile { 10 | const [ast, errorCollector] = ParsingHelper.parseFile( 11 | new CodeFile("", source) 12 | ); 13 | errorCollector.throwIfAny(); 14 | return ast!; 15 | } 16 | it("parses a simple documentation comment", () => { 17 | const ast = doParse(` 18 | /** 19 | * Hello 20 | * I am a documentation comment. 21 | **/ 22 | module asdf() { 23 | 24 | } 25 | `); 26 | const dc = DocComment.fromExtraTokens( 27 | (ast.statements[0] as ModuleDeclarationStmt).tokens.moduleKeyword 28 | .extraTokens 29 | ); 30 | expect(dc.documentationContent).toEqual( 31 | "Hello\nI am a documentation comment." 32 | ); 33 | }); 34 | it("parses a simple documentation comment with an annotation", () => { 35 | const ast = doParse(` 36 | /** 37 | * Hello 38 | * I am a documentation comment. 39 | * @intrinsic controlFlow 40 | **/ 41 | module asdf() { 42 | 43 | } 44 | `); 45 | const dc = DocComment.fromExtraTokens( 46 | (ast.statements[0] as ModuleDeclarationStmt).tokens.moduleKeyword 47 | .extraTokens 48 | ); 49 | expect(dc.documentationContent).toEqual( 50 | "Hello\nI am a documentation comment." 51 | ); 52 | expect(dc.annotations).toHaveLength(1); 53 | expect(dc.annotations[0]).toBeInstanceOf(IntrinsicAnnotation); 54 | expect((dc.annotations[0] as IntrinsicAnnotation).intrinsicType).toEqual( 55 | "controlFlow" 56 | ); 57 | }); 58 | it("does not leave param annotations in the doc description", () => { 59 | const ast = doParse(` 60 | /** 61 | * Hello 62 | * I am a documentation comment. 63 | * @param d [named] [conflictsWith=r] [type=number] the diameter of the circle 64 | **/ 65 | module asdf() { 66 | 67 | } 68 | `); 69 | const dc = DocComment.fromExtraTokens( 70 | (ast.statements[0] as ModuleDeclarationStmt).tokens.moduleKeyword 71 | .extraTokens 72 | ); 73 | expect(dc.documentationContent).not.toContain("@param"); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/semantic/FilenameCompletionProvider.test.ts: -------------------------------------------------------------------------------- 1 | import CodeFile from "../CodeFile"; 2 | import FilenameCompletionProvider from "./FilenameCompletionProvider"; 3 | import ParsingHelper from "../ParsingHelper"; 4 | import CodeLocation from "../CodeLocation"; 5 | import { off } from "process"; 6 | import * as mockFs from "mock-fs"; 7 | 8 | describe("FilenameCompletionProvider.test", () => { 9 | beforeEach(() => { 10 | mockFs({ 11 | "/test/ddd": "blah" 12 | }); 13 | }); 14 | afterEach(() => { 15 | mockFs.restore(); 16 | }); 17 | describe("shouldActivate", () => { 18 | const isInFilename = (code: string, offset: number) => { 19 | const cf = new CodeFile("/test/ddd", code); 20 | const [ast, ec] = ParsingHelper.parseFile(cf); 21 | ec.throwIfAny(); 22 | if(!ast) { 23 | throw new Error("no ast"); 24 | } 25 | const fcp = new FilenameCompletionProvider(); 26 | return fcp.shouldActivate(ast, new CodeLocation(cf, offset, 0, 0)); 27 | }; 28 | it("does not activate when not inside of a filename", () => { 29 | expect(isInFilename(`include `, 8)).toBeFalsy(); 30 | expect(isInFilename(`include `, 28)).toBeFalsy(); 31 | }); 32 | it("does activate when inside of a filename", () => { 33 | for (let i = 9; i < 28; i++) { 34 | expect(isInFilename(`include `, i)).toBeTruthy(); 35 | } 36 | for (let i = 5; i < 23; i++) { 37 | expect(isInFilename(`use `, i)).toBeTruthy(); 38 | } 39 | }); 40 | }); 41 | 42 | it("does list files when provided an absolute path", async () => { 43 | const cf = new CodeFile("/test/ddd", "use { 52 | const cf = new CodeFile("/test/ddd", "include "); 53 | const [ast, ec] = ParsingHelper.parseFile(cf); 54 | 55 | const fcp = new FilenameCompletionProvider(); 56 | expect( 57 | await fcp.getExistingPath(ast!, new CodeLocation(cf, 26, 0, 0)) 58 | ).toEqual("MCAD/hardware.scad"); 59 | expect( 60 | await fcp.getExistingPath(ast!, new CodeLocation(cf, 27, 0, 0)) 61 | ).toEqual("MCAD/hardware.scad"); 62 | expect( 63 | await fcp.getExistingPath(ast!, new CodeLocation(cf, 25, 0, 0)) 64 | ).toEqual("MCAD/hardware.sca"); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/comments/DocComment.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExtraToken, 3 | MultiLineComment, 4 | NewLineExtraToken, 5 | SingleLineComment, 6 | } from "../extraTokens"; 7 | import { 8 | IntrinsicAnnotation, 9 | IntrinsicRenameAnnotation, 10 | ParamAnnotation, 11 | SeeAnnotation, 12 | } from "./annotations"; 13 | import DocAnnotationClass from "./DocAnnotationClass"; 14 | 15 | export default class DocComment { 16 | static possibleAnnotations: DocAnnotationClass[] = [ 17 | IntrinsicAnnotation, 18 | IntrinsicRenameAnnotation, 19 | ParamAnnotation, 20 | SeeAnnotation, 21 | ]; 22 | constructor( 23 | public documentationContent: string, 24 | public annotations: Object[] 25 | ) {} 26 | static fromExtraTokens(extraTokens: ExtraToken[]): DocComment { 27 | const docComments: (MultiLineComment | SingleLineComment)[] = []; 28 | let beginningNewlinesLimit = 5; 29 | // iterate through the extra tokens backwards, looking from the annotated element 30 | for (let i = extraTokens.length - 1; i >= 0; i--) { 31 | if (extraTokens[i] instanceof NewLineExtraToken) { 32 | beginningNewlinesLimit--; 33 | } 34 | if ( 35 | extraTokens[i] instanceof MultiLineComment || 36 | extraTokens[i] instanceof SingleLineComment 37 | ) { 38 | beginningNewlinesLimit = 2; 39 | docComments.unshift( 40 | extraTokens[i] as MultiLineComment | SingleLineComment 41 | ); 42 | } 43 | if (beginningNewlinesLimit <= 0) { 44 | break; 45 | } 46 | } 47 | // we assemble the comments into one string, and remove the preceding stars 48 | const lines = docComments 49 | .map((c) => c.contents) 50 | .flatMap((c) => c.split("\n")) 51 | .map((l) => l.trim().replace(/^\*/, "").trim()); 52 | let contents = ""; 53 | let annotations: Object[] = []; 54 | // we loop over every line of the preceeding comment to find the documentation contents and the annotations 55 | // for each line we check if it stats with a @ (annotation) 56 | for (const line of lines) { 57 | if (line.startsWith("@")) { 58 | // this is an annotation 59 | const segments = line.substring(1).split(" "); 60 | let foundAnnotation = false; 61 | for (const possible of this.possibleAnnotations) { 62 | if (possible.annotationTag === segments[0]) { 63 | annotations.push(new possible(segments.slice(1))); 64 | foundAnnotation = true; 65 | break; 66 | } 67 | } 68 | if (foundAnnotation) { 69 | continue; 70 | } 71 | } 72 | 73 | contents += line + "\n"; 74 | } 75 | contents = contents.trim(); 76 | return new DocComment(contents, annotations); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/semantic/ASTScopePopulator.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import ASTNode from "../ast/ASTNode"; 3 | import { ModuleInstantiationStmt } from "../ast/statements"; 4 | import ASTAssembler from "../ASTAssembler"; 5 | import CodeFile from "../CodeFile"; 6 | import ParsingHelper from "../ParsingHelper"; 7 | import Token from "../Token"; 8 | import ASTScopePopulator from "./ASTScopePopulator"; 9 | import { LcForExprWithScope } from "./nodesWithScopes"; 10 | import Scope from "./Scope"; 11 | 12 | describe("ASTScopePopulator", () => { 13 | it("does not crash when populating code containing range expressions", () => { 14 | const [ast, ec] = ParsingHelper.parseFile( 15 | new CodeFile("", "x = [10:20];") 16 | ); 17 | ec.throwIfAny(); 18 | const pop = new ASTScopePopulator(new Scope()); 19 | ast!.accept(pop); 20 | }); 21 | it("populates the scope with prelude functions", async () => { 22 | const [ast, ec] = ParsingHelper.parseFile( 23 | await CodeFile.load(path.join(__dirname, "../prelude/prelude.scad")) 24 | ); 25 | ec.throwIfAny(); 26 | const scope = new Scope(); 27 | const pop = new ASTScopePopulator(scope); 28 | ast!.accept(pop); 29 | }); 30 | function checkIfExistsInTree( 31 | source: string, 32 | Ctor: { new (...args: any[]): T }, 33 | chkFunc?: (elem: T) => void 34 | ) { 35 | const [ast, ec] = ParsingHelper.parseFile(new CodeFile("", source)); 36 | ec.throwIfAny(); 37 | const pop = new ASTScopePopulator(new Scope()); 38 | const populated = ast!.accept(pop); 39 | const ok = jest.fn(); 40 | class Tmp extends ASTAssembler { 41 | protected processAssembledNode( 42 | t: (Token | (() => void))[], 43 | self: ASTNode 44 | ): void { 45 | if (self instanceof Ctor) { 46 | if (chkFunc) { 47 | chkFunc(self); 48 | } 49 | ok(); 50 | } 51 | for (const a of t) { 52 | if (typeof a === "function") { 53 | a(); 54 | } 55 | } 56 | } 57 | } 58 | 59 | populated.accept(new Tmp()); 60 | expect(ok).toHaveBeenCalled(); 61 | } 62 | 63 | it("creates LcForExprWithScope nodes", () => { 64 | checkIfExistsInTree(`x = [for(xD = [2,5]) xD];`, LcForExprWithScope); 65 | }); 66 | it("creates LcForExprWithScope nodes for LcFor's without variable names", () => { 67 | checkIfExistsInTree(`x = [for([2,5]) 1];`, LcForExprWithScope); 68 | }); 69 | it("preserves tags in module instantations", () => { 70 | checkIfExistsInTree(`% * theMod();`, ModuleInstantiationStmt, (mod) => { 71 | expect(mod.tagBackground).toBeTruthy(); 72 | expect(mod.tagDisabled).toBeTruthy(); 73 | expect(mod.tagRoot).toBeFalsy(); 74 | expect(mod.tagHighlight).toBeFalsy(); 75 | }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /src/scadfmt.ts: -------------------------------------------------------------------------------- 1 | #!/bin/env node 2 | 3 | import ASTPrinter from "./ASTPrinter"; 4 | import CodeFile from "./CodeFile"; 5 | import FormattingConfiguration from "./FormattingConfiguration"; 6 | import ParsingHelper from "./ParsingHelper"; 7 | import * as fs from "fs/promises"; 8 | 9 | class CliFlag { 10 | constructor( 11 | public name: string, 12 | public aliases: string[], 13 | public description: string, 14 | public hasValue: boolean = false 15 | ) {} 16 | } 17 | 18 | const cliFlags: CliFlag[] = [ 19 | new CliFlag("--help", ["-h"], "Prints this help message"), 20 | new CliFlag("--version", ["-v"], "Prints the version"), 21 | new CliFlag("--write", ["-w"], "Formats the file in-place"), 22 | ]; 23 | 24 | async function run() { 25 | const flags: { [key: string]: string | boolean } = {}; 26 | const rest: string[] = []; 27 | for (let i = 2; i < process.argv.length; i++) { 28 | const arg = process.argv[i]; 29 | const flag = cliFlags.find( 30 | (f) => f.name === arg || f.aliases.includes(arg) 31 | ); 32 | if (flag) { 33 | flags[flag.name] = flag.hasValue ? process.argv[i + 1] : true; 34 | if (flag.hasValue) { 35 | i++; 36 | } 37 | } else { 38 | rest.push(arg); 39 | } 40 | } 41 | let shouldPrintHelp = rest.length === 0 || flags["--help"]; 42 | if (shouldPrintHelp) { 43 | let helpMsg = `Usage: scadfmt [options] [files...] 44 | By default, scadfmt will format the provided files and output them to stdout. Use -w to overwrite them in-place. 45 | 46 | Options:`; 47 | for (const flag of cliFlags) { 48 | helpMsg += `\n ${flag.name}${ 49 | flag.hasValue ? " " : "" 50 | }, ${flag.aliases.join(", ")}${flag.hasValue ? " " : ""} - ${ 51 | flag.description 52 | }`; 53 | } 54 | helpMsg += ` 55 | 56 | scadfmt is a part of openscad-parser. https://github.com/alufers/openscad-parser`; 57 | console.error(helpMsg); 58 | process.exit(0); 59 | } 60 | let formattedOutputs: { [key: string]: string } = {}; 61 | for (let filename of rest) { 62 | const file = await CodeFile.load(filename); 63 | const [ast, errorCollector] = ParsingHelper.parseFile(file); 64 | if (errorCollector.hasErrors()) { 65 | errorCollector.printErrors(); 66 | process.exit(1); 67 | } 68 | if (!ast) { 69 | throw new Error("No AST"); 70 | } 71 | if (flags["--write"]) { 72 | formattedOutputs[filename] = new ASTPrinter( 73 | new FormattingConfiguration() 74 | ).visitScadFile(ast); 75 | } else { 76 | console.log( 77 | new ASTPrinter(new FormattingConfiguration()).visitScadFile(ast) 78 | ); 79 | } 80 | } 81 | 82 | if (flags["--write"]) { 83 | for (let filename in formattedOutputs) { 84 | await fs.writeFile(filename, formattedOutputs[filename]); 85 | } 86 | } 87 | } 88 | run().catch(console.error); 89 | -------------------------------------------------------------------------------- /src/TokenType.ts: -------------------------------------------------------------------------------- 1 | enum TokenType { 2 | Error, 3 | /** 4 | * Eot is always pushed as the last token and used by the parser to detect the endo of the file. 5 | */ 6 | Eot, 7 | /** 8 | * The module keyword. 9 | */ 10 | Module, 11 | /** 12 | * The function keyword. 13 | */ 14 | Function, 15 | /** 16 | * The if keyword. 17 | */ 18 | If, 19 | /** 20 | * The else keyword. 21 | */ 22 | Else, 23 | /** 24 | * The for keyword. 25 | */ 26 | For, 27 | /** 28 | * The let keyword. 29 | */ 30 | Let, 31 | /** 32 | * The assert keyword. 33 | */ 34 | Assert, 35 | /** 36 | * The echo keyword. 37 | */ 38 | Echo, 39 | /** 40 | * The each keyword. 41 | */ 42 | Each, 43 | /** 44 | * The use keyword. 45 | */ 46 | Use, 47 | /** 48 | * An identifier, represents a function, module or variable name 49 | */ 50 | Identifier, 51 | /** 52 | * A string literal (e.g. quoted color names) 53 | */ 54 | StringLiteral, 55 | /** 56 | * A number literal. 57 | */ 58 | NumberLiteral, 59 | 60 | /** 61 | * The true keyword. 62 | */ 63 | True, 64 | /** 65 | * The false keyword. 66 | */ 67 | False, 68 | /** 69 | * The undef keyword. 70 | */ 71 | Undef, 72 | 73 | /** 74 | * ! 75 | */ 76 | Bang, 77 | /** 78 | * < 79 | */ 80 | Less, 81 | /** 82 | * > 83 | */ 84 | Greater, 85 | /** 86 | * <= 87 | */ 88 | LessEqual, 89 | /** 90 | * >= 91 | */ 92 | GreaterEqual, 93 | /** 94 | * == 95 | */ 96 | EqualEqual, 97 | /** 98 | * = 99 | */ 100 | Equal, 101 | /** 102 | * != 103 | */ 104 | BangEqual, 105 | /** 106 | * && 107 | */ 108 | AND, 109 | /** 110 | * || 111 | */ 112 | OR, 113 | 114 | Plus, 115 | Minus, 116 | Star, 117 | Slash, 118 | Percent, 119 | Caret, 120 | 121 | /** 122 | * Left parenthesis: ( 123 | */ 124 | LeftParen, 125 | /** 126 | * Right parenthesis: ) 127 | */ 128 | RightParen, 129 | /** 130 | * Left bracket: [ 131 | */ 132 | LeftBracket, 133 | /** 134 | * Right bracket: ] 135 | */ 136 | RightBracket, 137 | /** 138 | * Left brace: { 139 | */ 140 | LeftBrace, 141 | /** 142 | * Right brace: } 143 | */ 144 | RightBrace, 145 | /** 146 | * ; 147 | */ 148 | Semicolon, 149 | /** 150 | * , 151 | */ 152 | Comma, 153 | /** 154 | * . 155 | */ 156 | Dot, 157 | 158 | /** 159 | * The ? symbol 160 | */ 161 | QuestionMark, 162 | 163 | /** 164 | * The : symbol 165 | */ 166 | Colon, 167 | 168 | /** 169 | * The '#' symbol 170 | */ 171 | Hash, 172 | 173 | /** 174 | * The filename of an imported file e.g. '' 175 | */ 176 | FilenameInChevrons, 177 | 178 | /** 179 | * The include keyword. 180 | */ 181 | Include, 182 | } 183 | 184 | export default TokenType; 185 | -------------------------------------------------------------------------------- /src/semantic/ASTSymbolLister.ts: -------------------------------------------------------------------------------- 1 | import AssignmentNode, { AssignmentNodeRole } from "../ast/AssignmentNode"; 2 | import ASTNode from "../ast/ASTNode"; 3 | import { 4 | FunctionDeclarationStmt, 5 | ModuleDeclarationStmt, 6 | } from "../ast/statements"; 7 | import ASTAssembler from "../ASTAssembler"; 8 | import CodeSpan from "../CodeSpan"; 9 | import LiteralToken from "../LiteralToken"; 10 | import Token from "../Token"; 11 | 12 | export enum SymbolKind { 13 | MODULE, 14 | FUNCTION, 15 | VARIABLE, 16 | } 17 | 18 | /** 19 | * Generates a symbol tree for the outline view in vscode. 20 | * It uses AST assembler to walk down the tree and determine the full range of a symbol. 21 | */ 22 | export default class ASTSymbolLister extends ASTAssembler { 23 | constructor( 24 | public makeSymbol: ( 25 | name: string, 26 | kind: SymbolKind, 27 | fullRange: CodeSpan, 28 | nameRange: CodeSpan, 29 | children: SymType[] 30 | ) => SymType 31 | ) { 32 | super(); 33 | } 34 | 35 | /** 36 | * Returns the node at pinpointLocation and populates bottomUpHierarchy. 37 | * @param n The AST (or AST fragment) to search through. 38 | */ 39 | doList(n: ASTNode): SymType[] { 40 | n.accept(this); 41 | return this.symbolsAtCurrentDepth; 42 | } 43 | 44 | private symbolsAtCurrentDepth: SymType[] = []; 45 | 46 | protected processAssembledNode( 47 | t: (Token | (() => Token[]))[], 48 | self: ASTNode 49 | ): Token[] { 50 | let currKind: SymbolKind | null = null; 51 | let currName: LiteralToken | null = null; 52 | if (self instanceof FunctionDeclarationStmt) { 53 | currKind = SymbolKind.FUNCTION; 54 | currName = self.tokens.name as LiteralToken; 55 | } else if (self instanceof ModuleDeclarationStmt) { 56 | currKind = SymbolKind.MODULE; 57 | currName = self.tokens.name as LiteralToken; 58 | } else if ( 59 | self instanceof AssignmentNode && 60 | self.role === AssignmentNodeRole.VARIABLE_DECLARATION 61 | ) { 62 | currKind = SymbolKind.VARIABLE; 63 | currName = self.tokens.name as LiteralToken; 64 | } 65 | const newArr: Token[] = []; 66 | for (const m of t) { 67 | if (typeof m === "function") { 68 | newArr.push(...m()); 69 | } else { 70 | newArr.push(m); 71 | } 72 | } 73 | if (currKind != null && currName != null) { 74 | let savedSymbols = this.symbolsAtCurrentDepth; 75 | this.symbolsAtCurrentDepth = []; 76 | 77 | const childrenSymbols = this.symbolsAtCurrentDepth; 78 | this.symbolsAtCurrentDepth = savedSymbols; // restore the symbols 79 | this.symbolsAtCurrentDepth.push( 80 | this.makeSymbol( 81 | currName.value, 82 | currKind, 83 | CodeSpan.combine(...newArr.map((t) => t.span)), 84 | currName.span, 85 | childrenSymbols 86 | ) 87 | ); 88 | return newArr; 89 | } else { 90 | return newArr; 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/ASTPinpointer.ts: -------------------------------------------------------------------------------- 1 | import ASTNode from "./ast/ASTNode"; 2 | import ASTVisitor from "./ast/ASTVisitor"; 3 | import ASTAssembler from "./ASTAssembler"; 4 | import CodeLocation from "./CodeLocation"; 5 | import Token from "./Token"; 6 | 7 | export const BinAfter = Symbol("BinAfter"); 8 | export const BinBefore = Symbol("BinBefore"); 9 | 10 | export type PinpointerRet = ASTNode | typeof BinAfter | typeof BinBefore; 11 | 12 | export type DispatchTokenMix = (Token | (() => PinpointerRet))[]; 13 | 14 | /** 15 | * This class searches through the AST to find a node based on its position. 16 | * It may return BinAfter or BinBefore if the node cannot be found. 17 | */ 18 | export default class ASTPinpointer 19 | extends ASTAssembler 20 | implements ASTVisitor 21 | { 22 | /** 23 | * Contains all the ancestors of the pinpointed nodes. The pinpointed node is always first. 24 | */ 25 | public bottomUpHierarchy: ASTNode[] = []; 26 | 27 | constructor(public pinpointLocation: CodeLocation) { 28 | super(); 29 | } 30 | 31 | /** 32 | * Returns the node at pinpointLocation and populates bottomUpHierarchy. 33 | * @param n The AST (or AST fragment) to search through. 34 | */ 35 | doPinpoint(n: ASTNode): PinpointerRet { 36 | this.bottomUpHierarchy = []; 37 | return n.accept(this); 38 | } 39 | protected processAssembledNode( 40 | t: DispatchTokenMix, 41 | self: ASTNode 42 | ): PinpointerRet { 43 | let l = 0, 44 | r = t.length - 1; 45 | // perform a binary search on the tokens 46 | while (l <= r) { 47 | let pivot = Math.floor((r + l) / 2); 48 | if (t[pivot] instanceof Token) { 49 | const tokenAtPiviot = t[pivot] as Token; 50 | if (tokenAtPiviot.span.end.char <= this.pinpointLocation.char) { 51 | l = pivot + 1; 52 | continue; 53 | } 54 | if ( 55 | tokenAtPiviot.startWithWhitespace.char > this.pinpointLocation.char 56 | ) { 57 | r = pivot - 1; 58 | continue; 59 | } 60 | this.bottomUpHierarchy.push(self); 61 | return self; // yay this is us 62 | } else if (typeof t[pivot] === "function") { 63 | const astFunc = t[pivot] as () => PinpointerRet; 64 | const result = astFunc.call(this) as PinpointerRet; 65 | 66 | if (result === BinBefore) { 67 | r = pivot - 1; 68 | continue; 69 | } 70 | if (result === BinAfter) { 71 | l = pivot + 1; 72 | continue; 73 | } 74 | if (result instanceof ASTNode) { 75 | this.bottomUpHierarchy.push(self); 76 | return result; 77 | } 78 | } else { 79 | throw new Error( 80 | `Bad element in token mix: ${typeof t[pivot]} at index ${pivot}.` 81 | ); 82 | } 83 | } 84 | const firstThing = t[0]; 85 | if (firstThing instanceof Token) { 86 | if (firstThing.span.end.char <= this.pinpointLocation.char) { 87 | return BinAfter; 88 | } 89 | return BinBefore; 90 | } 91 | if (typeof firstThing === "function") { 92 | return firstThing.call(this); 93 | } 94 | throw new Error( 95 | `Bad element in first token mix element. Recieved ${firstThing}, expected a function or a Token.` 96 | ); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/testdata/resolver_solution_manager_test.scad: -------------------------------------------------------------------------------- 1 | // All ModuleInstantiationStmt should be changed to ResolvedModuleInstantiationStmt in this file 2 | 3 | 4 | REMOTE_DIAM = 40; 5 | 6 | /** 7 | * mmddd fddff 8 | **/ 9 | enclosure_margin = 2.5; 10 | // mm 11 | wall_thickness = 0.5; 12 | pcb_thickness = 1; 13 | bat_thickness = 5; 14 | pcb_components_thickness = 7; 15 | 16 | screw_hole_distance = 37 - 4.5; 17 | scre_hole_diam = 2.5; 18 | 19 | ledge_thickness = 1; 20 | 21 | enclosure_height = pcb_thickness + bat_thickness + wall_thickness + pcb_components_thickness + ledge_thickness; 22 | 23 | bevel_radius = 1; 24 | 25 | enclosure_screw_diam = 3.15; 26 | 27 | 28 | /** 29 | * Creates a cube, centered only on the XY axes. 30 | **/ 31 | module centercube_xy(size) { 32 | translate([0, 0, size.z / 2]) 33 | cube(size, center = true); 34 | a = PI; 35 | } 36 | 37 | module enclosure_base() { 38 | difference() { 39 | minkowski() { 40 | centercube_xy([REMOTE_DIAM + enclosure_margin * 2 - bevel_radius, REMOTE_DIAM + enclosure_margin * 2 - bevel_radius, enclosure_height]); 41 | cylinder(r = bevel_radius, $fn = 100); 42 | } 43 | translate([0, 0, wall_thickness + bat_thickness]) 44 | cylinder(d = REMOTE_DIAM, h = enclosure_height, $fn = 100); 45 | 46 | intersection() { 47 | translate([0, 0, wall_thickness + bat_thickness + pcb_thickness]) 48 | cylinder(d = REMOTE_DIAM + 2, h = pcb_thickness + pcb_components_thickness + 3, $fn = 100); 49 | translate([0, -REMOTE_DIAM / 2, 0]) 50 | centercube_xy([REMOTE_DIAM + enclosure_margin * 2, REMOTE_DIAM / 2, enclosure_height + 2]); 51 | } 52 | intersection() { 53 | translate([0, 7.5, wall_thickness]) 54 | centercube_xy([REMOTE_DIAM / 2, 0.8 * REMOTE_DIAM, bat_thickness + 1]); 55 | 56 | translate([0, 0, wall_thickness - 1]) 57 | cylinder(d = REMOTE_DIAM, h = pcb_thickness + pcb_components_thickness + 1, $fn = 100); 58 | 59 | } 60 | 61 | translate([-screw_hole_distance / 2, 0, wall_thickness]) 62 | cylinder(d = scre_hole_diam, h = enclosure_height, $fn = 100); 63 | translate([screw_hole_distance / 2, 0, wall_thickness]) 64 | cylinder(d = scre_hole_diam, h = enclosure_height, $fn = 100); 65 | 66 | difference() { 67 | 68 | translate([0, 0, enclosure_height - ledge_thickness]) 69 | minkowski() { 70 | centercube_xy([REMOTE_DIAM - bevel_radius, REMOTE_DIAM - bevel_radius, enclosure_height]); 71 | cylinder(r = bevel_radius, $fn = 100); 72 | } 73 | translate([0, 0, -1]) 74 | for(x = [-1, 1]) 75 | for(y = [-1, 1]) 76 | translate([x * REMOTE_DIAM / 2, y * REMOTE_DIAM / 2, 0]) 77 | cylinder(h = enclosure_height + 5, d = enclosure_screw_diam + 3, $fn = 100); 78 | } 79 | 80 | translate([0, 0, -1]) 81 | for(x = [-1, 1]) 82 | for(y = [-1, 1]) 83 | translate([x * REMOTE_DIAM / 2, y * REMOTE_DIAM / 2, 0]) 84 | cylinder(h = 5, d = enclosure_screw_diam + 1.5, $fn = 100); 85 | translate([0, 0, -1]) 86 | for(x = [-1, 1]) 87 | for(y = [-1, 1]) 88 | translate([x * REMOTE_DIAM / 2, y * REMOTE_DIAM / 2, 0]) 89 | cylinder(h = enclosure_height + 5, d = enclosure_screw_diam, $fn = 100); 90 | } 91 | 92 | } 93 | 94 | 95 | enclosure_base(); 96 | -------------------------------------------------------------------------------- /src/semantic/SolutionManager.test.ts: -------------------------------------------------------------------------------- 1 | import SolutionManager, { SolutionFile } from "../SolutionManager"; 2 | import { promises as fs } from "fs"; 3 | import { join } from "path"; 4 | import { 5 | AssignmentNode, 6 | ASTMutator, 7 | FunctionCallExpr, 8 | LookupExpr, 9 | ModuleInstantiationStmt, 10 | ModuleInstantiationStmtWithScope, 11 | ResolvedLookupExpr, 12 | ResolvedModuleInstantiationStmt, 13 | ScadFile 14 | } from ".."; 15 | describe("SolutionManager", () => { 16 | it("returns files after they have fully processed when using getFile", async () => { 17 | const sm = new SolutionManager(); 18 | const path = join(__dirname, "../testdata/includes/file.scad"); 19 | await sm.notifyNewFileOpened( 20 | path, 21 | await fs.readFile(path, { encoding: "utf8" }) 22 | ); 23 | 24 | expect(await sm.getFile(path)).toBeInstanceOf(SolutionFile); 25 | }); 26 | it("returns files before they have fully processed when using getFile", async () => { 27 | const sm = new SolutionManager(); 28 | const path = join(__dirname, "../testdata/includes/file.scad"); 29 | sm.notifyNewFileOpened(path, await fs.readFile(path, { encoding: "utf8" })); 30 | 31 | expect(await sm.getFile(path)).toBeInstanceOf(SolutionFile); 32 | }); 33 | it("keeps ResolvedModuleInstantiationStmt in a test file", async () => { 34 | const sm = new SolutionManager(); 35 | const path = join( 36 | __dirname, 37 | "../testdata/resolver_solution_manager_test.scad" 38 | ); 39 | await sm.notifyNewFileOpened( 40 | path, 41 | await fs.readFile(path, { encoding: "utf8" }) 42 | ); 43 | 44 | const file = await sm.getFile(path); 45 | const spy = jest.fn(); 46 | class Walker extends ASTMutator { 47 | visitModuleInstantiationStmt(node: ModuleInstantiationStmt) { 48 | expect(node).toBeInstanceOf(ResolvedModuleInstantiationStmt); 49 | 50 | spy(); 51 | return node; 52 | } 53 | } 54 | if (!file?.ast) { 55 | throw new Error("File has no ast"); 56 | } 57 | file.ast.accept(new Walker()); 58 | 59 | expect(spy).toHaveBeenCalled(); 60 | }); 61 | 62 | async function checkNoError(filePath: string) { 63 | const sm = new SolutionManager(); 64 | const path = join(__dirname, filePath); 65 | sm.notifyNewFileOpened(path, await fs.readFile(path, { encoding: "utf8" })); 66 | const sf = await sm.getFile(path); 67 | expect(sf).toBeInstanceOf(SolutionFile); 68 | expect(sf?.errors).toHaveLength(0); 69 | } 70 | 71 | it("does not report errors on 'echo' statements", async () => { 72 | await checkNoError("../testdata/echo_test.scad"); 73 | }); 74 | it("does not report errors on the 'render()' module", async () => { 75 | await checkNoError("../testdata/render_test.scad"); 76 | }) 77 | it('does report error when using an unknwon module', async () => { 78 | const sm = new SolutionManager(); 79 | const path = join(__dirname, "../testdata/unknown_module.scad"); 80 | sm.notifyNewFileOpened(path, await fs.readFile(path, { encoding: "utf8" })); 81 | const sf = await sm.getFile(path); 82 | expect(sf).toBeInstanceOf(SolutionFile); 83 | expect(sf?.errors).toHaveLength(1); 84 | }); 85 | 86 | it('returns hover info for function calls', async () => { 87 | const sm = new SolutionManager(); 88 | const path = join(__dirname, "../testdata/function_call_test.scad"); 89 | sm.notifyNewFileOpened(path, await fs.readFile(path, { encoding: "utf8" })); 90 | const sf = await sm.getFile(path); 91 | expect(sf).toBeInstanceOf(SolutionFile); 92 | expect(sf?.errors).toHaveLength(0); 93 | const an = ((sf?.ast as ScadFile).statements[0] as AssignmentNode) 94 | expect(an).toBeInstanceOf(AssignmentNode); 95 | const val = an.value as FunctionCallExpr; 96 | expect(val).toBeInstanceOf(FunctionCallExpr); 97 | const calee = val.callee as LookupExpr; 98 | expect(calee).toBeInstanceOf(ResolvedLookupExpr); 99 | 100 | expect(sf?.getSymbolDeclaration(calee.span.start)).toBeTruthy(); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /src/semantic/IncludeResolver.ts: -------------------------------------------------------------------------------- 1 | import ScadFileProvider, { WithExportedScopes } from "./ScadFileProvider"; 2 | import ScadFile from "../ast/ScadFile"; 3 | import { UseStmt, IncludeStmt } from "../ast/statements"; 4 | import { promises as fs } from "fs"; 5 | import * as os from "os"; 6 | import * as path from "path"; 7 | import ErrorCollector from "../ErrorCollector"; 8 | import CodeError from "../errors/CodeError"; 9 | import CodeLocation from "../CodeLocation"; 10 | 11 | export class IncludedFileNotFoundError extends CodeError { 12 | constructor(pos: CodeLocation, filename: string) { 13 | super(pos, `Included file '${filename} not found.'`); 14 | } 15 | } 16 | 17 | export class UsedFileNotFoundError extends CodeError { 18 | constructor(pos: CodeLocation, filename: string) { 19 | super(pos, `Used file '${filename} not found.'`); 20 | } 21 | } 22 | 23 | export default class IncludeResolver { 24 | constructor(private provider: ScadFileProvider) {} 25 | /** 26 | * Finds all file includes and returns paths to them 27 | * @param f 28 | */ 29 | async resolveIncludes(f: ScadFile, ec: ErrorCollector) { 30 | if (!f.span.start.file) { 31 | throw new Error("file in pos is null"); 32 | } 33 | const includes: string[] = []; 34 | for (const stmt of f.statements) { 35 | if (stmt instanceof IncludeStmt) { 36 | const filePath = await this.locateScadFile( 37 | f.span.start.file.path, 38 | stmt.filename 39 | ); 40 | if (!filePath) { 41 | ec.reportError( 42 | new IncludedFileNotFoundError( 43 | stmt.tokens.filename.span.start, 44 | stmt.filename 45 | ) 46 | ); 47 | continue; 48 | } 49 | includes.push(filePath); 50 | } 51 | } 52 | return Promise.all( 53 | includes.map((incl) => this.provider.provideScadFile(incl)) 54 | ); 55 | } 56 | 57 | /** 58 | * Finds all file uses and returns paths to them. 59 | * Uses do not export to parent scopes and do not execute statements inside of the used files. 60 | * @param f 61 | */ 62 | async resolveUses(f: ScadFile, ec: ErrorCollector) { 63 | if(!f.span.start.file) { 64 | throw new Error("file in pos is null"); 65 | } 66 | const uses: string[] = []; 67 | for (const stmt of f.statements) { 68 | if (stmt instanceof UseStmt) { 69 | const filePath = await this.locateScadFile( 70 | f.span.start.file.path, 71 | stmt.filename 72 | ); 73 | if (!filePath) { 74 | ec.reportError( 75 | new UsedFileNotFoundError(stmt.tokens.filename.span.start, stmt.filename) 76 | ); 77 | continue; 78 | } 79 | uses.push(filePath); 80 | } 81 | } 82 | return Promise.all(uses.map((incl) => this.provider.provideScadFile(incl))); 83 | } 84 | 85 | async locateScadFile(parent: string, relativePath: string) { 86 | const searchDirs = [path.dirname(parent), ...IncludeResolver.includeDirs]; 87 | for (const dir of searchDirs) { 88 | const resultingPath = path.resolve(dir, relativePath); 89 | try { 90 | if ((await fs.stat(resultingPath)).isFile()) { 91 | return resultingPath; 92 | } 93 | } catch (e) {} 94 | } 95 | return null; 96 | } 97 | 98 | private static _includeDirsCache: string[] | null = null; 99 | 100 | static get includeDirs() { 101 | if (!this._includeDirsCache) { 102 | this._includeDirsCache = []; 103 | const ENV_SEP = os.platform() === "win32" ? ";" : ":"; 104 | this._includeDirsCache.push( 105 | ...(process.env.OPENSCADPATH || "").split(ENV_SEP) 106 | ); 107 | if (os.platform() === "win32") { 108 | // TODO: add my documents path 109 | // TODO: add installation directory 110 | } 111 | if (os.platform() === "linux") { 112 | this._includeDirsCache.push( 113 | path.join(os.homedir(), ".local/share/OpenSCAD/libraries") 114 | ); 115 | this._includeDirsCache.push("/usr/share/openscad/libraries"); 116 | } 117 | if (os.platform() === "darwin") { 118 | this._includeDirsCache.push( 119 | path.join(os.homedir(), "Documents/OpenSCAD/libraries") 120 | ); 121 | //TODO: add installation directory 122 | } 123 | } 124 | return this._includeDirsCache; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/semantic/FilenameCompletionProvider.ts: -------------------------------------------------------------------------------- 1 | import CompletionProvider from "./CompletionProvider"; 2 | import CompletionSymbol from "./CompletionSymbol"; 3 | import * as path from "path"; 4 | import { promises as fs } from "fs"; 5 | import CompletionType from "./CompletionType"; 6 | import IncludeResolver from "./IncludeResolver"; 7 | import ASTNode from "../ast/ASTNode"; 8 | import CodeLocation from "../CodeLocation"; 9 | /** 10 | * FilenameCompletionProvider provides completions to the include<> and use<> statements. 11 | */ 12 | export default class FilenameCompletionProvider implements CompletionProvider { 13 | textOnly = true; 14 | exclusive = true; 15 | /** 16 | * Determines whether we are in a include<> or use<> statement 17 | * @param ast 18 | * @param loc 19 | */ 20 | shouldActivate(ast: ASTNode, loc: CodeLocation): boolean { 21 | return this.getExistingPath(ast, loc) != null; 22 | } 23 | 24 | async getSymbolsAtLocation( 25 | ast: ASTNode, 26 | locM: CodeLocation 27 | ): Promise { 28 | const loc = new CodeLocation(locM.file, locM.char, locM.line, locM.col); 29 | let existingPath = this.getExistingPath(ast, loc) || ""; 30 | let searchDirs: string[] = []; 31 | if (path.isAbsolute(existingPath)) { 32 | searchDirs = [path.dirname(existingPath)]; 33 | } else { 34 | searchDirs = IncludeResolver.includeDirs.map((id) => 35 | path.join(id, path.dirname(existingPath)) 36 | ); 37 | } 38 | let output: CompletionSymbol[] = []; 39 | 40 | for (const sd of searchDirs) { 41 | try { 42 | const filenames = (await fs.readdir(sd)).filter((p) => 43 | p.startsWith(path.basename(existingPath)) 44 | ); 45 | 46 | output = [ 47 | ...output, 48 | ...(( 49 | await Promise.all( 50 | filenames.map(async (f) => { 51 | const stat = await fs.stat(path.join(sd, f)); 52 | if (stat.isDirectory()) { 53 | return new CompletionSymbol(CompletionType.DIRECTORY, f); 54 | } 55 | if (stat.isFile() && f.endsWith(".scad")) { 56 | return new CompletionSymbol(CompletionType.FILE, f); 57 | } 58 | return null; 59 | }) 60 | ) 61 | ).filter((s) => !!s) as CompletionSymbol[]), 62 | ]; 63 | } catch (e) { 64 | console.error("filed to find in dir", sd, e); 65 | } 66 | } 67 | 68 | return output; 69 | } 70 | 71 | /** 72 | * Obtains the part of the included path the user has already entered 73 | * @param ast the ast to search 74 | * @param loc the location where the user is typing 75 | * @returns the part of the included path the user has already entered 76 | */ 77 | getExistingPath(ast: ASTNode, loc: CodeLocation): string | null { 78 | let charPos = loc.char; 79 | let linesLimit = 5; 80 | let stage = 0; 81 | let existingFilename = ""; 82 | let isFirst = true; 83 | if(!loc.file) { 84 | throw new Error("No file in CodeLocation"); 85 | } 86 | while (true) { 87 | if (charPos <= 0 || linesLimit <= 0) { 88 | return null; 89 | } 90 | const char = loc.file.code[charPos]; 91 | if (char === "\n") { 92 | linesLimit--; 93 | } 94 | if (!isFirst && char === ">") { 95 | return null; 96 | } 97 | 98 | if (!isFirst && stage === 0 && char === "<") { 99 | stage++; 100 | existingFilename = loc.file.code.substring(charPos + 1, loc.char + 1); 101 | } else if ( 102 | stage === 1 && 103 | char !== " " && 104 | char !== "\t" && 105 | char !== "\r" && 106 | char !== "\n" 107 | ) { 108 | if ( 109 | loc.file.code.substring(charPos - "use".length + 1, charPos + 1) === 110 | "use" 111 | ) { 112 | if (existingFilename.endsWith(">")) { 113 | return existingFilename.slice(0, -1); 114 | } 115 | return existingFilename; 116 | } 117 | if ( 118 | loc.file.code.substring( 119 | charPos - "include".length + 1, 120 | charPos + 1 121 | ) === "include" 122 | ) { 123 | if (existingFilename.endsWith(">")) { 124 | return existingFilename.slice(0, -1); 125 | } 126 | return existingFilename; 127 | } 128 | return null; 129 | } 130 | isFirst = false; 131 | charPos--; 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/errors/parsingErrors.ts: -------------------------------------------------------------------------------- 1 | import CodeLocation from "../CodeLocation"; 2 | import friendlyTokenNames from "../friendlyTokenNames"; 3 | import TokenType from "../TokenType"; 4 | import ParsingError from "./ParsingError"; 5 | 6 | /** 7 | * @category Error 8 | */ 9 | export class UnterminatedUseStatementParsingError extends ParsingError { 10 | constructor(pos: CodeLocation) { 11 | super(pos, `Unterminated 'use' statement.`); 12 | } 13 | } 14 | 15 | /** 16 | * @category Error 17 | */ 18 | export class UnexpectedTokenParsingError extends ParsingError { 19 | constructor(pos: CodeLocation, tt: TokenType, extraMsg?: string) { 20 | if (extraMsg) { 21 | super(pos, `Unexpected token ${friendlyTokenNames[tt]}${extraMsg}`); 22 | } else { 23 | super(pos, `Unexpected token ${friendlyTokenNames[tt]}.`); 24 | } 25 | } 26 | } 27 | 28 | /** 29 | * @category Error 30 | */ 31 | export class UnexpectedTokenWhenStatementParsingError extends UnexpectedTokenParsingError { 32 | constructor(pos: CodeLocation, tt: TokenType) { 33 | super(pos, tt, `, expected statement.`); 34 | } 35 | } 36 | 37 | /** 38 | * @category Error 39 | */ 40 | export class UnexpectedTokenAfterIdentifierInStatementParsingError extends UnexpectedTokenParsingError { 41 | constructor(pos: CodeLocation, tt: TokenType) { 42 | super( 43 | pos, 44 | tt, 45 | `, expected ${friendlyTokenNames[TokenType.LeftParen]} or ${ 46 | friendlyTokenNames[TokenType.Equal] 47 | } after identifier in statement.` 48 | ); 49 | } 50 | } 51 | 52 | /** 53 | * @category Error 54 | */ 55 | export class UnexpectedEndOfFileBeforeModuleInstantiationParsingError extends ParsingError { 56 | constructor(pos: CodeLocation) { 57 | super(pos, `Unexpected end of file before module instantiation.`); 58 | } 59 | } 60 | 61 | /** 62 | * @category Error 63 | */ 64 | export class UnterminatedParametersListParsingError extends ParsingError { 65 | constructor(pos: CodeLocation) { 66 | super(pos, `Unterminated parameters list.`); 67 | } 68 | } 69 | 70 | /** 71 | * @category Error 72 | */ 73 | export class UnexpectedTokenInNamedArgumentsListParsingError extends UnexpectedTokenParsingError { 74 | constructor(pos: CodeLocation, tt: TokenType) { 75 | super(pos, tt, ` in named arguments list.`); 76 | } 77 | } 78 | 79 | /** 80 | * @category Error 81 | */ 82 | export class UnterminatedForLoopParamsParsingError extends ParsingError { 83 | constructor(pos: CodeLocation) { 84 | super(pos, `Unterminated for loop params.`); 85 | } 86 | } 87 | 88 | /** 89 | * @category Error 90 | */ 91 | export class UnexpectedTokenInForLoopParamsListParsingError extends UnexpectedTokenParsingError { 92 | constructor(pos: CodeLocation, tt: TokenType) { 93 | super(pos, tt, ` in for loop params list.`); 94 | } 95 | } 96 | 97 | /** 98 | * @category Error 99 | */ 100 | export class FailedToMatchPrimaryExpressionParsingError extends ParsingError { 101 | constructor(pos: CodeLocation) { 102 | super(pos, `Failed to match primary expression.`); 103 | } 104 | } 105 | 106 | /** 107 | * @category Error 108 | */ 109 | export class UnterminatedVectorExpressionParsingError extends ParsingError { 110 | constructor(pos: CodeLocation) { 111 | super(pos, `Unterminated vector literal.`); 112 | } 113 | } 114 | 115 | /** 116 | * @category Error 117 | */ 118 | export class ConsumptionParsingError extends UnexpectedTokenParsingError { 119 | constructor( 120 | pos: CodeLocation, 121 | public real: TokenType, 122 | public expected: TokenType, 123 | where: string 124 | ) { 125 | super(pos, real, `, expected ${friendlyTokenNames[expected]} ${where}.`); 126 | } 127 | } 128 | 129 | /** 130 | * @category Error 131 | */ 132 | export class UnexpectedCommentBeforeUseChevronParsingError extends ParsingError { 133 | constructor(pos: CodeLocation) { 134 | super(pos, `Comments are illegal before '<' in the use statement.`); 135 | } 136 | } 137 | 138 | /** 139 | * @category Error 140 | */ 141 | export class UnexpectedUseStatementParsingError extends ParsingError { 142 | constructor(pos: CodeLocation) { 143 | super( 144 | pos, 145 | `Use ('use <...>') statements are only allowed at the root scope of the file, not inside of blocks.` 146 | ); 147 | } 148 | } 149 | 150 | /** 151 | * @category Error 152 | */ 153 | export class UnexpectedIncludeStatementParsingError extends ParsingError { 154 | constructor(pos: CodeLocation) { 155 | super( 156 | pos, 157 | `Include ('include <...>') statements are only allowed at the root scope of the file, not inside of blocks.` 158 | ); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/semantic/nodesWithScopes.ts: -------------------------------------------------------------------------------- 1 | import ASTVisitor from "../ast/ASTVisitor"; 2 | import { AnonymousFunctionExpr, LcForCExpr, LcForExpr, LcLetExpr, LetExpr } from "../ast/expressions"; 3 | import ScadFile from "../ast/ScadFile"; 4 | import { 5 | BlockStmt, 6 | FunctionDeclarationStmt, 7 | ModuleDeclarationStmt, 8 | ModuleInstantiationStmt, 9 | } from "../ast/statements"; 10 | import NodeWithScope from "./NodeWithScope"; 11 | import Scope from "./Scope"; 12 | 13 | export interface ASTVisitorForNodesWithScopes extends ASTVisitor { 14 | visitBlockStmtWithScope(n: BlockStmtWithScope): R; 15 | visitLetExprWithScope(n: LetExprWithScope): R; 16 | visitScadFileWithScope(n: ScadFileWithScope): R; 17 | visitFunctionDeclarationStmtWithScope(n: FunctionDeclarationStmtWithScope): R; 18 | visitModuleDeclarationStmtWithScope(n: ModuleDeclarationStmtWithScope): R; 19 | visitLcLetExprWithScope(n: LcLetExprWithScope): R; 20 | visitLcForExprWithScope(n: LcForExprWithScope): R; 21 | visitLcForCExprWithScope(n: LcForCExprWithScope): R; 22 | visitModuleInstantiationStmtWithScope(n: ModuleInstantiationStmtWithScope): R; 23 | visitAnonymousFunctionExprWithScope(n: AnonymousFunctionExprWithScope): R; 24 | } 25 | 26 | export class BlockStmtWithScope extends BlockStmt implements NodeWithScope { 27 | scope!: Scope; 28 | accept(visitor: ASTVisitorForNodesWithScopes): R { 29 | if (visitor.visitBlockStmtWithScope) { 30 | return visitor.visitBlockStmtWithScope(this); 31 | } 32 | return visitor.visitBlockStmt(this); 33 | } 34 | } 35 | export class LetExprWithScope extends LetExpr implements NodeWithScope { 36 | scope!: Scope; 37 | accept(visitor: ASTVisitorForNodesWithScopes): R { 38 | if (visitor.visitLetExprWithScope) { 39 | return visitor.visitLetExprWithScope(this); 40 | } 41 | return visitor.visitLetExpr(this); 42 | } 43 | } 44 | 45 | export class ScadFileWithScope extends ScadFile implements NodeWithScope { 46 | scope!: Scope; 47 | accept(visitor: ASTVisitorForNodesWithScopes): R { 48 | if (visitor.visitScadFileWithScope) { 49 | return visitor.visitScadFileWithScope(this); 50 | } 51 | return visitor.visitScadFile(this); 52 | } 53 | } 54 | 55 | export class FunctionDeclarationStmtWithScope 56 | extends FunctionDeclarationStmt 57 | implements NodeWithScope 58 | { 59 | scope!: Scope; 60 | accept(visitor: ASTVisitorForNodesWithScopes): R { 61 | if (visitor.visitFunctionDeclarationStmtWithScope) { 62 | return visitor.visitFunctionDeclarationStmtWithScope(this); 63 | } 64 | return visitor.visitFunctionDeclarationStmt(this); 65 | } 66 | } 67 | 68 | export class ModuleDeclarationStmtWithScope 69 | extends ModuleDeclarationStmt 70 | implements NodeWithScope 71 | { 72 | scope!: Scope; 73 | accept(visitor: ASTVisitorForNodesWithScopes): R { 74 | if (visitor.visitModuleDeclarationStmtWithScope) { 75 | return visitor.visitModuleDeclarationStmtWithScope(this); 76 | } 77 | return visitor.visitModuleDeclarationStmt(this); 78 | } 79 | } 80 | 81 | export class ModuleInstantiationStmtWithScope 82 | extends ModuleInstantiationStmt 83 | implements NodeWithScope 84 | { 85 | scope!: Scope; 86 | accept(visitor: ASTVisitorForNodesWithScopes): R { 87 | if (visitor.visitModuleInstantiationStmtWithScope) { 88 | return visitor.visitModuleInstantiationStmtWithScope(this); 89 | } 90 | return visitor.visitModuleInstantiationStmt(this); 91 | } 92 | } 93 | 94 | export class LcLetExprWithScope extends LcLetExpr implements NodeWithScope { 95 | scope!: Scope; 96 | accept(visitor: ASTVisitorForNodesWithScopes): R { 97 | if (visitor.visitLcLetExprWithScope) { 98 | return visitor.visitLcLetExprWithScope(this); 99 | } 100 | return visitor.visitLcLetExpr(this); 101 | } 102 | } 103 | 104 | export class LcForExprWithScope extends LcForExpr implements NodeWithScope { 105 | scope!: Scope; 106 | accept(visitor: ASTVisitorForNodesWithScopes): R { 107 | if (visitor.visitLcForExprWithScope) { 108 | return visitor.visitLcForExprWithScope(this); 109 | } 110 | return visitor.visitLcForExpr(this); 111 | } 112 | } 113 | 114 | export class LcForCExprWithScope extends LcForCExpr implements NodeWithScope { 115 | scope!: Scope; 116 | accept(visitor: ASTVisitorForNodesWithScopes): R { 117 | if (visitor.visitLcForCExprWithScope) { 118 | return visitor.visitLcForCExprWithScope(this); 119 | } 120 | return visitor.visitLcForCExpr(this); 121 | } 122 | } 123 | 124 | export class AnonymousFunctionExprWithScope extends AnonymousFunctionExpr implements NodeWithScope { 125 | scope!: Scope; 126 | accept(visitor: ASTVisitorForNodesWithScopes): R { 127 | if (visitor.visitAnonymousFunctionExprWithScope) { 128 | return visitor.visitAnonymousFunctionExprWithScope(this); 129 | } 130 | return visitor.visitAnonymousFunctionExpr(this); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Automatically generated by barrelsby. 3 | */ 4 | 5 | export { default as ASTAssembler } from "./ASTAssembler"; 6 | export * from "./ASTAssembler"; 7 | export { default as ASTMutator } from "./ASTMutator"; 8 | export * from "./ASTMutator"; 9 | export { default as ASTPinpointer } from "./ASTPinpointer"; 10 | export * from "./ASTPinpointer"; 11 | export { default as ASTPrinter } from "./ASTPrinter"; 12 | export * from "./ASTPrinter"; 13 | export { default as CodeFile } from "./CodeFile"; 14 | export * from "./CodeFile"; 15 | export { default as CodeLocation } from "./CodeLocation"; 16 | export * from "./CodeLocation"; 17 | export { default as ErrorCollector } from "./ErrorCollector"; 18 | export * from "./ErrorCollector"; 19 | export { default as FormattingConfiguration } from "./FormattingConfiguration"; 20 | export * from "./FormattingConfiguration"; 21 | export { default as Lexer } from "./Lexer"; 22 | export * from "./Lexer"; 23 | export { default as LiteralToken } from "./LiteralToken"; 24 | export * from "./LiteralToken"; 25 | export { default as Parser } from "./Parser"; 26 | export * from "./Parser"; 27 | export { default as ParsingHelper } from "./ParsingHelper"; 28 | export * from "./ParsingHelper"; 29 | export { default as SolutionManager } from "./SolutionManager"; 30 | export * from "./SolutionManager"; 31 | export { default as Token } from "./Token"; 32 | export * from "./Token"; 33 | export { default as TokenType } from "./TokenType"; 34 | export * from "./TokenType"; 35 | export * from "./extraTokens"; 36 | export { default as friendlyTokenNames } from "./friendlyTokenNames"; 37 | export * from "./friendlyTokenNames"; 38 | export { default as keywords } from "./keywords"; 39 | export * from "./keywords"; 40 | export { default as ASTNode } from "./ast/ASTNode"; 41 | export * from "./ast/ASTNode"; 42 | export { default as ASTVisitor } from "./ast/ASTVisitor"; 43 | export * from "./ast/ASTVisitor"; 44 | export { default as AssignmentNode } from "./ast/AssignmentNode"; 45 | export * from "./ast/AssignmentNode"; 46 | export { default as ErrorNode } from "./ast/ErrorNode"; 47 | export * from "./ast/ErrorNode"; 48 | export { default as ScadFile } from "./ast/ScadFile"; 49 | export * from "./ast/ScadFile"; 50 | export * from "./ast/expressions"; 51 | export * from "./ast/statements"; 52 | export { default as DocAnnotationClass } from "./comments/DocAnnotationClass"; 53 | export * from "./comments/DocAnnotationClass"; 54 | export { default as DocComment } from "./comments/DocComment"; 55 | export * from "./comments/DocComment"; 56 | export * from "./comments/annotations"; 57 | export { default as CodeError } from "./errors/CodeError"; 58 | export * from "./errors/CodeError"; 59 | export { default as LexingError } from "./errors/LexingError"; 60 | export * from "./errors/LexingError"; 61 | export { default as ParsingError } from "./errors/ParsingError"; 62 | export * from "./errors/ParsingError"; 63 | export * from "./errors/lexingErrors"; 64 | export * from "./errors/parsingErrors"; 65 | export { default as PreludeUtil } from "./prelude/PreludeUtil"; 66 | export * from "./prelude/PreludeUtil"; 67 | export { default as ASTScopePopulator } from "./semantic/ASTScopePopulator"; 68 | export * from "./semantic/ASTScopePopulator"; 69 | export { default as ASTSymbolLister } from "./semantic/ASTSymbolLister"; 70 | export * from "./semantic/ASTSymbolLister"; 71 | export { default as CompletionProvider } from "./semantic/CompletionProvider"; 72 | export * from "./semantic/CompletionProvider"; 73 | export { default as CompletionSymbol } from "./semantic/CompletionSymbol"; 74 | export * from "./semantic/CompletionSymbol"; 75 | export { default as CompletionType } from "./semantic/CompletionType"; 76 | export * from "./semantic/CompletionType"; 77 | export { default as CompletionUtil } from "./semantic/CompletionUtil"; 78 | export * from "./semantic/CompletionUtil"; 79 | export { default as FilenameCompletionProvider } from "./semantic/FilenameCompletionProvider"; 80 | export * from "./semantic/FilenameCompletionProvider"; 81 | export { default as IncludeResolver } from "./semantic/IncludeResolver"; 82 | export * from "./semantic/IncludeResolver"; 83 | export { default as KeywordsCompletionProvider } from "./semantic/KeywordsCompletionProvider"; 84 | export * from "./semantic/KeywordsCompletionProvider"; 85 | export { default as NodeWithScope } from "./semantic/NodeWithScope"; 86 | export * from "./semantic/NodeWithScope"; 87 | export { default as ScadFileProvider } from "./semantic/ScadFileProvider"; 88 | export * from "./semantic/ScadFileProvider"; 89 | export { default as Scope } from "./semantic/Scope"; 90 | export * from "./semantic/Scope"; 91 | export { default as ScopeSymbolCompletionProvider } from "./semantic/ScopeSymbolCompletionProvider"; 92 | export * from "./semantic/ScopeSymbolCompletionProvider"; 93 | export { default as SymbolResolver } from "./semantic/SymbolResolver"; 94 | export * from "./semantic/SymbolResolver"; 95 | export * from "./semantic/nodesWithScopes"; 96 | export * from "./semantic/resolvedNodes"; 97 | export * from "./semantic/unresolvedSymbolErrors"; 98 | -------------------------------------------------------------------------------- /src/semantic/SymbolResolver.ts: -------------------------------------------------------------------------------- 1 | import AssignmentNode from "../ast/AssignmentNode"; 2 | import ASTNode from "../ast/ASTNode"; 3 | import { 4 | AnonymousFunctionExpr, 5 | FunctionCallExpr, 6 | LcForCExpr, 7 | LcForExpr, 8 | LcLetExpr, 9 | LetExpr, 10 | LookupExpr, 11 | } from "../ast/expressions"; 12 | import ScadFile from "../ast/ScadFile"; 13 | import { 14 | BlockStmt, 15 | FunctionDeclarationStmt, 16 | ModuleDeclarationStmt, 17 | ModuleInstantiationStmt, 18 | } from "../ast/statements"; 19 | import ASTMutator from "../ASTMutator"; 20 | import ErrorCollector from "../ErrorCollector"; 21 | import NodeWithScope from "./NodeWithScope"; 22 | import { 23 | ResolvedLookupExpr, 24 | ResolvedModuleInstantiationStmt, 25 | } from "./resolvedNodes"; 26 | import Scope from "./Scope"; 27 | import { 28 | UnresolvedFunctionError, 29 | UnresolvedModuleError, 30 | UnresolvedVariableError, 31 | } from "./unresolvedSymbolErrors"; 32 | 33 | export default class SymbolResolver extends ASTMutator { 34 | constructor( 35 | private errorCollector: ErrorCollector, 36 | /** 37 | * Represents the scope where the resolver has descended. 38 | * It initially is null, but the resolver should encounter a NodeWithScope 39 | * and set this to the scope of that node. 40 | */ 41 | public currentScope: Scope | null = null, 42 | public isInCallee: boolean = false 43 | ) { 44 | super(); 45 | } 46 | 47 | visitLookupExpr(n: LookupExpr): ASTNode { 48 | if(! this.currentScope) { 49 | throw new Error("currentScope cannot be null when resolving lookup"); 50 | } 51 | const resolved = new ResolvedLookupExpr(n.name, n.tokens); 52 | resolved.resolvedDeclaration = this.currentScope.lookupVariable(n.name); 53 | if(this.isInCallee && !resolved.resolvedDeclaration) { 54 | resolved.resolvedDeclaration = this.currentScope.lookupFunction(n.name); 55 | } 56 | if (!resolved.resolvedDeclaration) { 57 | this.errorCollector.reportError( 58 | new UnresolvedVariableError(n.span.start, n.name) 59 | ); 60 | return n; 61 | } 62 | return resolved; 63 | } 64 | 65 | visitModuleInstantiationStmt(n: ModuleInstantiationStmt): ASTNode { 66 | if(! this.currentScope) { 67 | throw new Error("currentScope cannot be null when resolving module"); 68 | } 69 | const resolved = new ResolvedModuleInstantiationStmt( 70 | n.name, 71 | n.args.map((a) => a.accept(this)) as AssignmentNode[], 72 | n.child ? n.child.accept(this) : null, 73 | n.tokens 74 | ); 75 | resolved.resolvedDeclaration = this.currentScope.lookupModule(n.name); 76 | if (!resolved.resolvedDeclaration) { 77 | this.errorCollector.reportError(new UnresolvedModuleError(n.span.start, n.name)); 78 | return n; 79 | } 80 | return resolved; 81 | } 82 | 83 | 84 | /** 85 | * visitFunctionCallExpr switches the SymbolResolver into a special mode where 86 | * it falls back to resolving named functions when processing lookup expressions. 87 | * This behaviour tries to mimic the behaviour of OpenSCAD's function call resolution, 88 | * it is not perfect, since you can abuse this to do things like assign a named function 89 | * to a variable which is not allowed in OpenSCAD. So this covers all but the most 90 | * pathological cases. 91 | * @param n 92 | * @returns 93 | */ 94 | visitFunctionCallExpr(n: FunctionCallExpr): ASTNode { 95 | return super.visitFunctionCallExpr.call( 96 | this.copyWithIsInCallee(), 97 | n 98 | ); 99 | } 100 | 101 | // scope handling 102 | private copyWithNextScope(s: Scope) { 103 | if (!s) { 104 | throw new Error("Scope cannot be falsy"); 105 | } 106 | return new SymbolResolver(this.errorCollector, s, this.isInCallee); 107 | } 108 | 109 | private copyWithIsInCallee() { 110 | return new SymbolResolver(this.errorCollector, this.currentScope, true); 111 | } 112 | 113 | visitBlockStmt(n: BlockStmt): ASTNode { 114 | return super.visitBlockStmt.call( 115 | this.copyWithNextScope((n as unknown as NodeWithScope).scope), 116 | n 117 | ); 118 | } 119 | visitLetExpr(n: LetExpr): ASTNode { 120 | return super.visitLetExpr.call( 121 | this.copyWithNextScope((n as unknown as NodeWithScope).scope), 122 | n 123 | ); 124 | } 125 | visitScadFile(n: ScadFile): ASTNode { 126 | return super.visitScadFile.call( 127 | this.copyWithNextScope((n as unknown as NodeWithScope).scope), 128 | n 129 | ); 130 | } 131 | visitFunctionDeclarationStmt(n: FunctionDeclarationStmt): ASTNode { 132 | return super.visitFunctionDeclarationStmt.call( 133 | this.copyWithNextScope((n as unknown as NodeWithScope).scope), 134 | n 135 | ); 136 | } 137 | visitModuleDeclarationStmt(n: ModuleDeclarationStmt): ASTNode { 138 | return super.visitModuleDeclarationStmt.call( 139 | this.copyWithNextScope((n as unknown as NodeWithScope).scope), 140 | n 141 | ); 142 | } 143 | visitLcLetExpr(n: LcLetExpr): ASTNode { 144 | return super.visitLcLetExpr.call( 145 | this.copyWithNextScope((n as unknown as NodeWithScope).scope), 146 | n 147 | ); 148 | } 149 | visitLcForExpr(n: LcForExpr): ASTNode { 150 | return super.visitLcForExpr.call( 151 | this.copyWithNextScope((n as unknown as NodeWithScope).scope), 152 | n 153 | ); 154 | } 155 | visitLcForCExpr(n: LcForCExpr): ASTNode { 156 | return super.visitLcForCExpr.call( 157 | this.copyWithNextScope((n as unknown as NodeWithScope).scope), 158 | n 159 | ); 160 | } 161 | visitAnonymousFunctionExpr(n: AnonymousFunctionExpr): ASTNode { 162 | return super.visitAnonymousFunctionExpr.call( 163 | this.copyWithNextScope((n as unknown as NodeWithScope).scope), 164 | n 165 | ); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/ast/statements.ts: -------------------------------------------------------------------------------- 1 | import CodeLocation from "../CodeLocation"; 2 | import DocComment from "../comments/DocComment"; 3 | import LiteralToken from "../LiteralToken"; 4 | import Token from "../Token"; 5 | import AssignmentNode from "./AssignmentNode"; 6 | import ASTNode from "./ASTNode"; 7 | import ASTVisitor from "./ASTVisitor"; 8 | import { Expression } from "./expressions"; 9 | 10 | /** 11 | * @category AST 12 | */ 13 | export abstract class Statement extends ASTNode {} 14 | 15 | /** 16 | * @category AST 17 | */ 18 | export class UseStmt extends Statement { 19 | /** 20 | * 21 | * @param pos 22 | * @param filename The used filename 23 | */ 24 | constructor( 25 | 26 | public filename: string, 27 | public tokens: { 28 | useKeyword: Token; 29 | filename: LiteralToken; 30 | } 31 | ) { 32 | super(); 33 | } 34 | accept(visitor: ASTVisitor): R { 35 | return visitor.visitUseStmt(this); 36 | } 37 | } 38 | 39 | /** 40 | * @category AST 41 | */ 42 | export class IncludeStmt extends Statement { 43 | /** 44 | * 45 | * @param pos 46 | * @param filename The used filename 47 | */ 48 | constructor( 49 | public filename: string, 50 | public tokens: { 51 | includeKeyword: Token; 52 | filename: LiteralToken; 53 | } 54 | ) { 55 | super(); 56 | } 57 | accept(visitor: ASTVisitor): R { 58 | return visitor.visitIncludeStmt(this); 59 | } 60 | } 61 | 62 | /** 63 | * Represents a statement that can be prefixed with the !%#* symbols to change it's behaviour. 64 | * @category AST 65 | */ 66 | export interface TaggableStatement { 67 | /** 68 | * Set to true if this module instantation has been tagged with a '!' symbol. 69 | */ 70 | tagRoot: boolean; 71 | 72 | /** 73 | * Set to true if this module instantation has been tagged with a '#' symbol. 74 | */ 75 | tagHighlight: boolean; 76 | 77 | /** 78 | * Set to true if this module instantation has been tagged with a '%' symbol. 79 | */ 80 | tagBackground: boolean; 81 | 82 | /** 83 | * Set to true if this module instantation has been tagged with a '*' symbol. 84 | */ 85 | tagDisabled: boolean; 86 | } 87 | 88 | /** 89 | * @category AST 90 | */ 91 | export class ModuleInstantiationStmt 92 | extends Statement 93 | implements TaggableStatement 94 | { 95 | /** 96 | * ! 97 | */ 98 | public tagRoot: boolean = false; 99 | 100 | /** 101 | * # 102 | */ 103 | public tagHighlight: boolean = false; 104 | 105 | /** 106 | * % 107 | */ 108 | public tagBackground: boolean = false; 109 | 110 | /** 111 | * * 112 | */ 113 | public tagDisabled: boolean = false; 114 | 115 | constructor( 116 | 117 | public name: string, 118 | public args: AssignmentNode[], 119 | /** 120 | * The child statement in a module instantiation chain. 121 | * Can be null if this is the last statement in the chain. 122 | */ 123 | public child: Statement | null, 124 | public tokens: { 125 | name: Token; 126 | firstParen: Token; 127 | secondParen: Token; 128 | modifiersInOrder: Token[]; 129 | } 130 | ) { 131 | super(); 132 | } 133 | accept(visitor: ASTVisitor): R { 134 | return visitor.visitModuleInstantiationStmt(this); 135 | } 136 | } 137 | 138 | /** 139 | * @category AST 140 | */ 141 | export class ModuleDeclarationStmt extends Statement { 142 | constructor( 143 | 144 | public name: string, 145 | public definitionArgs: AssignmentNode[], 146 | public stmt: Statement, 147 | public tokens: { 148 | moduleKeyword: Token; 149 | name: Token; 150 | firstParen: Token; 151 | secondParen: Token; 152 | }, 153 | public docComment: DocComment 154 | ) { 155 | super(); 156 | } 157 | accept(visitor: ASTVisitor): R { 158 | return visitor.visitModuleDeclarationStmt(this); 159 | } 160 | } 161 | 162 | /** 163 | * FunctionDeclarationStmt reperesents a named function declaration statement. 164 | * @category AST 165 | */ 166 | export class FunctionDeclarationStmt extends Statement { 167 | constructor( 168 | 169 | public name: string, 170 | public definitionArgs: AssignmentNode[], 171 | public expr: Expression, 172 | public tokens: { 173 | functionKeyword: Token; 174 | name: Token; 175 | firstParen: Token; 176 | secondParen: Token; 177 | equals: Token; 178 | semicolon: Token; 179 | }, 180 | public docComment: DocComment 181 | ) { 182 | super(); 183 | } 184 | accept(visitor: ASTVisitor): R { 185 | return visitor.visitFunctionDeclarationStmt(this); 186 | } 187 | } 188 | 189 | /** 190 | * @category AST 191 | */ 192 | export class BlockStmt extends Statement { 193 | constructor( 194 | 195 | public children: Statement[], 196 | public tokens: { 197 | firstBrace: Token; 198 | secondBrace: Token; 199 | } 200 | ) { 201 | super(); 202 | } 203 | accept(visitor: ASTVisitor): R { 204 | return visitor.visitBlockStmt(this); 205 | } 206 | } 207 | 208 | /** 209 | * @category AST 210 | */ 211 | export class NoopStmt extends Statement { 212 | constructor( 213 | 214 | public tokens: { 215 | semicolon: Token; 216 | } 217 | ) { 218 | super(); 219 | } 220 | accept(visitor: ASTVisitor): R { 221 | return visitor.visitNoopStmt(this); 222 | } 223 | } 224 | 225 | /** 226 | * IfElseStmt represents an if-else statement. elseIfs are represented as 227 | * additional IfElseStmt instances in the else branch (simmilar to how C works). 228 | * @category AST 229 | */ 230 | export class IfElseStatement extends Statement implements TaggableStatement { 231 | public tagRoot: boolean = false; 232 | public tagHighlight: boolean = false; 233 | public tagBackground: boolean = false; 234 | public tagDisabled: boolean = false; 235 | constructor( 236 | 237 | public cond: Expression, 238 | public thenBranch: Statement, 239 | /** 240 | * The else branch. 241 | * It can be null if there is no else branch. 242 | */ 243 | public elseBranch: Statement | null, 244 | public tokens: { 245 | ifKeyword: Token; 246 | firstParen: Token; 247 | secondParen: Token; 248 | elseKeyword: Token | null; 249 | modifiersInOrder: Token[]; 250 | } 251 | ) { 252 | super(); 253 | } 254 | accept(visitor: ASTVisitor): R { 255 | return visitor.visitIfElseStatement(this); 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /src/ASTPinpointer.test.ts: -------------------------------------------------------------------------------- 1 | import AssignmentNode from "./ast/AssignmentNode"; 2 | import { GroupingExpr, LiteralExpr, LookupExpr } from "./ast/expressions"; 3 | import ScadFile from "./ast/ScadFile"; 4 | import { BlockStmt } from "./ast/statements"; 5 | import ASTPinpointer, { BinAfter, BinBefore } from "./ASTPinpointer"; 6 | import CodeFile from "./CodeFile"; 7 | import CodeLocation from "./CodeLocation"; 8 | import ErrorCollector from "./ErrorCollector"; 9 | import Lexer from "./Lexer"; 10 | import ParsingHelper from "./ParsingHelper"; 11 | import ASTScopePopulator from "./semantic/ASTScopePopulator"; 12 | import Scope from "./semantic/Scope"; 13 | 14 | describe("ASTPinpointer", () => { 15 | it("the internal binsearch dispatch works with simple tokens", () => { 16 | const f = new CodeFile("", "a=5;b=a;"); 17 | const ec = new ErrorCollector(); 18 | 19 | const lexer = new Lexer(f, ec); 20 | const tokens = lexer.scan(); 21 | ec.throwIfAny(); 22 | class TstClass extends ASTPinpointer { 23 | testFunc1() { 24 | return this.processAssembledNode( 25 | [tokens[1], tokens[2]], 26 | new ScadFile([], { eot: tokens[tokens.length - 1] }) 27 | ); 28 | } 29 | } 30 | const p = new TstClass(new CodeLocation(f, 0)); 31 | expect(p.testFunc1()).toEqual(BinBefore); 32 | 33 | p.pinpointLocation = new CodeLocation(f, 200); 34 | expect(p.testFunc1()).toEqual(BinAfter); 35 | 36 | p.pinpointLocation = new CodeLocation(f, 4); 37 | expect(p.testFunc1()).toEqual(BinAfter); 38 | }); 39 | it("the internal binsearch dispatch works with function trees", () => { 40 | const f = new CodeFile("", "a=5;b=a;"); 41 | const ec = new ErrorCollector(); 42 | 43 | const lexer = new Lexer(f, ec); 44 | const tokens = lexer.scan(); 45 | ec.throwIfAny(); 46 | class TstClass extends ASTPinpointer { 47 | testFunc1() { 48 | return this.processAssembledNode( 49 | [ 50 | () => 51 | this.processAssembledNode( 52 | [ 53 | tokens[0], 54 | tokens[1], 55 | () => 56 | this.processAssembledNode( 57 | [tokens[2]], 58 | new (LiteralExpr as any)() 59 | ), 60 | tokens[3], 61 | ], 62 | new (AssignmentNode as any)() 63 | ), 64 | () => 65 | this.processAssembledNode( 66 | [ 67 | tokens[4], 68 | tokens[5], 69 | () => 70 | this.processAssembledNode( 71 | [tokens[6]], 72 | new (LiteralExpr as any)() 73 | ), 74 | tokens[7], 75 | ], 76 | new (AssignmentNode as any)() 77 | ), 78 | ], 79 | new (ScadFile as any)() 80 | ); 81 | } 82 | } 83 | const p = new TstClass(new CodeLocation(f, 0)); 84 | expect(p.testFunc1()).toBeInstanceOf(AssignmentNode); 85 | 86 | p.pinpointLocation = new CodeLocation(f, 200); 87 | expect(p.testFunc1()).toEqual(BinAfter); 88 | 89 | p.pinpointLocation = new CodeLocation(f, 1); 90 | expect(p.testFunc1()).toBeInstanceOf(AssignmentNode); 91 | p.pinpointLocation = new CodeLocation(f, 2); 92 | expect(p.testFunc1()).toBeInstanceOf(LiteralExpr); 93 | p.pinpointLocation = new CodeLocation(f, 3); 94 | expect(p.testFunc1()).toBeInstanceOf(AssignmentNode); 95 | }); 96 | it("pinpoints nodes in a simple assignment expression", () => { 97 | const f = new CodeFile("", "a=5;b=a;"); 98 | // 12345678 99 | 100 | const [ast, ec] = ParsingHelper.parseFile(f); 101 | ec.throwIfAny(); 102 | if(!ast) { 103 | throw new Error("ast is null"); 104 | } 105 | const ap = new ASTPinpointer(new CodeLocation(f, 1)); 106 | let theNode = ap.doPinpoint(ast); 107 | expect(theNode).toBeInstanceOf(AssignmentNode); 108 | ap.pinpointLocation = new CodeLocation(f, 2); 109 | theNode = ap.doPinpoint(ast); 110 | expect(theNode).toBeInstanceOf(LiteralExpr); 111 | ap.pinpointLocation = new CodeLocation(f, 3); 112 | theNode = ap.doPinpoint(ast); 113 | expect(theNode).toBeInstanceOf(AssignmentNode); 114 | ap.pinpointLocation = new CodeLocation(f, 4); 115 | theNode = ap.doPinpoint(ast); 116 | expect(theNode).toBeInstanceOf(AssignmentNode); 117 | ap.pinpointLocation = new CodeLocation(f, 5); 118 | theNode = ap.doPinpoint(ast); 119 | expect(theNode).toBeInstanceOf(AssignmentNode); 120 | ap.pinpointLocation = new CodeLocation(f, 6); 121 | theNode = ap.doPinpoint(ast); 122 | expect(theNode).toBeInstanceOf(LookupExpr); 123 | ap.pinpointLocation = new CodeLocation(f, 7); 124 | theNode = ap.doPinpoint(ast); 125 | expect(theNode).toBeInstanceOf(AssignmentNode); 126 | }); 127 | it("populates bottomUpHierarchy", () => { 128 | const f = new CodeFile("", "x=(10);"); 129 | 130 | const [ast, ec] = ParsingHelper.parseFile(f); 131 | ec.throwIfAny(); 132 | if(!ast) { 133 | throw new Error("ast is null"); 134 | } 135 | 136 | const ap = new ASTPinpointer(new CodeLocation(f, 4)); 137 | let theNode = ap.doPinpoint(ast); 138 | expect(theNode).toBeInstanceOf(LiteralExpr); 139 | expect(ap.bottomUpHierarchy[0]).toBeInstanceOf(LiteralExpr); 140 | expect(ap.bottomUpHierarchy[1]).toBeInstanceOf(GroupingExpr); 141 | expect(ap.bottomUpHierarchy[2]).toBeInstanceOf(AssignmentNode); 142 | expect(ap.bottomUpHierarchy[3]).toBeInstanceOf(ScadFile); 143 | }); 144 | it("does not throw when pinpointing in a code file with a module instantation", () => { 145 | const f = new CodeFile("", "cube([10, 10, 10]);"); 146 | 147 | const [ast, ec] = ParsingHelper.parseFile(f); 148 | 149 | ec.throwIfAny(); 150 | if(!ast) { 151 | throw new Error("ast is null"); 152 | } 153 | 154 | const ap = new ASTPinpointer(new CodeLocation(f, 4)); 155 | let theNode = ap.doPinpoint(ast); 156 | }); 157 | it("does not throw when pinpointing a module delcaration in a scope populated ast, pinpoints the correct ast nodes", async () => { 158 | const f = await CodeFile.load("./src/testdata/pinpointer_block_test.scad"); 159 | 160 | const [ast, ec] = ParsingHelper.parseFile(f); 161 | const lexer = new Lexer(f, ec); 162 | const tokens = lexer.scan(); 163 | ec.throwIfAny(); 164 | if(!ast) { 165 | throw new Error("ast is null"); 166 | } 167 | const populator = new ASTScopePopulator(new Scope()); 168 | const astWithScopes = ast.accept(populator); 169 | const ap = new ASTPinpointer(new CodeLocation(f, 32)); 170 | let theNode; //= ap.doPinpoint(astWithScopes); 171 | ap.pinpointLocation = new CodeLocation(f, 58); 172 | theNode = ap.doPinpoint(ast); 173 | expect(theNode).toBeInstanceOf(BlockStmt); 174 | }); 175 | }); 176 | -------------------------------------------------------------------------------- /src/semantic/SymbolResolver.test.ts: -------------------------------------------------------------------------------- 1 | import { PreludeUtil } from ".."; 2 | import AssignmentNode from "../ast/AssignmentNode"; 3 | import { AssertExpr, LiteralExpr } from "../ast/expressions"; 4 | import ScadFile from "../ast/ScadFile"; 5 | import ASTMutator from "../ASTMutator"; 6 | import CodeFile from "../CodeFile"; 7 | import ParsingHelper from "../ParsingHelper"; 8 | import ASTScopePopulator from "./ASTScopePopulator"; 9 | import { ScadFileWithScope } from "./nodesWithScopes"; 10 | import { ResolvedLookupExpr } from "./resolvedNodes"; 11 | import Scope from "./Scope"; 12 | import SymbolResolver from "./SymbolResolver"; 13 | 14 | describe("SymbolResolver", () => { 15 | it("resolves local symbols in file top level", () => { 16 | let [ast, ec] = ParsingHelper.parseFile( 17 | new CodeFile( 18 | "", 19 | `x = [10:20]; 20 | cincoman = assert(1==1) x; 21 | ` 22 | ) 23 | ); 24 | ec.throwIfAny(); 25 | if(!ast) { 26 | throw new Error("ast is null"); 27 | } 28 | const pop = new ASTScopePopulator(new Scope()); 29 | ast = ast.accept(pop) as ScadFile; 30 | const resolver = new SymbolResolver(ec); 31 | ast = ast.accept(resolver) as ScadFile; 32 | ec.throwIfAny(); 33 | class A extends ASTMutator { 34 | visitAssertExpr(n: AssertExpr) { 35 | expect(n.expr).toBeInstanceOf(ResolvedLookupExpr); 36 | return n; 37 | } 38 | } 39 | ast.accept(new A()); 40 | }); 41 | 42 | it("resolves local symbols in block statements, with correct shadowing", () => { 43 | let [ast, ec] = ParsingHelper.parseFile( 44 | new CodeFile( 45 | "", 46 | `x = [10:20]; 47 | { 48 | x = "correct"; 49 | cincoman = assert(1==1) x; 50 | } 51 | ` 52 | ) 53 | ); 54 | ec.throwIfAny(); 55 | const pop = new ASTScopePopulator(new Scope()); 56 | ast = ast!.accept(pop) as ScadFile; 57 | const resolver = new SymbolResolver(ec); 58 | ast = ast.accept(resolver) as ScadFile; 59 | ec.throwIfAny(); 60 | 61 | const confirmFn = jest.fn(); 62 | 63 | class A extends ASTMutator { 64 | visitAssertExpr(n: AssertExpr) { 65 | confirmFn(); 66 | expect(n.expr).toBeInstanceOf(ResolvedLookupExpr); 67 | expect( 68 | (n.expr as ResolvedLookupExpr).resolvedDeclaration 69 | ).toBeInstanceOf(AssignmentNode); 70 | expect( 71 | ((n.expr as ResolvedLookupExpr).resolvedDeclaration as AssignmentNode).value 72 | ).toBeInstanceOf(LiteralExpr); 73 | expect( 74 | ( 75 | ((n.expr as ResolvedLookupExpr).resolvedDeclaration as AssignmentNode) 76 | .value as LiteralExpr 77 | ).value 78 | ).toEqual("correct"); 79 | return n; 80 | } 81 | } 82 | ast.accept(new A()); 83 | expect(confirmFn).toHaveBeenCalled(); // make sure we have called the method which verifies this test 84 | }); 85 | it("resolves local symbols inside of module children", () => { 86 | let [ast, ec] = ParsingHelper.parseFile( 87 | new CodeFile( 88 | "", 89 | `x = [10:20]; 90 | module mod(); 91 | mod() { 92 | x = "correct"; 93 | cincoman = assert(1==1) x; 94 | }; 95 | ` 96 | ) 97 | ); 98 | ec.throwIfAny(); 99 | const pop = new ASTScopePopulator(new Scope()); 100 | ast = ast!.accept(pop) as ScadFile; 101 | const resolver = new SymbolResolver(ec); 102 | ast = ast.accept(resolver) as ScadFile; 103 | ec.throwIfAny(); 104 | 105 | const confirmFn = jest.fn(); 106 | 107 | class A extends ASTMutator { 108 | visitAssertExpr(n: AssertExpr) { 109 | confirmFn(); 110 | expect(n.expr).toBeInstanceOf(ResolvedLookupExpr); 111 | expect( 112 | (n.expr as ResolvedLookupExpr).resolvedDeclaration 113 | ).toBeInstanceOf(AssignmentNode); 114 | expect( 115 | ((n.expr as ResolvedLookupExpr).resolvedDeclaration as AssignmentNode).value 116 | ).toBeInstanceOf(LiteralExpr); 117 | expect( 118 | ( 119 | ( (n.expr as ResolvedLookupExpr).resolvedDeclaration as AssignmentNode) 120 | .value as LiteralExpr 121 | ).value 122 | ).toEqual("correct"); 123 | return n; 124 | } 125 | } 126 | ast.accept(new A()); 127 | expect(confirmFn).toHaveBeenCalled(); // make sure we have called the method which verifies this test 128 | }); 129 | it("preserves nodes with scopes in the returned AST so that code completion still works", () => { 130 | let [ast, ec] = ParsingHelper.parseFile( 131 | new CodeFile( 132 | "", 133 | `x = [10:20]; 134 | { 135 | x = "correct"; 136 | cincoman = assert(1==1) x; 137 | } 138 | ` 139 | ) 140 | ); 141 | ec.throwIfAny(); 142 | const pop = new ASTScopePopulator(new Scope()); 143 | ast = ast!.accept(pop) as ScadFile; 144 | expect(ast).toBeInstanceOf(ScadFileWithScope); 145 | const resolver = new SymbolResolver(ec); 146 | ast = ast.accept(resolver) as ScadFile; 147 | ec.throwIfAny(); 148 | expect(ast).toBeInstanceOf(ScadFileWithScope); 149 | }); 150 | it("resolves variables from for loops", () => { 151 | let [ast, ec] = ParsingHelper.parseFile( 152 | new CodeFile( 153 | "", 154 | ` 155 | for(x = [1 : 5]) { 156 | cincoman = assert(1==1) x; 157 | } 158 | intersection_for(x = [1 : 5]) { 159 | cincoman = assert(1==1) x; 160 | } 161 | ` 162 | ) 163 | ); 164 | ec.throwIfAny(); 165 | const pop = new ASTScopePopulator(new Scope()); 166 | let astWithScope = ast!.accept(pop) as ScadFileWithScope; 167 | astWithScope.scope.siblingScopes = [PreludeUtil.preludeScope]; 168 | const resolver = new SymbolResolver(ec); 169 | astWithScope = astWithScope.accept(resolver) as ScadFileWithScope; 170 | ec.throwIfAny(); 171 | const confirmFn = jest.fn(); 172 | 173 | class A extends ASTMutator { 174 | visitAssertExpr(n: AssertExpr) { 175 | expect(n.expr).toBeInstanceOf(ResolvedLookupExpr); 176 | confirmFn(); 177 | return n; 178 | } 179 | } 180 | astWithScope.accept(new A()); 181 | expect(confirmFn).toHaveBeenCalled(); 182 | }); 183 | it("resolves function calls to anonymous functions", () => { 184 | let [ast, ec] = ParsingHelper.parseFile( 185 | new CodeFile( 186 | "", 187 | ` 188 | 189 | func = function (x) x * x; 190 | a = func(5); // ECHO: 25 191 | ` 192 | ) 193 | ); 194 | ec.throwIfAny(); 195 | const pop = new ASTScopePopulator(new Scope()); 196 | let astWithScope = ast!.accept(pop) as ScadFileWithScope; 197 | astWithScope.scope.siblingScopes = [PreludeUtil.preludeScope]; 198 | const resolver = new SymbolResolver(ec); 199 | astWithScope = astWithScope.accept(resolver) as ScadFileWithScope; 200 | ec.throwIfAny(); 201 | }); 202 | 203 | }); 204 | -------------------------------------------------------------------------------- /src/SolutionManager.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import ASTNode from "./ast/ASTNode"; 3 | import ScadFile from "./ast/ScadFile"; 4 | import ASTPinpointer from "./ASTPinpointer"; 5 | import ASTPrinter from "./ASTPrinter"; 6 | import CodeFile from "./CodeFile"; 7 | import CodeLocation from "./CodeLocation"; 8 | import CodeSpan from "./CodeSpan"; 9 | import FormattingConfiguration from "./FormattingConfiguration"; 10 | import ParsingHelper from "./ParsingHelper"; 11 | import PreludeUtil from "./prelude/PreludeUtil"; 12 | import ASTScopePopulator from "./semantic/ASTScopePopulator"; 13 | import ASTSymbolLister, { SymbolKind } from "./semantic/ASTSymbolLister"; 14 | import CompletionUtil from "./semantic/CompletionUtil"; 15 | import IncludeResolver from "./semantic/IncludeResolver"; 16 | import { ScadFileWithScope } from "./semantic/nodesWithScopes"; 17 | import { 18 | ResolvedLookupExpr, 19 | ResolvedModuleInstantiationStmt 20 | } from "./semantic/resolvedNodes"; 21 | import ScadFileProvider, { 22 | WithExportedScopes 23 | } from "./semantic/ScadFileProvider"; 24 | import Scope from "./semantic/Scope"; 25 | import SymbolResolver from "./semantic/SymbolResolver"; 26 | 27 | export class SolutionFile implements WithExportedScopes { 28 | codeFile!: CodeFile; 29 | ast: ASTNode|null = null; 30 | dependencies!: SolutionFile[]; 31 | errors!: Error[]; 32 | includeResolver: IncludeResolver; 33 | 34 | includedFiles!: SolutionFile[]; 35 | 36 | onlyOwnScope!: Scope; 37 | 38 | constructor(public solutionManager: SolutionManager) { 39 | this.includeResolver = new IncludeResolver(this.solutionManager); 40 | } 41 | 42 | async parseAndProcess() { 43 | let [ast, errors] = ParsingHelper.parseFile(this.codeFile); 44 | if (ast) { 45 | this.ast = new ASTScopePopulator(new Scope()).populate(ast); 46 | this.includedFiles = await this.includeResolver.resolveIncludes( 47 | this.ast as ScadFile, 48 | errors 49 | ); 50 | const usedFiles = await this.includeResolver.resolveIncludes( 51 | this.ast as ScadFile, 52 | errors 53 | ); 54 | this.dependencies = [...this.includedFiles, ...usedFiles]; 55 | this.onlyOwnScope = (this.ast as ScadFileWithScope).scope.copy(); 56 | (this.ast as ScadFileWithScope).scope.siblingScopes = [ 57 | ...this.includedFiles.map((f) => f.getExportedScopes()).flat(), 58 | ...usedFiles.map((f) => f.getExportedScopes()).flat(), 59 | PreludeUtil.preludeScope, 60 | ]; 61 | this.ast = this.ast.accept(new SymbolResolver(errors)); 62 | } 63 | this.errors = errors.errors; 64 | } 65 | getCompletionsAtLocation(loc: CodeLocation) { 66 | return CompletionUtil.getSymbolsAtLocation(this.ast!, loc); 67 | } 68 | 69 | getSymbols( 70 | makeSymbol: ( 71 | name: string, 72 | kind: SymbolKind, 73 | fullRange: CodeSpan, 74 | nameRange: CodeSpan, 75 | children: SymType[] 76 | ) => SymType 77 | ) { 78 | const l = new ASTSymbolLister(makeSymbol); 79 | return l.doList(this.ast!); 80 | } 81 | 82 | getFormatted() { 83 | return new ASTPrinter(new FormattingConfiguration()).visitScadFile( 84 | this.ast as ScadFile 85 | ); 86 | } 87 | 88 | getExportedScopes(): Scope[] { 89 | return [ 90 | this.onlyOwnScope, 91 | ...this.includedFiles.map((f) => f.getExportedScopes()).flat(), 92 | ]; 93 | } 94 | getSymbolDeclaration(loc: CodeLocation) { 95 | const pp = new ASTPinpointer(loc).doPinpoint(this.ast!); 96 | if ( 97 | pp instanceof ResolvedLookupExpr || 98 | pp instanceof ResolvedModuleInstantiationStmt 99 | ) { 100 | return pp.resolvedDeclaration; 101 | } 102 | return null; 103 | } 104 | getSymbolDeclarationLocation(loc: CodeLocation): CodeLocation | null { 105 | const decl = this.getSymbolDeclaration(loc); 106 | if (decl) { 107 | return decl.tokens.name ? decl.tokens.name.span.start : null; 108 | } 109 | return null; 110 | } 111 | } 112 | 113 | export default class SolutionManager implements ScadFileProvider { 114 | openedFiles: Map = new Map(); 115 | allFiles: Map = new Map(); 116 | notReadyFiles: Map> = new Map(); 117 | 118 | /** 119 | * Returns a registered solution file for a given path. It supports getting files which have not been fully processed yet. 120 | * @param filePath 121 | */ 122 | async getFile(filePath: string) { 123 | if (!path.isAbsolute(filePath)) { 124 | throw new Error("Path must be absolute and normalized."); 125 | } 126 | let file = this.allFiles.get(filePath); 127 | if (file) { 128 | return file; 129 | } 130 | return await this.notReadyFiles.get(filePath); 131 | } 132 | 133 | async notifyNewFileOpened(filePath: string, contents: string) { 134 | if (!path.isAbsolute(filePath)) { 135 | throw new Error("Path must be absolute and normalized."); 136 | } 137 | const cFile = new CodeFile(filePath, contents); 138 | 139 | this.openedFiles.set(filePath, await this.attachSolutionFile(cFile)); 140 | } 141 | 142 | async notifyFileChanged(filePath: string, contents: string) { 143 | if (!path.isAbsolute(filePath)) { 144 | throw new Error("Path must be absolute and normalized."); 145 | } 146 | const cFile = new CodeFile(filePath, contents); 147 | let sf = this.openedFiles.get(filePath); 148 | if (!sf) { 149 | if (this.notReadyFiles.has(filePath)) { 150 | sf = await this.notReadyFiles.get(filePath) as SolutionFile; 151 | } else { 152 | throw new Error("No such file"); 153 | } 154 | } 155 | sf.codeFile = cFile; 156 | await sf.parseAndProcess(); 157 | } 158 | 159 | notifyFileClosed(filePath: string) { 160 | this.openedFiles.delete(filePath); 161 | this.garbageCollect(); 162 | } 163 | 164 | protected async attachSolutionFile(codeFile: CodeFile) { 165 | const solutionFile = new SolutionFile(this); 166 | solutionFile.codeFile = codeFile; 167 | try { 168 | let resolve!: (s: SolutionFile) => void; 169 | this.notReadyFiles.set( 170 | codeFile.path, 171 | new Promise((r) => (resolve = r)) 172 | ); 173 | await solutionFile.parseAndProcess(); 174 | resolve(solutionFile); 175 | this.allFiles.set(codeFile.path, solutionFile); 176 | return solutionFile; 177 | } finally { 178 | this.notReadyFiles.delete(codeFile.path); 179 | } 180 | } 181 | 182 | /** 183 | * Checks whether a file is already in the solution, and if not it loads it from disk. 184 | * @param filePath The dependent-upon file. 185 | */ 186 | async provideScadFile(filePath: string) { 187 | let f: SolutionFile | undefined = await this.getFile(filePath); 188 | if (f) return f; // the file is already opened or refrenced by antoher 189 | return await this.attachSolutionFile(await CodeFile.load(filePath)); 190 | } 191 | 192 | /** 193 | * Removes dependencies that aren't directly or indirectly referenced in any of the open files to free memory. 194 | */ 195 | protected garbageCollect() { 196 | const gcMarked = new WeakMap(); 197 | function markRecursive(f: SolutionFile) { 198 | gcMarked.set(f, true); 199 | for (const dep of f.dependencies) { 200 | if (!gcMarked.has(dep)) { 201 | markRecursive(dep); 202 | } 203 | } 204 | } 205 | for (const [_, dep] of this.openedFiles) { 206 | markRecursive(dep); 207 | } 208 | for (const [path, f] of this.allFiles) { 209 | if (!gcMarked.has(f)) { 210 | this.allFiles.delete(path); 211 | } 212 | } 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/testdata/ddd.scad: -------------------------------------------------------------------------------- 1 | outer_radius = 32; 2 | inner_radius = 30; 3 | depth = 60; 4 | bearing_depth = 7; 5 | bearing_outer_radius = 11; 6 | backwall_thickness = 2; 7 | shaft_radius = 4; 8 | 9 | inlet_outer_radius = 5; 10 | inlet_inner_radius = 3; 11 | 12 | lock_tooth_depth = 1.5; 13 | lock_tooth_width = 10; 14 | lock_tooth_inner_cut_radius = inner_radius - 2; 15 | 16 | lock_tooth_offset_angle = 30; 17 | lock_tooth_slack = 0.2; 18 | 19 | exhaust_hole_radius = 2; 20 | exhaust_hole_distance = 3.5; 21 | 22 | // water collection basin 23 | wcb_depth = 6; 24 | wcb_radius = 21; 25 | wcb_wall_thickness = backwall_thickness; 26 | 27 | // water collection pipe 28 | wcp_radius = 3.2; 29 | 30 | $fn = 128; // cylinder resolution 31 | 32 | part = ""; 33 | 34 | module enclosure() union() { 35 | if(true) difference() { 36 | 37 | // hull 38 | union() { 39 | cylinder(h=depth, r=outer_radius); 40 | // stand cube 41 | color([1, 1, 0]) translate([-outer_radius, -outer_radius, 0]) cube([outer_radius * 2, outer_radius, depth]); 42 | translate([0.75 * outer_radius, 0, 0.5 * depth]) rotate([90, 0, 180]) cylinder(r = inlet_outer_radius, h = 1.5 * outer_radius); 43 | 44 | // // wcb inner wall 45 | // color("red") difference() { 46 | // color("green") translate([0, 0, -wcb_depth]) cylinder(h = wcb_depth, r = bearing_outer_radius + 1 + wcb_wall_thickness); 47 | // translate([0, 0, -wcb_depth - 1]) cylinder(h = wcb_depth + 1, r = bearing_outer_radius + 1); 48 | // } 49 | // // wcb outer wall 50 | // difference() { 51 | // color("green") translate([0, 0, -wcb_depth]) cylinder(h = wcb_depth, r = wcb_radius); 52 | // translate([0, 0, -wcb_depth - 1]) cylinder(h = wcb_depth + 1, r = wcb_radius - wcb_wall_thickness); 53 | // rotate([0, 0, -45]) translate([-wcp_radius, -wcb_radius, -wcb_depth + backwall_thickness]) cube([wcp_radius * 2, outer_radius - bearing_outer_radius * 1, wcb_depth]); 54 | // } 55 | 56 | 57 | 58 | // // wcb lid 59 | // color([1, 0, 0, 0.5]) difference() { 60 | // color("green") translate([0, 0, -wcb_depth]) cylinder(h = backwall_thickness, r = wcb_radius); 61 | // translate([0, 0, -wcb_depth - 1]) cylinder(h = backwall_thickness + 2, r = bearing_outer_radius + 1 ); 62 | // } 63 | 64 | // color("pink") translate([-outer_radius + wcp_radius + 3, -outer_radius + wcp_radius + 3, -wcb_depth]) { 65 | // difference() { 66 | // union() { 67 | // cylinder(h = wcb_depth, r = wcp_radius + backwall_thickness); 68 | // rotate([0, 0, -45]) translate([-wcb_depth / 2, 0, 0]) cube([wcp_radius * 2, outer_radius - bearing_outer_radius * 1.4, wcb_depth + backwall_thickness]); 69 | // } 70 | // rotate([0, 0, -45]) translate([-wcb_depth / 2 + backwall_thickness / 2, 0, backwall_thickness]) cube([wcp_radius * 2 - 1 * backwall_thickness, outer_radius - bearing_outer_radius * 1.4, wcb_depth ]); 71 | // translate([0, 0, 2]) cylinder(h = wcb_depth - 2, r = wcp_radius); 72 | // } 73 | // } 74 | // rotate([0, 0, -45]) translate([-wcb_depth / 2, -backwall_thickness, 2]) cube([wcp_radius * 2 - backwall_thickness * 2, outer_radius - bearing_outer_radius * 1.4, wcb_depth - 2]); 75 | 76 | } 77 | 78 | // // wcp 79 | // color("blue") translate([-outer_radius + wcp_radius + 3, -outer_radius + wcp_radius + 3, -1]) { 80 | // cylinder(h = depth + backwall_thickness + lock_tooth_depth - 1, r = wcp_radius); 81 | // } 82 | 83 | // // wcp 84 | // color("blue") translate([outer_radius - wcp_radius - 3, -outer_radius + wcp_radius + 3, -1]) { 85 | // cylinder(h = depth + backwall_thickness + lock_tooth_depth - 1, r = wcp_radius); 86 | // } 87 | 88 | 89 | // inside 90 | translate([0, 0, backwall_thickness + bearing_depth]) { 91 | cylinder(h = depth, r=inner_radius); 92 | } 93 | 94 | 95 | translate([0, 0, -1]) { 96 | // bearing hole 97 | cylinder(h = bearing_depth + 1, r=bearing_outer_radius); 98 | 99 | // shaft hole 100 | cylinder(h = 2000, r=shaft_radius + 1); 101 | } 102 | 103 | // inlet inside 104 | translate([0.75*outer_radius, 1, 0.5 * depth]) rotate([90, 0, 180]) cylinder(r = inlet_inner_radius, h = 1.5 * outer_radius); 105 | 106 | for(angle = [0 : 60 : 360]) rotate([0, 0, angle]) translate([0, bearing_outer_radius + exhaust_hole_radius + exhaust_hole_distance, -1]) cylinder(r = exhaust_hole_radius, h = bearing_depth + backwall_thickness + 5); 107 | 108 | } 109 | // lock tools 110 | color("cyan") intersection() { 111 | difference() { 112 | for(angle = [0 : 60 : 360]) { 113 | rotate([0, 0, angle]) translate([-outer_radius, -lock_tooth_width / 2, depth - lock_tooth_depth]) cube([outer_radius * 2, lock_tooth_width, lock_tooth_depth]); 114 | } 115 | cylinder(h = depth + 50, r=lock_tooth_inner_cut_radius); 116 | } 117 | translate([0, 0, depth - lock_tooth_depth - 1]) cylinder(h = lock_tooth_depth + 1, r=inner_radius); 118 | } 119 | } 120 | 121 | module lid() difference() { 122 | translate([0, 0, -lock_tooth_depth - lock_tooth_slack]) rotate([0, 0, lock_tooth_offset_angle]) union() { 123 | color("red") intersection() { 124 | difference() { 125 | for(angle = [0 : 60 : 360]) { 126 | rotate([0, 0, angle]) translate([-outer_radius, -lock_tooth_width / 2, depth - lock_tooth_depth]) cube([outer_radius * 2, lock_tooth_width, lock_tooth_depth]); 127 | } 128 | } 129 | translate([0, 0, depth - lock_tooth_depth - 1]) cylinder(h = lock_tooth_depth + 1, r=inner_radius); 130 | } 131 | color("gray") { 132 | translate([0, 0, depth - lock_tooth_depth]) cylinder(h = 2 *lock_tooth_depth + lock_tooth_slack, r = lock_tooth_inner_cut_radius); 133 | translate([0, 0, depth - lock_tooth_depth + 2 * lock_tooth_depth + lock_tooth_slack]) cylinder(h = backwall_thickness, r = outer_radius); 134 | // translate([0, 0, depth - lock_tooth_depth + 2 * lock_tooth_depth + lock_tooth_slack + backwall_thickness]) cylinder(h = bearing_depth, r = bearing_outer_radius * 1.2); 135 | } 136 | translate([0, 0, depth - lock_tooth_depth * 2]) cylinder(h = bearing_depth + 1, r = bearing_outer_radius + 3); 137 | 138 | for(angle = [0 : 60 : 360]) rotate([0, 0, angle]) translate([0, bearing_outer_radius + 10, depth]) cylinder(r = 3, h = bearing_depth - lock_tooth_depth - 0.5); 139 | } 140 | 141 | // bearing hole 142 | translate([0, 0, depth - lock_tooth_depth * 2]) cylinder(h = bearing_depth + 1, r = bearing_outer_radius); 143 | 144 | // shaft hole 145 | cylinder(h = 2000, r=shaft_radius + 1); 146 | } 147 | 148 | 149 | module shaft_coupler() { 150 | translate([0, 0, depth + bearing_depth + backwall_thickness]) { 151 | difference() { 152 | cylinder(r = shaft_radius + 1, h = 20); 153 | translate([0, 0, -1]) cylinder(r = shaft_radius, h = 10 + 2); 154 | translate([0, 0, 10]) cylinder(r = 1, h = 10 + 2); 155 | } 156 | } 157 | } 158 | 159 | module motor_holder() { 160 | color("lightblue") 161 | translate([0, 0, depth + bearing_depth + backwall_thickness + 20]) { 162 | difference() { 163 | translate([-15, -(outer_radius), 0]) cube([30, outer_radius + 10, 10]); 164 | translate([0, 0, -1]) { 165 | intersection() { 166 | cylinder(r = 10.3, h = 30); 167 | cube([21, 15.2, 50], center = true); 168 | 169 | } 170 | } 171 | translate([-10, -outer_radius / 2 - 13, -1]) cube([20, outer_radius - 15, 20]); 172 | } 173 | } 174 | 175 | 176 | } 177 | 178 | module base() { 179 | color("green") 180 | difference() { 181 | translate([-outer_radius - 3, -outer_radius - 3, -3]) cube([outer_radius * 2 + 6, 7, depth + 50]); 182 | translate([-outer_radius, -outer_radius, -.04]) cube([outer_radius * 2 + 0.4, outer_radius, depth + 0.8]); 183 | translate([0, 0, depth + bearing_depth + backwall_thickness + 20]) 184 | translate([-15, -(outer_radius), 0]) cube([30, outer_radius + 10, 10]); 185 | translate([-outer_radius * 1.5 / 2, -outer_radius, depth - 1]) cube([outer_radius * 1.5 + 0.4, outer_radius * 0.5, 5]); 186 | } 187 | } 188 | 189 | 190 | 191 | if (part == "enclosure") { 192 | enclosure(); 193 | } else if (part == "lid") { 194 | lid(); 195 | } else { 196 | enclosure(); 197 | lid(); 198 | motor_holder(); 199 | shaft_coupler(); 200 | base(); 201 | 202 | } -------------------------------------------------------------------------------- /src/testdata/hull.scad: -------------------------------------------------------------------------------- 1 | // https://github.com/OskarLinde/scad-utils/blob/master/hull.scad 2 | 3 | // NOTE: this code uses 4 | // * experimental let() syntax 5 | // * experimental list comprehension syntax 6 | // * search() bugfix and feature addition 7 | // * vector min()/max() 8 | 9 | // Calculates the convex hull of a set of points. 10 | // The result is expressed in point indices. 11 | // If the points are collinear (or 2d), the result is a convex 12 | // polygon [i1,i2,i3,...], otherwise a triangular 13 | // polyhedron [[i1,i2,i3],[i2,i3,i4],...] 14 | 15 | function hull(points) = 16 | !(len(points) > 0) ? [] : 17 | len(points[0]) == 2 ? convexhull2d(points) : 18 | len(points[0]) == 3 ? convexhull3d(points) : []; 19 | 20 | epsilon = 1e-9; 21 | 22 | // 2d version 23 | function convexhull2d(points) = 24 | len(points) < 3 ? [] : let( 25 | a=0, b=1, 26 | 27 | c = find_first_noncollinear([a,b], points, 2) 28 | 29 | ) c == len(points) ? convexhull_collinear(points) : let( 30 | 31 | remaining = [ for (i = [2:len(points)-1]) if (i != c) i ], 32 | 33 | polygon = area_2d(points[a], points[b], points[c]) > 0 ? [a,b,c] : [b,a,c] 34 | 35 | ) convex_hull_iterative_2d(points, polygon, remaining); 36 | 37 | 38 | // Adds the remaining points one by one to the convex hull 39 | function convex_hull_iterative_2d(points, polygon, remaining, i_=0) = i_ >= len(remaining) ? polygon : 40 | let ( 41 | // pick a point 42 | i = remaining[i_], 43 | 44 | // find the segments that are in conflict with the point (point not inside) 45 | conflicts = find_conflicting_segments(points, polygon, points[i]) 46 | 47 | // no conflicts, skip point and move on 48 | ) len(conflicts) == 0 ? convex_hull_iterative_2d(points, polygon, remaining, i_+1) : let( 49 | 50 | // find the first conflicting segment and the first not conflicting 51 | // conflict will be sorted, if not wrapping around, do it the easy way 52 | polygon = remove_conflicts_and_insert_point(polygon, conflicts, i) 53 | ) convex_hull_iterative_2d( 54 | points, 55 | polygon, 56 | remaining, 57 | i_+1 58 | ); 59 | 60 | function find_conflicting_segments(points, polygon, point) = [ 61 | for (i = [0:len(polygon)-1]) let(j = (i+1) % len(polygon)) 62 | if (area_2d(points[polygon[i]], points[polygon[j]], point) < 0) 63 | i 64 | ]; 65 | 66 | // remove the conflicting segments from the polygon 67 | function remove_conflicts_and_insert_point(polygon, conflicts, point) = 68 | conflicts[0] == 0 ? let( 69 | nonconflicting = [ for(i = [0:len(polygon)-1]) if (!contains(conflicts, i)) i ], 70 | new_indices = concat(nonconflicting, (nonconflicting[len(nonconflicting)-1]+1) % len(polygon)), 71 | polygon = concat([ for (i = new_indices) polygon[i] ], point) 72 | ) polygon : let( 73 | prior_to_first_conflict = [ for(i = [0:1:min(conflicts)]) polygon[i] ], 74 | after_last_conflict = [ for(i = [max(conflicts)+1:1:len(polygon)-1]) polygon[i] ], 75 | polygon = concat(prior_to_first_conflict, point, after_last_conflict) 76 | ) polygon; 77 | 78 | 79 | // 3d version 80 | function convexhull3d(points) = 81 | len(points) < 3 ? [ for(i = [0:1:len(points)-1]) i ] : let ( 82 | 83 | // start with a single triangle 84 | a=0, b=1, c=2, 85 | plane = plane(points,a,b,c), 86 | 87 | d = find_first_noncoplanar(plane, points, 3) 88 | 89 | ) d == len(points) ? /* all coplanar*/ let ( 90 | 91 | pts2d = [ for (p = points) plane_project(p, points[a], points[b], points[c]) ], 92 | hull2d = convexhull2d(pts2d) 93 | 94 | ) hull2d : let( 95 | 96 | remaining = [for (i = [3:len(points)-1]) if (i != d) i], 97 | 98 | // Build an initial tetrahedron 99 | 100 | // swap b,c if d is in front of triangle t 101 | bc = in_front(plane, points[d]) ? [c,b] : [b,c], 102 | b = bc[0], c = bc[1], 103 | 104 | triangles = [ 105 | [a,b,c], 106 | [d,b,a], 107 | [c,d,a], 108 | [b,d,c], 109 | ], 110 | 111 | // calculate the plane equations 112 | planes = [ for (t = triangles) plane(points, t[0], t[1], t[2]) ] 113 | 114 | ) convex_hull_iterative(points, triangles, planes, remaining); 115 | 116 | // A plane equation (normal, offset) 117 | function plane(points, a, b, c) = let( 118 | normal = unit(cross(points[c]-points[a], points[b]-points[a])) 119 | ) [ 120 | normal, 121 | normal * points[a] 122 | ]; 123 | 124 | // Adds the remaining points one by one to the convex hull 125 | function convex_hull_iterative(points, triangles, planes, remaining, i_=0) = i_ >= len(remaining) ? triangles : 126 | let ( 127 | // pick a point 128 | i = remaining[i_], 129 | 130 | // find the triangles that are in conflict with the point (point not inside) 131 | conflicts = find_conflicts(points[i], planes), 132 | 133 | // for all triangles that are in conflict, collect their halfedges 134 | halfedges = [ 135 | for(c = conflicts) 136 | for(i = [0:2]) let(j = (i+1)%3) 137 | [triangles[c][i], triangles[c][j]] 138 | ], 139 | 140 | // find the outer perimeter of the set of conflicting triangles 141 | horizon = remove_internal_edges(halfedges), 142 | 143 | // generate a new triangle for each horizon halfedge together with the picked point i 144 | new_triangles = [ for (h = horizon) concat(h,i) ], 145 | 146 | // calculate the corresponding plane equations 147 | new_planes = [ for (t = new_triangles) plane(points, t[0], t[1], t[2]) ] 148 | 149 | ) convex_hull_iterative( 150 | points, 151 | // remove the conflicting triangles and add the new ones 152 | concat(remove_elements(triangles, conflicts), new_triangles), 153 | concat(remove_elements(planes, conflicts), new_planes), 154 | remaining, 155 | i_+1 156 | ); 157 | 158 | function convexhull_collinear(points) = let( 159 | n = points[1] - points[0], 160 | a = points[0], 161 | points1d = [ for(p = points) (p-a)*n ], 162 | min_i = min_index(points1d), 163 | max_i = max_index(points1d) 164 | ) [ min_i, max_i ]; 165 | 166 | function min_index(values,min_,min_i_,i_) = 167 | i_ == undef ? min_index(values,values[0],0,1) : 168 | i_ >= len(values) ? min_i_ : 169 | values[i_] < min_ ? min_index(values,values[i_],i_,i_+1) 170 | : min_index(values,min_,min_i_,i_+1); 171 | 172 | function max_index(values,max_,max_i_,i_) = 173 | i_ == undef ? max_index(values,values[0],0,1) : 174 | i_ >= len(values) ? max_i_ : 175 | values[i_] > max_ ? max_index(values,values[i_],i_,i_+1) 176 | : max_index(values,max_,max_i_,i_+1); 177 | 178 | function remove_elements(array, elements) = [ 179 | for (i = [0:len(array)-1]) 180 | if (!search(i, elements)) 181 | array[i] 182 | ]; 183 | 184 | function remove_internal_edges(halfedges) = [ 185 | for (h = halfedges) 186 | if (!contains(halfedges, reverse(h))) 187 | h 188 | ]; 189 | 190 | function plane_project(point, a, b, c) = let( 191 | u = b-a, 192 | v = c-a, 193 | n = cross(u,v), 194 | w = cross(n,u), 195 | relpoint = point-a 196 | ) [relpoint * u, relpoint * w]; 197 | 198 | function plane_unproject(point, a, b, c) = let( 199 | u = b-a, 200 | v = c-a, 201 | n = cross(u,v), 202 | w = cross(n,u) 203 | ) a + point[0] * u + point[1] * w; 204 | 205 | function reverse(arr) = [ for (i = [len(arr)-1:-1:0]) arr[i] ]; 206 | 207 | function contains(arr, element) = search([element],arr)[0] != [] ? true : false; 208 | 209 | function find_conflicts(point, planes) = [ 210 | for (i = [0:len(planes)-1]) 211 | if (in_front(planes[i], point)) 212 | i 213 | ]; 214 | 215 | function find_first_noncollinear(line, points, i) = 216 | i >= len(points) ? len(points) : 217 | collinear(points[line[0]], 218 | points[line[1]], 219 | points[i]) ? find_first_noncollinear(line, points, i+1) 220 | : i; 221 | 222 | function find_first_noncoplanar(plane, points, i) = 223 | i >= len(points) ? len(points) : 224 | coplanar(plane, points[i]) ? find_first_noncoplanar(plane, points, i+1) 225 | : i; 226 | 227 | function distance(plane, point) = plane[0] * point - plane[1]; 228 | 229 | function in_front(plane, point) = distance(plane, point) > epsilon; 230 | 231 | function coplanar(plane, point) = abs(distance(plane,point)) <= epsilon; 232 | 233 | function unit(v) = v/norm(v); 234 | 235 | function area_2d(a,b,c) = ( 236 | a[0] * (b[1] - c[1]) + 237 | b[0] * (c[1] - a[1]) + 238 | c[0] * (a[1] - b[1])) / 2; 239 | 240 | function collinear(a,b,c) = abs(area_2d(a,b,c)) < epsilon; 241 | 242 | function spherical(cartesian) = [ 243 | atan2(cartesian[1], cartesian[0]), 244 | asin(cartesian[2]) 245 | ]; 246 | 247 | function cartesian(spherical) = [ 248 | cos(spherical[1]) * cos(spherical[0]), 249 | cos(spherical[1]) * sin(spherical[0]), 250 | sin(spherical[1]) 251 | ]; 252 | 253 | 254 | /// TESTCODE 255 | 256 | 257 | phi = 1.618033988749895; 258 | 259 | testpoints_on_sphere = [ for(p = 260 | [ 261 | [1,phi,0], [-1,phi,0], [1,-phi,0], [-1,-phi,0], 262 | [0,1,phi], [0,-1,phi], [0,1,-phi], [0,-1,-phi], 263 | [phi,0,1], [-phi,0,1], [phi,0,-1], [-phi,0,-1] 264 | ]) 265 | unit(p) 266 | ]; 267 | 268 | testpoints_spherical = [ for(p = testpoints_on_sphere) spherical(p) ]; 269 | testpoints_circular = [ for(a = [0:15:360-epsilon]) [cos(a),sin(a)] ]; 270 | 271 | testpoints_coplanar = let(u = unit([1,3,7]), v = unit([-2,1,-2])) [ for(i = [1:10]) rands(-1,1,1)[0] * u + rands(-1,1,1)[0] * v ]; 272 | 273 | testpoints_collinear_2d = let(u = unit([5,3])) [ for(i = [1:20]) rands(-1,1,1)[0] * u ]; 274 | testpoints_collinear_3d = let(u = unit([5,3,-5])) [ for(i = [1:20]) rands(-1,1,1)[0] * u ]; 275 | 276 | testpoints2d = 20 * [for (i = [1:10]) concat(rands(-1,1,2))]; 277 | testpoints3d = 20 * [for (i = [1:50]) concat(rands(-1,1,3))]; 278 | 279 | // All points are on the sphere, no point should be red 280 | translate([-50,0]) visualize_hull(20*testpoints_on_sphere); 281 | 282 | // 2D points 283 | translate([50,0]) visualize_hull(testpoints2d); 284 | 285 | // All points on a circle, no point should be red 286 | translate([0,50]) visualize_hull(20*testpoints_circular); 287 | 288 | // All points 3d but collinear 289 | translate([0,-50]) visualize_hull(20*testpoints_coplanar); 290 | 291 | // Collinear 292 | translate([50,50]) visualize_hull(20*testpoints_collinear_2d); 293 | 294 | // Collinear 295 | translate([-50,50]) visualize_hull(20*testpoints_collinear_3d); 296 | 297 | // 3D points 298 | visualize_hull(testpoints3d); 299 | 300 | 301 | module visualize_hull(points) { 302 | 303 | hull = hull(points); 304 | 305 | %if (len(hull) > 0 && len(hull[0]) > 0) 306 | polyhedron(points=points, faces = hull); 307 | else 308 | polyhedron(points=points, faces = [hull]); 309 | 310 | for (i = [0:len(points)-1]) assign(p = points[i], $fn = 16) { 311 | translate(p) { 312 | if (hull_contains_index(hull,i)) { 313 | color("blue") sphere(1); 314 | } else { 315 | color("red") sphere(1); 316 | } 317 | } 318 | } 319 | 320 | function hull_contains_index(hull, index) = 321 | search(index,hull,1,0) || 322 | search(index,hull,1,1) || 323 | search(index,hull,1,2); 324 | 325 | } -------------------------------------------------------------------------------- /src/ASTAssembler.ts: -------------------------------------------------------------------------------- 1 | import AssignmentNode from "./ast/AssignmentNode"; 2 | import ASTNode from "./ast/ASTNode"; 3 | import ASTVisitor from "./ast/ASTVisitor"; 4 | import ErrorNode from "./ast/ErrorNode"; 5 | import { 6 | AnonymousFunctionExpr, 7 | ArrayLookupExpr, 8 | AssertExpr, 9 | BinaryOpExpr, 10 | EchoExpr, 11 | FunctionCallExpr, 12 | GroupingExpr, 13 | LcEachExpr, 14 | LcForCExpr, 15 | LcForExpr, 16 | LcIfExpr, 17 | LcLetExpr, 18 | LetExpr, 19 | LiteralExpr, 20 | LookupExpr, 21 | MemberLookupExpr, 22 | RangeExpr, 23 | TernaryExpr, 24 | UnaryOpExpr, 25 | VectorExpr, 26 | } from "./ast/expressions"; 27 | import ScadFile from "./ast/ScadFile"; 28 | import { 29 | BlockStmt, 30 | FunctionDeclarationStmt, 31 | IfElseStatement, 32 | IncludeStmt, 33 | ModuleDeclarationStmt, 34 | ModuleInstantiationStmt, 35 | NoopStmt, 36 | UseStmt, 37 | } from "./ast/statements"; 38 | import Token from "./Token"; 39 | 40 | /** 41 | * This class walks through the AST and generates arrays of tokens and function, which themselves return the same array. 42 | * It can be used to search through the AST, or determine the ranges of AST nodes. 43 | */ 44 | export default abstract class ASTAssembler implements ASTVisitor { 45 | protected abstract processAssembledNode( 46 | t: (Token | (() => R))[], 47 | self: ASTNode 48 | ): R; 49 | visitScadFile(n: ScadFile): R { 50 | return this.processAssembledNode( 51 | [...n.statements.map((stmt) => () => stmt.accept(this)), n.tokens.eot], 52 | n 53 | ); 54 | } 55 | visitAssignmentNode(n: AssignmentNode): R { 56 | const arr: (Token | (() => R))[] = []; 57 | if (n.tokens.name) { 58 | arr.push(n.tokens.name); 59 | } 60 | if (n.tokens.equals) { 61 | arr.push(n.tokens.equals); 62 | } 63 | if (n.value) { 64 | // n.value won't be modified, so we can assert it is not null 65 | arr.push(() => n.value!.accept(this)); 66 | } 67 | if (n.tokens.trailingCommas) { 68 | arr.push(...n.tokens.trailingCommas); 69 | } 70 | if (n.tokens.semicolon) { 71 | arr.push(n.tokens.semicolon); 72 | } 73 | return this.processAssembledNode(arr, n); 74 | } 75 | visitUnaryOpExpr(n: UnaryOpExpr): R { 76 | return this.processAssembledNode( 77 | [n.tokens.operator, () => n.right.accept(this)], 78 | n 79 | ); 80 | } 81 | visitBinaryOpExpr(n: BinaryOpExpr): R { 82 | return this.processAssembledNode( 83 | [ 84 | () => n.left.accept(this), 85 | n.tokens.operator, 86 | () => n.right.accept(this), 87 | ], 88 | n 89 | ); 90 | } 91 | visitTernaryExpr(n: TernaryExpr): R { 92 | return this.processAssembledNode( 93 | [ 94 | () => n.cond.accept(this), 95 | n.tokens.questionMark, 96 | () => n.ifExpr.accept(this), 97 | n.tokens.colon, 98 | () => n.elseExpr.accept(this), 99 | ], 100 | n 101 | ); 102 | } 103 | visitArrayLookupExpr(n: ArrayLookupExpr): R { 104 | return this.processAssembledNode( 105 | [ 106 | () => n.array.accept(this), 107 | n.tokens.firstBracket, 108 | () => n.index.accept(this), 109 | n.tokens.secondBracket, 110 | ], 111 | n 112 | ); 113 | } 114 | visitLiteralExpr(n: LiteralExpr): R { 115 | return this.processAssembledNode([n.tokens.literalToken], n); 116 | } 117 | visitRangeExpr(n: RangeExpr): R { 118 | if (n.step && n.tokens.secondColon) { 119 | let parts = [() => n.begin.accept(this), n.tokens.firstColon]; 120 | if (n.step) { 121 | parts.push(() => n!.step!.accept(this)); 122 | } 123 | 124 | parts.push(n.tokens.secondColon, () => n.end.accept(this)); 125 | return this.processAssembledNode(parts, n); 126 | } 127 | return this.processAssembledNode( 128 | [ 129 | () => n.begin.accept(this), 130 | n.tokens.firstColon, 131 | () => n.end.accept(this), 132 | ], 133 | n 134 | ); 135 | } 136 | visitVectorExpr(n: VectorExpr): R { 137 | const arr = []; 138 | arr.push(n.tokens.firstBracket); 139 | for (let i = 0; i < n.children.length; i++) { 140 | arr.push(() => n.children[i].accept(this)); 141 | if (i < n.children.length - 1) { 142 | arr.push(n.tokens.commas[i]); 143 | } 144 | } 145 | arr.push(...n.tokens.commas.slice(n.children.length)); 146 | arr.push(n.tokens.secondBracket); 147 | return this.processAssembledNode(arr, n); 148 | } 149 | visitLookupExpr(n: LookupExpr): R { 150 | return this.processAssembledNode([n.tokens.identifier], n); 151 | } 152 | visitMemberLookupExpr(n: MemberLookupExpr): R { 153 | return this.processAssembledNode( 154 | [() => n.expr.accept(this), n.tokens.dot, n.tokens.memberName], 155 | n 156 | ); 157 | } 158 | visitFunctionCallExpr(n: FunctionCallExpr): R { 159 | return this.processAssembledNode( 160 | [ 161 | () => n.callee.accept(this), 162 | n.tokens.firstParen, 163 | ...n.args.map((a) => () => a.accept(this)), 164 | n.tokens.secondParen, 165 | ], 166 | n 167 | ); 168 | } 169 | visitLetExpr(n: LetExpr): R { 170 | return this.processAssembledNode( 171 | [ 172 | n.tokens.name, 173 | n.tokens.firstParen, 174 | ...n.args.map((a) => () => a.accept(this)), 175 | n.tokens.secondParen, 176 | ], 177 | n 178 | ); 179 | } 180 | visitAssertExpr(n: AssertExpr): R { 181 | return this.processAssembledNode( 182 | [ 183 | n.tokens.name, 184 | n.tokens.firstParen, 185 | ...n.args.map((a) => () => a.accept(this)), 186 | n.tokens.secondParen, 187 | ], 188 | n 189 | ); 190 | } 191 | visitEchoExpr(n: EchoExpr): R { 192 | return this.processAssembledNode( 193 | [ 194 | n.tokens.name, 195 | n.tokens.firstParen, 196 | ...n.args.map((a) => () => a.accept(this)), 197 | n.tokens.secondParen, 198 | ], 199 | n 200 | ); 201 | } 202 | visitLcIfExpr(n: LcIfExpr): R { 203 | const elseStuff: (Token | (() => R))[] = []; 204 | if (n.elseExpr && n.tokens.elseKeyword) { 205 | elseStuff.push(n.tokens.elseKeyword, () => n.elseExpr!.accept(this)); 206 | } 207 | return this.processAssembledNode( 208 | [ 209 | n.tokens.ifKeyword, 210 | n.tokens.firstParen, 211 | () => n.cond.accept(this), 212 | n.tokens.secondParen, 213 | () => n.ifExpr.accept(this), 214 | ...elseStuff, 215 | ], 216 | n 217 | ); 218 | } 219 | visitLcEachExpr(n: LcEachExpr): R { 220 | return this.processAssembledNode( 221 | [n.tokens.eachKeyword, () => n.expr.accept(this)], 222 | n 223 | ); 224 | } 225 | visitLcForExpr(n: LcForExpr): R { 226 | return this.processAssembledNode( 227 | [ 228 | n.tokens.forKeyword, 229 | n.tokens.firstParen, 230 | ...n.args.map((a) => () => a.accept(this)), 231 | n.tokens.secondParen, 232 | () => n.expr.accept(this), 233 | ], 234 | n 235 | ); 236 | } 237 | visitLcForCExpr(n: LcForCExpr): R { 238 | return this.processAssembledNode( 239 | [ 240 | n.tokens.forKeyword, 241 | n.tokens.firstParen, 242 | ...n.args.map((a) => () => a.accept(this)), 243 | n.tokens.firstSemicolon, 244 | () => n.cond.accept(this), 245 | n.tokens.secondSemicolon, 246 | ...n.incrArgs.map((a) => () => a.accept(this)), 247 | n.tokens.secondParen, 248 | () => n.expr.accept(this), 249 | ], 250 | n 251 | ); 252 | } 253 | visitLcLetExpr(n: LcLetExpr): R { 254 | return this.processAssembledNode( 255 | [ 256 | n.tokens.letKeyword, 257 | n.tokens.firstParen, 258 | ...n.args.map((a) => () => a.accept(this)), 259 | n.tokens.secondParen, 260 | () => n.expr.accept(this), 261 | ], 262 | n 263 | ); 264 | } 265 | visitGroupingExpr(n: GroupingExpr): R { 266 | return this.processAssembledNode( 267 | [n.tokens.firstParen, () => n.inner.accept(this), n.tokens.secondParen], 268 | n 269 | ); 270 | } 271 | visitUseStmt(n: UseStmt): R { 272 | return this.processAssembledNode( 273 | [n.tokens.useKeyword, n.tokens.filename], 274 | n 275 | ); 276 | } 277 | 278 | visitIncludeStmt(n: IncludeStmt): R { 279 | return this.processAssembledNode( 280 | [n.tokens.includeKeyword, n.tokens.filename], 281 | n 282 | ); 283 | } 284 | visitModuleInstantiationStmt(n: ModuleInstantiationStmt): R { 285 | const arr = []; 286 | arr.push(...n.tokens.modifiersInOrder); 287 | arr.push(n.tokens.name); 288 | arr.push(n.tokens.firstParen); 289 | arr.push(...n.args.map((a) => () => a.accept(this))); 290 | arr.push(n.tokens.secondParen); 291 | if ( 292 | n.child && 293 | !(n.child instanceof ErrorNode && n.child.tokens.tokens.length === 0) // omit zero-width error nodes since they contribute nothing. 294 | ) { 295 | arr.push(() => n.child!.accept(this)); 296 | } 297 | 298 | return this.processAssembledNode(arr, n); 299 | } 300 | visitModuleDeclarationStmt(n: ModuleDeclarationStmt): R { 301 | return this.processAssembledNode( 302 | [ 303 | n.tokens.moduleKeyword, 304 | n.tokens.name, 305 | n.tokens.firstParen, 306 | ...n.definitionArgs.map((a) => () => a.accept(this)), 307 | n.tokens.secondParen, 308 | () => n.stmt.accept(this), 309 | ], 310 | n 311 | ); 312 | } 313 | visitFunctionDeclarationStmt(n: FunctionDeclarationStmt): R { 314 | return this.processAssembledNode( 315 | [ 316 | n.tokens.functionKeyword, 317 | n.tokens.name, 318 | n.tokens.firstParen, 319 | ...n.definitionArgs.map((a) => () => a.accept(this)), 320 | n.tokens.secondParen, 321 | () => n.expr.accept(this), 322 | n.tokens.semicolon, 323 | ], 324 | n 325 | ); 326 | } 327 | visitBlockStmt(n: BlockStmt): R { 328 | return this.processAssembledNode( 329 | [ 330 | n.tokens.firstBrace, 331 | ...n.children.map((a) => () => a.accept(this)), 332 | n.tokens.secondBrace, 333 | ], 334 | n 335 | ); 336 | } 337 | visitNoopStmt(n: NoopStmt): R { 338 | return this.processAssembledNode([n.tokens.semicolon], n); 339 | } 340 | visitIfElseStatement(n: IfElseStatement): R { 341 | const arr = []; 342 | arr.push(...n.tokens.modifiersInOrder); 343 | arr.push(n.tokens.ifKeyword); 344 | arr.push(n.tokens.firstParen); 345 | arr.push(() => n.cond.accept(this)); 346 | arr.push(n.tokens.secondParen); 347 | arr.push(() => n.thenBranch.accept(this)); 348 | if (n.elseBranch) { 349 | arr.push(n!.tokens!.elseKeyword!, () => n!.elseBranch!.accept(this)); 350 | } 351 | return this.processAssembledNode(arr, n); 352 | } 353 | visitAnonymousFunctionExpr(n: AnonymousFunctionExpr): R { 354 | return this.processAssembledNode( 355 | [ 356 | n.tokens.functionKeyword, 357 | n.tokens.firstParen, 358 | ...n.definitionArgs.map((a) => () => a.accept(this)), 359 | n.tokens.secondParen, 360 | () => n.expr.accept(this), 361 | ], 362 | n 363 | ); 364 | } 365 | visitErrorNode(n: ErrorNode): R { 366 | return this.processAssembledNode([...n.tokens.tokens], n); 367 | } 368 | } 369 | -------------------------------------------------------------------------------- /src/semantic/ASTScopePopulator.ts: -------------------------------------------------------------------------------- 1 | import { notStrictEqual } from "assert"; 2 | import AssignmentNode, { AssignmentNodeRole } from "../ast/AssignmentNode"; 3 | import ASTNode from "../ast/ASTNode"; 4 | import ASTVisitor from "../ast/ASTVisitor"; 5 | import ErrorNode from "../ast/ErrorNode"; 6 | import { 7 | AnonymousFunctionExpr, 8 | ArrayLookupExpr, 9 | AssertExpr, 10 | BinaryOpExpr, 11 | EchoExpr, 12 | FunctionCallExpr, 13 | GroupingExpr, 14 | LcEachExpr, 15 | LcForCExpr, 16 | LcForExpr, 17 | LcIfExpr, 18 | LcLetExpr, 19 | LetExpr, 20 | LiteralExpr, 21 | LookupExpr, 22 | MemberLookupExpr, 23 | RangeExpr, 24 | TernaryExpr, 25 | UnaryOpExpr, 26 | VectorExpr, 27 | } from "../ast/expressions"; 28 | import ScadFile from "../ast/ScadFile"; 29 | import { 30 | BlockStmt, 31 | FunctionDeclarationStmt, 32 | IfElseStatement, 33 | IncludeStmt, 34 | ModuleDeclarationStmt, 35 | ModuleInstantiationStmt, 36 | NoopStmt, 37 | Statement, 38 | UseStmt, 39 | } from "../ast/statements"; 40 | import { 41 | AnonymousFunctionExprWithScope, 42 | BlockStmtWithScope, 43 | FunctionDeclarationStmtWithScope, 44 | LcForCExprWithScope, 45 | LcForExprWithScope, 46 | LcLetExprWithScope, 47 | LetExprWithScope, 48 | ModuleDeclarationStmtWithScope, 49 | ModuleInstantiationStmtWithScope, 50 | ScadFileWithScope, 51 | } from "./nodesWithScopes"; 52 | import Scope from "./Scope"; 53 | 54 | export default class ASTScopePopulator implements ASTVisitor { 55 | nearestScope: Scope; 56 | constructor(rootScope: Scope) { 57 | this.nearestScope = rootScope; 58 | } 59 | 60 | protected copyWithNewNearestScope(newScope: Scope) { 61 | return new ASTScopePopulator(newScope); 62 | } 63 | populate(n: ASTNode) { 64 | return n.accept(this); 65 | } 66 | visitScadFile(n: ScadFile): ASTNode { 67 | const sf = new ScadFileWithScope( 68 | n.statements.map((stmt) => stmt.accept(this)), 69 | n.tokens 70 | ); 71 | sf.scope = this.nearestScope; // we assume the nearest scope is the root scope, since we are processing the scad file 72 | return sf; 73 | } 74 | visitAssignmentNode(n: AssignmentNode): ASTNode { 75 | const an = new AssignmentNode( 76 | n.name, 77 | n.value ? n.value.accept(this) : null, 78 | n.role, 79 | n.tokens 80 | ); 81 | if (n.name && n.role != AssignmentNodeRole.ARGUMENT_ASSIGNMENT) { 82 | this.nearestScope.variables.set(an.name, an); 83 | } 84 | return an; 85 | } 86 | visitUnaryOpExpr(n: UnaryOpExpr): ASTNode { 87 | return new UnaryOpExpr(n.operation, n.right.accept(this), n.tokens); 88 | } 89 | visitBinaryOpExpr(n: BinaryOpExpr): ASTNode { 90 | return new BinaryOpExpr( 91 | n.left.accept(this), 92 | n.operation, 93 | n.right.accept(this), 94 | n.tokens 95 | ); 96 | } 97 | visitTernaryExpr(n: TernaryExpr): ASTNode { 98 | return new TernaryExpr( 99 | n.cond.accept(this), 100 | n.ifExpr.accept(this), 101 | n.elseExpr.accept(this), 102 | n.tokens 103 | ); 104 | } 105 | visitArrayLookupExpr(n: ArrayLookupExpr): ASTNode { 106 | return new ArrayLookupExpr( 107 | n.array.accept(this), 108 | n.index.accept(this), 109 | n.tokens 110 | ); 111 | } 112 | visitLiteralExpr(n: LiteralExpr): ASTNode { 113 | return new LiteralExpr(n.value, n.tokens); 114 | } 115 | visitRangeExpr(n: RangeExpr): ASTNode { 116 | return new RangeExpr( 117 | n.begin.accept(this), 118 | n.step ? n.step.accept(this) : null, 119 | n.end.accept(this), 120 | n.tokens 121 | ); 122 | } 123 | visitVectorExpr(n: VectorExpr): ASTNode { 124 | return new VectorExpr( 125 | n.children.map((c) => c.accept(this)), 126 | n.tokens 127 | ); 128 | } 129 | visitLookupExpr(n: LookupExpr): ASTNode { 130 | return new LookupExpr(n.name, n.tokens); 131 | } 132 | visitMemberLookupExpr(n: MemberLookupExpr): ASTNode { 133 | return new MemberLookupExpr(n.expr.accept(this), n.member, n.tokens); 134 | } 135 | visitFunctionCallExpr(n: FunctionCallExpr): ASTNode { 136 | return new FunctionCallExpr( 137 | n.callee, 138 | n.args.map((a) => a.accept(this)) as AssignmentNode[], 139 | n.tokens 140 | ); 141 | } 142 | visitLetExpr(n: LetExpr): ASTNode { 143 | const letExprWithScope = new LetExprWithScope( 144 | null as unknown as any, 145 | null as unknown as any, 146 | n.tokens 147 | ); 148 | letExprWithScope.scope = new Scope(); 149 | letExprWithScope.scope.parent = this.nearestScope; 150 | const copy = this.copyWithNewNearestScope(letExprWithScope.scope); 151 | letExprWithScope.args = n.args.map((a) => 152 | a.accept(copy) 153 | ) as AssignmentNode[]; 154 | letExprWithScope.expr = n.expr.accept(copy); 155 | for (const a of letExprWithScope.args) { 156 | if (a.name) { 157 | letExprWithScope.scope.variables.set(a.name, a); 158 | } 159 | } 160 | return letExprWithScope; 161 | } 162 | visitAssertExpr(n: AssertExpr): ASTNode { 163 | return new AssertExpr( 164 | n.args.map((a) => a.accept(this)) as AssignmentNode[], 165 | n.expr.accept(this), 166 | n.tokens 167 | ); 168 | } 169 | visitEchoExpr(n: EchoExpr): ASTNode { 170 | return new EchoExpr( 171 | n.args.map((a) => a.accept(this)) as AssignmentNode[], 172 | n.expr.accept(this), 173 | n.tokens 174 | ); 175 | } 176 | visitLcIfExpr(n: LcIfExpr): ASTNode { 177 | return new LcIfExpr( 178 | n.cond.accept(this), 179 | n.ifExpr.accept(this), 180 | n.elseExpr ? n.elseExpr.accept(this) : null, 181 | n.tokens 182 | ); 183 | } 184 | visitLcEachExpr(n: LcEachExpr): ASTNode { 185 | return new LcEachExpr(n.expr.accept(this), n.tokens); 186 | } 187 | visitLcForExpr(n: LcForExpr): ASTNode { 188 | const newNode = new LcForExprWithScope( 189 | null as unknown as any, 190 | null as unknown as any, 191 | n.tokens 192 | ); 193 | newNode.scope = new Scope(); 194 | newNode.scope.parent = this.nearestScope; 195 | const copy = this.copyWithNewNearestScope(newNode.scope); 196 | newNode.args = n.args.map((a) => a.accept(copy)) as AssignmentNode[]; 197 | newNode.expr = n.expr.accept(copy); 198 | return newNode; 199 | } 200 | visitLcForCExpr(n: LcForCExpr): ASTNode { 201 | const newNode = new LcForCExprWithScope( 202 | null as unknown as any, 203 | null as unknown as any, 204 | null as unknown as any, 205 | null as unknown as any, 206 | n.tokens 207 | ); 208 | newNode.scope = new Scope(); 209 | newNode.scope.parent = this.nearestScope; 210 | const copy = this.copyWithNewNearestScope(newNode.scope); 211 | newNode.args = n.args.map((a) => a.accept(copy)) as AssignmentNode[]; 212 | newNode.incrArgs = n.incrArgs.map((a) => 213 | a.accept(copy) 214 | ) as AssignmentNode[]; 215 | newNode.cond = n.cond.accept(copy); 216 | newNode.expr = n.expr.accept(copy); 217 | return newNode; 218 | } 219 | visitLcLetExpr(n: LcLetExpr): ASTNode { 220 | const lcLetWithScopeExpr = new LcLetExprWithScope( 221 | null as unknown as any, 222 | null as unknown as any, 223 | n.tokens 224 | ); 225 | lcLetWithScopeExpr.scope = new Scope(); 226 | lcLetWithScopeExpr.scope.parent = this.nearestScope; 227 | const copy = this.copyWithNewNearestScope(lcLetWithScopeExpr.scope); 228 | lcLetWithScopeExpr.args = n.args.map((a) => 229 | a.accept(copy) 230 | ) as AssignmentNode[]; 231 | lcLetWithScopeExpr.expr = n.expr.accept(copy); 232 | return lcLetWithScopeExpr; 233 | } 234 | visitGroupingExpr(n: GroupingExpr): ASTNode { 235 | return new GroupingExpr(n.inner.accept(this), n.tokens); 236 | } 237 | visitUseStmt(n: UseStmt): ASTNode { 238 | return n; 239 | } 240 | visitIncludeStmt(n: IncludeStmt): ASTNode { 241 | return n; 242 | } 243 | visitModuleInstantiationStmt(n: ModuleInstantiationStmt): ASTNode { 244 | if (n.name === "for" || n.name === "intersection_for") { 245 | const inst = new ModuleInstantiationStmtWithScope( 246 | n.name, 247 | null as unknown as any, 248 | null, 249 | n.tokens 250 | ); 251 | inst.scope = new Scope(); 252 | inst.scope.parent = this.nearestScope; 253 | const copy = this.copyWithNewNearestScope(inst.scope); 254 | inst.args = n.args.map((a) => a.accept(copy)) as AssignmentNode[]; 255 | inst.child = n.child ? n.child.accept(copy) : null; 256 | } 257 | const inst = new ModuleInstantiationStmt( 258 | n.name, 259 | n.args.map((a) => a.accept(this)) as AssignmentNode[], 260 | n.child ? n.child.accept(this) : null, 261 | n.tokens 262 | ); 263 | inst.tagRoot = n.tagRoot; 264 | inst.tagHighlight = n.tagHighlight; 265 | inst.tagBackground = n.tagBackground; 266 | inst.tagDisabled = n.tagDisabled; 267 | return inst; 268 | } 269 | visitModuleDeclarationStmt(n: ModuleDeclarationStmt): ASTNode { 270 | const md = new ModuleDeclarationStmtWithScope( 271 | n.name, 272 | null as unknown as any, 273 | null as unknown as any, 274 | n.tokens, 275 | n.docComment 276 | ); 277 | this.nearestScope.modules.set(md.name, md); 278 | md.scope = new Scope(); 279 | md.scope.parent = this.nearestScope; 280 | const copy = this.copyWithNewNearestScope(md.scope); 281 | md.definitionArgs = n.definitionArgs.map((a) => 282 | a.accept(copy) 283 | ) as AssignmentNode[]; 284 | md.stmt = n.stmt.accept(copy); 285 | return md; 286 | } 287 | visitFunctionDeclarationStmt(n: FunctionDeclarationStmt): ASTNode { 288 | const fDecl = new FunctionDeclarationStmtWithScope( 289 | n.name, 290 | null as unknown as any, 291 | null as unknown as any, 292 | n.tokens, 293 | n.docComment 294 | ); 295 | this.nearestScope.functions.set(n.name, fDecl); 296 | fDecl.scope = new Scope(); 297 | fDecl.scope.parent = this.nearestScope; 298 | const newPopulator = this.copyWithNewNearestScope(fDecl.scope); 299 | fDecl.definitionArgs = n.definitionArgs.map((a) => 300 | a.accept(newPopulator) 301 | ) as AssignmentNode[]; 302 | fDecl.expr = n.expr.accept(newPopulator); 303 | return fDecl; 304 | } 305 | visitAnonymousFunctionExpr(n: AnonymousFunctionExpr): ASTNode { 306 | const fDecl = new AnonymousFunctionExprWithScope( 307 | null as unknown as any, 308 | null as unknown as any, 309 | n.tokens 310 | ); 311 | fDecl.scope = new Scope(); 312 | fDecl.scope.parent = this.nearestScope; 313 | const newPopulator = this.copyWithNewNearestScope(fDecl.scope); 314 | fDecl.definitionArgs = n.definitionArgs.map((a) => 315 | a.accept(newPopulator) 316 | ) as AssignmentNode[]; 317 | fDecl.expr = n.expr.accept(newPopulator); 318 | return fDecl; 319 | } 320 | visitBlockStmt(n: BlockStmt): ASTNode { 321 | const blk = new BlockStmtWithScope(null as unknown as any, n.tokens); 322 | blk.scope = new Scope(); 323 | blk.scope.parent = this.nearestScope; 324 | blk.children = n.children.map((c) => 325 | c.accept(this.copyWithNewNearestScope(blk.scope)) 326 | ) as Statement[]; 327 | return blk; 328 | } 329 | visitNoopStmt(n: NoopStmt): ASTNode { 330 | return new NoopStmt(n.tokens); 331 | } 332 | visitIfElseStatement(n: IfElseStatement): ASTNode { 333 | return new IfElseStatement( 334 | n.cond.accept(this), 335 | n.thenBranch.accept(this), 336 | n.elseBranch ? n.elseBranch.accept(this) : null, 337 | n.tokens 338 | ); 339 | } 340 | visitErrorNode(n: ErrorNode): ASTNode { 341 | return new ErrorNode(n.tokens); 342 | } 343 | } 344 | -------------------------------------------------------------------------------- /src/ASTPrinter.test.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import ScadFile from "./ast/ScadFile"; 3 | import ASTPrinter from "./ASTPrinter"; 4 | import CodeFile from "./CodeFile"; 5 | import ErrorCollector from "./ErrorCollector"; 6 | import FormattingConfiguration from "./FormattingConfiguration"; 7 | import Lexer from "./Lexer"; 8 | import ParsingHelper from "./ParsingHelper"; 9 | import ASTScopePopulator from "./semantic/ASTScopePopulator"; 10 | import Scope from "./semantic/Scope"; 11 | import TokenType from "./TokenType"; 12 | 13 | describe("ASTPrinter", () => { 14 | function doFormat(source: string) { 15 | let [ast, errorCollector] = ParsingHelper.parseFile( 16 | new CodeFile("", source) 17 | ); 18 | errorCollector.throwIfAny(); 19 | if (!ast) { 20 | throw new Error("No AST"); 21 | } 22 | ast = new ASTScopePopulator(new Scope()).populate(ast) as ScadFile; // populating the scopes should not change anything 23 | return new ASTPrinter(new FormattingConfiguration()).visitScadFile(ast); 24 | } 25 | function injectCommentsBetweenTokens(source: string): [string, string[]] { 26 | const ec = new ErrorCollector(); 27 | const lexer = new Lexer(new CodeFile("", source), ec); 28 | const tokens = lexer.scan(); 29 | ec.throwIfAny(); 30 | let injectId = 0; 31 | const injectedStrings: string[] = []; 32 | let codeWithInjections = ""; 33 | for (let i = 0; i < tokens.length; i++) { 34 | const tok = tokens[i]; 35 | let shouldInject = true; 36 | 37 | // do not inject comments around the use statement since it is illegal 38 | if ( 39 | tok.type === TokenType.Use || 40 | (i != 0 && tokens[i - 1].type === TokenType.Use) || 41 | tok.type === TokenType.Include || 42 | (i != 0 && tokens[i - 1].type === TokenType.Include) 43 | ) { 44 | shouldInject = false; 45 | } 46 | if (shouldInject) { 47 | const injectionBefore = `/* INJ_${injectId}_B */`; 48 | codeWithInjections += " " + injectionBefore + " "; 49 | injectedStrings.push(injectionBefore); 50 | injectId++; 51 | } 52 | codeWithInjections += tok.lexeme; 53 | if (shouldInject) { 54 | const injectionAfter = `/* INJ_${injectId}_A */`; 55 | codeWithInjections += " " + injectionAfter + " "; 56 | injectedStrings.push(injectionAfter); 57 | injectId++; 58 | } 59 | } 60 | return [codeWithInjections, injectedStrings]; 61 | } 62 | 63 | /** 64 | * Inserts comments between all tokens and then formats the code, 65 | * then checks if the comments are still there 66 | * @param source the source code to check 67 | */ 68 | function doPreserveTest(source: string) { 69 | const [codeWithInjections, injectedStrings] = 70 | injectCommentsBetweenTokens(source); 71 | 72 | // we do two passes, one with all the comments and another one with the problematic ones, this is where we throw errors 73 | const formatted = doFormat(codeWithInjections); 74 | let problematicCode = codeWithInjections; 75 | let problematicInjections: string[] = []; 76 | for (const inj of injectedStrings) { 77 | if (formatted.indexOf(inj) !== -1) { 78 | // the injection exists in the formatted code se we remove it 79 | problematicCode = problematicCode.replace(inj, " "); 80 | } else { 81 | problematicInjections.push(inj); 82 | } 83 | } 84 | const problematicFormatted = doFormat(problematicCode); 85 | if (problematicInjections.length > 0) console.log(problematicCode); 86 | for (const prob of problematicInjections) { 87 | expect(problematicFormatted).toEqual(expect.stringContaining(prob)); 88 | } 89 | } 90 | 91 | test("it preserves all comments in a file with all the syntactical elements", () => { 92 | doPreserveTest(` 93 | use 94 | include 95 | assignment = 123; the_same_line = 8; 96 | function ddd(argv = 10, second = !true) = (10 + 20) * 10; 97 | ybyby = x > 10 ? let(v = 200) doSomething() : assert(x = 20) echo("nothing") 5; 98 | arr = [20, if(true) each [20:50:30] else [808][0].x]; 99 | compre = [for(a = [rang1, 2, 3]) let(x = a + 1) [sin(a)],]; 100 | fun = function(a, b) a * b(13); 101 | alpha = undef || beta && gamma; 102 | module the_mod(arg1, arg2=2-2) { 103 | echo( [for (a = 0, b = 1;a < 5;a = a + 1, b = b + 2) [ a, b * b ] ] ); 104 | if(yeah == true) { 105 | ; 106 | } else { 107 | 108 | } 109 | } 110 | `); 111 | }); 112 | test("it preserves all comments nearby an use statement", () => { 113 | doPreserveTest(` 114 | use 115 | 116 | `); 117 | }); 118 | test("it preserves all comments nearby vectors", () => { 119 | doPreserveTest(` 120 | a = [d,]; 121 | `); 122 | doPreserveTest(` 123 | a = [d,a,b]; 124 | `); 125 | doPreserveTest(` 126 | a = [d]; 127 | `); 128 | }); 129 | test("it preserves all comments nearby list comprehensions with a for", () => { 130 | doPreserveTest(` 131 | compre = [for(a = [rang1, 2, 3]) x ]; 132 | `); 133 | }); 134 | 135 | test("it preserves all comments nearby exponentiation operators", () => { 136 | doPreserveTest(` 137 | expo = 3 * 2 ^ 8; 138 | `); 139 | }); 140 | 141 | test("it preserves all comments nearby module modifiers", () => { 142 | doPreserveTest(` 143 | % asdf(); 144 | * ffff(); 145 | # xD(); 146 | ! bangbang(); 147 | `); 148 | }); 149 | 150 | test("it preserves comments and tokens nearby anonymous functions", () => { 151 | doPreserveTest(` 152 | func = function (x) x * x; 153 | echo(func(5)); // ECHO: 25 154 | 155 | a = 1; 156 | selector = function (which) 157 | which == "add" 158 | ? function (x) x + x + a 159 | : function (x) x * x + a; 160 | 161 | echo(selector("add")); // ECHO: function(x) ((x + x) + a) 162 | echo(selector("add")(5)); // ECHO: 11 163 | 164 | echo(selector("mul")); // ECHO: function(x) ((x * x) + a) 165 | echo(selector("mul")(5)); // ECHO: 26 166 | 167 | `); 168 | }); 169 | 170 | test.skip("it preserves comments in ddd.scad", async () => { 171 | doPreserveTest( 172 | (await CodeFile.load(resolve(__dirname, "testdata/ddd.scad"))).code 173 | ); 174 | }); 175 | test("it does not add a space between the closing paren and semicolon in an empty module", () => { 176 | const f = doFormat(`module asdf();`); 177 | 178 | expect(f).not.toStrictEqual(expect.stringContaining(") ;")); 179 | }); 180 | test("does not cut off end chevron of an use statement", () => { 181 | const f = doFormat(`use `); 182 | 183 | expect(f).toStrictEqual(expect.stringContaining("")); 184 | }); 185 | 186 | test("does not cut off modifiers in module instantations", () => { 187 | const f = doFormat(` 188 | 189 | 190 | translate([0, -100, -50]) 191 | % import("relay_din_mount.stl"); 192 | `); 193 | expect(f).toStrictEqual(expect.stringContaining("%")); 194 | }); 195 | 196 | test("does not introduce newlines before else branches", () => { 197 | const f = doFormat(` 198 | 199 | if(true) { 200 | doSomething(); 201 | } else { 202 | doSomethingElse(); 203 | } 204 | `); 205 | expect(f).toStrictEqual(expect.stringContaining("} else {")); 206 | }); 207 | 208 | test("does not introduce newlines before else if branches", () => { 209 | const f = doFormat(` 210 | 211 | if(true) { 212 | doSomething(); 213 | } else if(false) { 214 | doSomethingElse(); 215 | } 216 | `); 217 | expect(f).toStrictEqual(expect.stringContaining("} else if(false) {")); 218 | }); 219 | 220 | test("preserves exponentiation operators", () => { 221 | const f = doFormat(` 222 | expo = 3 * 2 ^ 8; 223 | `); 224 | expect(f).toStrictEqual(expect.stringContaining("^")); 225 | }); 226 | 227 | test("formats 'for' comprehensions without variable names", () => { 228 | expect(doFormat(`x = [for([0:3]) 1];`)).toStrictEqual( 229 | `x = [for([0 : 3]) 1];\n` 230 | ); 231 | }); 232 | 233 | test("does not add a newline after a semicolon in an assignment if a comment follows it", () => { 234 | const f = doFormat(` 235 | enum_value="a"; // [a:ayyyy, b:beee, c:seeee] 236 | `); 237 | expect(f).toStrictEqual(` 238 | enum_value = "a"; // [a:ayyyy, b:beee, c:seeee] 239 | `); 240 | }); 241 | test("does keep a newline after a semi-colon in an assignment if the user wants it", () => { 242 | const f = doFormat( 243 | ` 244 | enum_value="a"; 245 | // [a:ayyyy, b:beee, c:seeee] 246 | ` 247 | ); 248 | expect(f).toStrictEqual(` 249 | enum_value = "a"; 250 | // [a:ayyyy, b:beee, c:seeee] 251 | `); 252 | }); 253 | it("preserves comments in 'for' list comprehensions", () => { 254 | doPreserveTest(` 255 | x = [for(a = [0:3]) 1]; 256 | `); 257 | }); 258 | it("preserves comments in 'for' list comprehensions without variable names", () => { 259 | doPreserveTest(` 260 | x = [for([0:3]) 1]; 261 | x = [for("abc") 1]; 262 | x = [for(alpha) 1]; 263 | `); 264 | }); 265 | it("correctly handles comments after chained module instantiations", () => { 266 | const formatted = doFormat(` 267 | $fn=4;// a comment 268 | translate([-5,-5,-10]) scale([1,1,2]) // another comment 269 | cube([10,10,10]); 270 | `); 271 | 272 | expect(formatted).toStrictEqual(` 273 | $fn = 4; // a comment 274 | translate([-5, -5, -10]) 275 | scale([1, 1, 2]) // another comment 276 | cube([10, 10, 10]); 277 | `); 278 | }); 279 | 280 | it("it adds spaces between module chained instantiations in one line", () => { 281 | const formatted = doFormat(` 282 | scale()cube(); 283 | `); 284 | 285 | expect(formatted).toStrictEqual(` 286 | scale() cube(); 287 | `); 288 | }); 289 | 290 | it("properly breaks apart long module instantiations", () => { 291 | const formatted = doFormat(` 292 | scale() translate() translate() translate() rotate() cube(); 293 | `); 294 | expect(formatted).toStrictEqual(` 295 | scale() 296 | translate() 297 | translate() 298 | translate() 299 | rotate() 300 | cube(); 301 | `); 302 | }); 303 | it("preserves comments in varius module instantiations", () => { 304 | doPreserveTest(` 305 | scale() translate() translate() translate() rotate() cube(); 306 | scale() 307 | translate() 308 | translate() 309 | translate() 310 | rotate() 311 | cube(); 312 | scale()cube(); 313 | $fn = 4; // a comment 314 | translate([-5, -5, -10]) 315 | scale([1, 1, 2]) // another comment 316 | cube([10, 10, 10]); 317 | `); 318 | }); 319 | 320 | test("It preserves comments in files doing division (harness test)", async () => { 321 | doPreserveTest(`translate([distance1-distance1/2,8.5,0]);`); 322 | }); 323 | 324 | it("doesn't break code with comment after ending brace", async () => { 325 | const formatted = doFormat(` 326 | { 327 | { 328 | } //end if 329 | }`); 330 | 331 | let [ast, errorCollector] = ParsingHelper.parseFile( 332 | new CodeFile("", formatted) 333 | ); 334 | errorCollector.throwIfAny(); 335 | }); 336 | 337 | it("Adds newlines between stacked closing braces", async () => { 338 | const formatted = doFormat(` 339 | { 340 | { 341 | }}`); 342 | expect(formatted).not.toContain("}}"); 343 | }); 344 | 345 | it("does not add newlines near the else keyword in ifs", async () => { 346 | const formatted = doFormat(` 347 | if(true) { 348 | } else { 349 | 350 | }`); 351 | expect(formatted).toContain("} else {"); 352 | }); 353 | 354 | it("doesn't break code with comment after ending brace of an else statement", async () => { 355 | const formatted = doFormat(` 356 | if(true) { 357 | } // hello 358 | else 359 | // hello 360 | { 361 | 362 | }`); 363 | 364 | let [ast, errorCollector] = ParsingHelper.parseFile( 365 | new CodeFile("", formatted) 366 | ); 367 | errorCollector.throwIfAny(); 368 | }); 369 | }); 370 | -------------------------------------------------------------------------------- /src/ast/expressions.ts: -------------------------------------------------------------------------------- 1 | import CodeLocation from "../CodeLocation"; 2 | import LiteralToken from "../LiteralToken"; 3 | import Token from "../Token"; 4 | import TokenType from "../TokenType"; 5 | import AssignmentNode from "./AssignmentNode"; 6 | import ASTNode from "./ASTNode"; 7 | import ASTVisitor from "./ASTVisitor"; 8 | 9 | export abstract class Expression extends ASTNode {} 10 | 11 | /** 12 | * Represents an unary expression (!right, -right) 13 | * @category AST 14 | */ 15 | export class UnaryOpExpr extends Expression { 16 | /** 17 | * The operation of this unary expression. 18 | */ 19 | operation: TokenType; 20 | 21 | /** 22 | * The expression on which the operation is performed. 23 | */ 24 | right: Expression; 25 | 26 | constructor( 27 | op: TokenType, 28 | right: Expression, 29 | public tokens: { operator: Token } 30 | ) { 31 | super(); 32 | this.operation = op; 33 | this.right = right; 34 | } 35 | accept(visitor: ASTVisitor): R { 36 | return visitor.visitUnaryOpExpr(this); 37 | } 38 | } 39 | 40 | /** 41 | * Represents a binary expression (LogicalAnd, LogicalOr, Multiply, Divide, Modulo, Plus, Minus, Less, LessEqual, Greater, GreaterEqual, Equal, NotEqual). 42 | * @category AST 43 | */ 44 | export class BinaryOpExpr extends Expression { 45 | /** 46 | * The left side of the operation. 47 | */ 48 | left: Expression; 49 | 50 | /** 51 | * The type of the operation performed. 52 | */ 53 | operation: TokenType; 54 | 55 | /** 56 | * The right side of the operation 57 | */ 58 | right: Expression; 59 | 60 | constructor( 61 | left: Expression, 62 | operation: TokenType, 63 | right: Expression, 64 | public tokens: { operator: Token } 65 | ) { 66 | super(); 67 | this.left = left; 68 | this.operation = operation; 69 | this.right = right; 70 | } 71 | accept(visitor: ASTVisitor): R { 72 | return visitor.visitBinaryOpExpr(this); 73 | } 74 | } 75 | 76 | /** 77 | * Represents a ternary expression (cond ? ifexpr : elsexpr) 78 | * @category AST 79 | */ 80 | export class TernaryExpr extends Expression { 81 | cond: Expression; 82 | ifExpr: Expression; 83 | elseExpr: Expression; 84 | constructor( 85 | cond: Expression, 86 | ifExpr: Expression, 87 | elseExpr: Expression, 88 | public tokens: { 89 | questionMark: Token; 90 | colon: Token; 91 | } 92 | ) { 93 | super(); 94 | this.cond = cond; 95 | this.ifExpr = ifExpr; 96 | this.elseExpr = elseExpr; 97 | } 98 | accept(visitor: ASTVisitor): R { 99 | return visitor.visitTernaryExpr(this); 100 | } 101 | } 102 | 103 | /** 104 | * Represents a lookup operation on an array (indexing). Example: arr[5] 105 | * @category AST 106 | */ 107 | export class ArrayLookupExpr extends Expression { 108 | /** 109 | * The array being indexed. 110 | */ 111 | array: Expression; 112 | 113 | /** 114 | * The index which is being looked up. 115 | */ 116 | index: Expression; 117 | 118 | constructor( 119 | array: Expression, 120 | index: Expression, 121 | public tokens: { 122 | firstBracket: Token; 123 | secondBracket: Token; 124 | } 125 | ) { 126 | super(); 127 | this.array = array; 128 | this.index = index; 129 | } 130 | accept(visitor: ASTVisitor): R { 131 | return visitor.visitArrayLookupExpr(this); 132 | } 133 | } 134 | 135 | /** 136 | * A literal expression (just a simple number, string or a boolean) 137 | * @category AST 138 | */ 139 | export class LiteralExpr extends Expression { 140 | value: TValue; 141 | 142 | constructor( 143 | value: TValue, 144 | public tokens: { 145 | literalToken: LiteralToken; 146 | } 147 | ) { 148 | super(); 149 | this.value = value; 150 | } 151 | accept(visitor: ASTVisitor): R { 152 | return visitor.visitLiteralExpr(this); 153 | } 154 | } 155 | 156 | /** 157 | * A range epxression. Example: [0: 1 :20] 158 | * @category AST 159 | */ 160 | export class RangeExpr extends Expression { 161 | begin: Expression; 162 | /** 163 | * The optional step expression. 164 | * It defaults to 1 if not specified. 165 | */ 166 | step: Expression | null; 167 | end: Expression; 168 | constructor( 169 | begin: Expression, 170 | step: Expression | null, 171 | end: Expression, 172 | public tokens: { 173 | firstBracket: Token; 174 | firstColon: Token; 175 | secondColon: Token | null; 176 | secondBracket: Token; 177 | } 178 | ) { 179 | super(); 180 | this.begin = begin; 181 | this.step = step; 182 | this.end = end; 183 | } 184 | accept(visitor: ASTVisitor): R { 185 | return visitor.visitRangeExpr(this); 186 | } 187 | } 188 | 189 | /** 190 | * A vector literal expression. Example: [1, 2, 3, 4] 191 | * @category AST 192 | */ 193 | export class VectorExpr extends Expression { 194 | children: Expression[]; 195 | constructor( 196 | children: Expression[], 197 | public tokens: { 198 | firstBracket: Token; 199 | commas: Token[]; 200 | secondBracket: Token; 201 | } 202 | ) { 203 | super(); 204 | this.children = children; 205 | } 206 | accept(visitor: ASTVisitor): R { 207 | return visitor.visitVectorExpr(this); 208 | } 209 | } 210 | 211 | /** 212 | * A lookup expression, it references a variable, module or function by name. 213 | * @category AST 214 | */ 215 | export class LookupExpr extends Expression { 216 | name: string; 217 | 218 | constructor(name: string, public tokens: { identifier: Token }) { 219 | super(); 220 | this.name = name; 221 | } 222 | accept(visitor: ASTVisitor): R { 223 | return visitor.visitLookupExpr(this); 224 | } 225 | } 226 | 227 | /** 228 | * A member lookup expression, (abc.ddd) 229 | * @category AST 230 | */ 231 | export class MemberLookupExpr extends Expression { 232 | expr: Expression; 233 | member: string; 234 | 235 | constructor( 236 | expr: Expression, 237 | member: string, 238 | public tokens: { 239 | dot: Token; 240 | memberName: LiteralToken; 241 | } 242 | ) { 243 | super(); 244 | this.expr = expr; 245 | this.member = member; 246 | } 247 | accept(visitor: ASTVisitor): R { 248 | return visitor.visitMemberLookupExpr(this); 249 | } 250 | } 251 | 252 | /** 253 | * A function call expression. Example: sin(10) 254 | * @category AST 255 | */ 256 | export class FunctionCallExpr extends Expression { 257 | /** 258 | * The expression that is being called. 259 | */ 260 | callee: Expression; 261 | 262 | /** 263 | * The named arguments of the function call 264 | */ 265 | args: AssignmentNode[]; 266 | constructor( 267 | callee: Expression, 268 | args: AssignmentNode[], 269 | public tokens: { 270 | firstParen: Token; 271 | secondParen: Token; 272 | } 273 | ) { 274 | super(); 275 | this.callee = callee; 276 | this.args = args; 277 | } 278 | accept(visitor: ASTVisitor): R { 279 | return visitor.visitFunctionCallExpr(this); 280 | } 281 | } 282 | 283 | /** 284 | * A common class for the Echo, Assert and Let expression so that the constructor is not copied. 285 | * @category AST 286 | */ 287 | export abstract class FunctionCallLikeExpr extends Expression { 288 | /** 289 | * The names of the assigned variables in this let expression. 290 | */ 291 | args: AssignmentNode[]; 292 | 293 | /** 294 | * The inner expression which will use the expression. 295 | */ 296 | expr: Expression; 297 | 298 | constructor( 299 | args: AssignmentNode[], 300 | expr: Expression, 301 | public tokens: { name: Token; firstParen: Token; secondParen: Token } 302 | ) { 303 | super(); 304 | this.args = args; 305 | this.expr = expr; 306 | } 307 | } 308 | 309 | /** 310 | * Represents a let expression. Please note that this is syntactically diffrent from the let module instantation and the let list comprehension. 311 | * @category AST 312 | */ 313 | export class LetExpr extends FunctionCallLikeExpr { 314 | accept(visitor: ASTVisitor): R { 315 | return visitor.visitLetExpr(this); 316 | } 317 | } 318 | 319 | /** 320 | * @category AST 321 | */ 322 | export class AssertExpr extends FunctionCallLikeExpr { 323 | accept(visitor: ASTVisitor): R { 324 | return visitor.visitAssertExpr(this); 325 | } 326 | } 327 | 328 | /** 329 | * @category AST 330 | */ 331 | export class EchoExpr extends FunctionCallLikeExpr { 332 | accept(visitor: ASTVisitor): R { 333 | return visitor.visitEchoExpr(this); 334 | } 335 | } 336 | 337 | /** 338 | * @category AST 339 | */ 340 | export abstract class ListComprehensionExpression extends Expression {} 341 | 342 | /** 343 | * @category AST 344 | */ 345 | export class LcIfExpr extends ListComprehensionExpression { 346 | cond: Expression; 347 | ifExpr: Expression; 348 | elseExpr: Expression | null; 349 | constructor( 350 | cond: Expression, 351 | ifExpr: Expression, 352 | elseExpr: Expression | null, 353 | public tokens: { 354 | ifKeyword: Token; 355 | firstParen: Token; 356 | secondParen: Token; 357 | elseKeyword: Token | null; 358 | } 359 | ) { 360 | super(); 361 | this.cond = cond; 362 | this.ifExpr = ifExpr; 363 | this.elseExpr = elseExpr; 364 | } 365 | accept(visitor: ASTVisitor): R { 366 | return visitor.visitLcIfExpr(this); 367 | } 368 | } 369 | 370 | /** 371 | * @category AST 372 | */ 373 | export class LcEachExpr extends ListComprehensionExpression { 374 | /** 375 | * The expression where the declared variables will be accessible. 376 | */ 377 | expr: Expression; 378 | 379 | constructor( 380 | expr: Expression, 381 | public tokens: { 382 | eachKeyword: Token; 383 | } 384 | ) { 385 | super(); 386 | 387 | this.expr = expr; 388 | } 389 | accept(visitor: ASTVisitor): R { 390 | return visitor.visitLcEachExpr(this); 391 | } 392 | } 393 | 394 | /** 395 | * @category AST 396 | */ 397 | export class LcForExpr extends ListComprehensionExpression { 398 | /** 399 | * The variable names in the for expression 400 | */ 401 | args: AssignmentNode[]; 402 | 403 | /** 404 | * The expression which will be looped. 405 | */ 406 | expr: Expression; 407 | 408 | constructor( 409 | args: AssignmentNode[], 410 | expr: Expression, 411 | public tokens: { 412 | forKeyword: Token; 413 | firstParen: Token; 414 | secondParen: Token; 415 | } 416 | ) { 417 | super(); 418 | this.args = args; 419 | this.expr = expr; 420 | } 421 | 422 | accept(visitor: ASTVisitor): R { 423 | return visitor.visitLcForExpr(this); 424 | } 425 | } 426 | 427 | /** 428 | * @category AST 429 | */ 430 | export class LcForCExpr extends ListComprehensionExpression { 431 | /** 432 | * The variable names in the for expression 433 | */ 434 | args: AssignmentNode[]; 435 | 436 | incrArgs: AssignmentNode[]; 437 | 438 | cond: Expression; 439 | /** 440 | * The expression which will be looped. 441 | */ 442 | expr: Expression; 443 | 444 | constructor( 445 | args: AssignmentNode[], 446 | incrArgs: AssignmentNode[], 447 | cond: Expression, 448 | expr: Expression, 449 | public tokens: { 450 | forKeyword: Token; 451 | firstParen: Token; 452 | firstSemicolon: Token; 453 | secondSemicolon: Token; 454 | secondParen: Token; 455 | } 456 | ) { 457 | super(); 458 | this.args = args; 459 | this.incrArgs = incrArgs; 460 | this.cond = cond; 461 | this.expr = expr; 462 | } 463 | accept(visitor: ASTVisitor): R { 464 | return visitor.visitLcForCExpr(this); 465 | } 466 | } 467 | 468 | /** 469 | * @category AST 470 | */ 471 | export class LcLetExpr extends ListComprehensionExpression { 472 | /** 473 | * The variable names in the let expression 474 | */ 475 | args: AssignmentNode[]; 476 | 477 | /** 478 | * The expression where the declared variables will be accessible. 479 | */ 480 | expr: Expression; 481 | 482 | constructor( 483 | args: AssignmentNode[], 484 | expr: Expression, 485 | public tokens: { 486 | letKeyword: Token; 487 | firstParen: Token; 488 | secondParen: Token; 489 | } 490 | ) { 491 | super(); 492 | this.args = args; 493 | this.expr = expr; 494 | } 495 | accept(visitor: ASTVisitor): R { 496 | return visitor.visitLcLetExpr(this); 497 | } 498 | } 499 | 500 | /** 501 | * An expression enclosed in parenthesis. 502 | * @category AST 503 | */ 504 | export class GroupingExpr extends Expression { 505 | inner: Expression; 506 | constructor( 507 | inner: Expression, 508 | public tokens: { 509 | firstParen: Token; 510 | secondParen: Token; 511 | } 512 | ) { 513 | super(); 514 | this.inner = inner; 515 | } 516 | accept(visitor: ASTVisitor): R { 517 | return visitor.visitGroupingExpr(this); 518 | } 519 | } 520 | 521 | /** 522 | * AnonymousFunctionExpr represents a function expression. 'function(x) x * x' 523 | * @category AST 524 | */ 525 | export class AnonymousFunctionExpr extends Expression { 526 | constructor( 527 | public definitionArgs: AssignmentNode[], 528 | public expr: Expression, 529 | public tokens: { 530 | functionKeyword: Token; 531 | firstParen: Token; 532 | secondParen: Token; 533 | } 534 | ) { 535 | super(); 536 | } 537 | accept(visitor: ASTVisitor): R { 538 | return visitor.visitAnonymousFunctionExpr(this); 539 | } 540 | } 541 | --------------------------------------------------------------------------------