diff --git a/src/__tests__/index.test.js b/src/__tests__/index.test.js index 14ed1af..ad386a3 100644 --- a/src/__tests__/index.test.js +++ b/src/__tests__/index.test.js @@ -35,8 +35,9 @@ describe('Pass a null value as the first argument', () => { describe('Pass an empty text as the first argument', () => { it('should get empty results.', (done) => { - const sampleText = ''; - parseString(sampleText, (err, results) => { + const inputText = ''; + parseString(inputText, (err, results) => { + expect(err).toBeNull(); expect(results.length).toBe(0); done(); }); @@ -144,9 +145,38 @@ describe('Commands', () => { }); }); -describe('Comments', () => { +describe('Stripping comments', () => { + it('should correctly parse a semicolon comment before parentheses', () => { + const line = 'M6 ; comment (tool change) T1'; + const data = parseLine(line, { lineMode: 'stripped' }); + expect(data.line).toBe('M6'); + expect(data.comments).toEqual([ + 'comment (tool change) T1', + ]); + }); + + it('should correctly parse nested parentheses containing a semicolon', () => { + const line = 'M6 (outer (inner;)) T1 ; comment'; + const data = parseLine(line, { lineMode: 'stripped' }); + expect(data.line).toBe('M6 T1'); + expect(data.comments).toEqual([ + 'outer (inner;)', + 'comment', + ]); + }); + + it('should correctly parse multiple comments in a line', () => { + const line = 'M6 (first comment) T1 ; second comment'; + const data = parseLine(line, { lineMode: 'stripped' }); + expect(data.line).toBe('M6 T1'); + expect(data.comments).toEqual([ + 'first comment', + 'second comment', + ]); + }); + it('should strip everything after a semi-colon to the end of the loine including preceding spaces.', (done) => { - const sampleText = [ + const inputText = [ ' % ', ' #', '; Operation: 0', @@ -161,17 +191,26 @@ describe('Comments', () => { ' ' // empty line ].join('\n'); - parseString(sampleText, (err, results) => { - results = results.filter(result => result.length > 0); - expect(results.length).toBe(0); + parseString(inputText, { lineMode: 'stripped' }, (err, results) => { + expect(results).toEqual([ + { line: '%', words: [], cmds: [ '%' ] }, + { line: '#', words: [] }, + { line: '', words: [], comments: [ 'Operation: 0' ] }, + { line: '', words: [], comments: [ 'Name:' ] }, + { line: '', words: [], comments: [ 'Type: Pocket' ] }, + { line: '', words: [], comments: [ 'Paths: 3' ] }, + { line: '', words: [], comments: [ 'Direction: Conventional' ] }, + { line: '', words: [], comments: [ 'Cut Depth: 3.175' ] }, + { line: '', words: [], comments: [ 'Pass Depth: 1.9999999999999998' ] }, + { line: '', words: [], comments: [ 'Plunge rate: 127' ] }, + { line: '', words: [], comments: [ 'Cut rate: 1016' ] } + ]); done(); }); }); -}); -describe('Parentheses', () => { it('should remove anything inside parentheses.', (done) => { - const sampleText = [ + const inputText = [ '(Generated with: DXF2GCODE, Version: Py3.4.4 PyQt5.4.1, Date: $Date: Sun Apr 17 16:32:22 2016 +0200 $)', '(Created from file: G:/Dropbox/Konstruktionen/20161022 - MicroCopter 180/complete.dxf)', '(Time: Sun Oct 23 12:30:46 2016)', @@ -187,80 +226,60 @@ describe('Parentheses', () => { ].join('\n'); const expectedResults = [ { - gcode: '', - cmds: undefined, + line: '', comments: ['Generated with: DXF2GCODE, Version: Py3.4.4 PyQt5.4.1, Date: $Date: Sun Apr 17 16:32:22 2016 +0200 $'], }, { - gcode: '', - cmds: undefined, + line: '', comments: ['Created from file: G:/Dropbox/Konstruktionen/20161022 - MicroCopter 180/complete.dxf'], }, { - gcode: '', - cmds: undefined, + line: '', comments: ['Time: Sun Oct 23 12:30:46 2016'], }, { - gcode: 'G21G90', - cmds: undefined, + line: 'G21G90', comments: ['Units in millimeters', 'Absolute programming'], }, { - gcode: '', - cmds: ['$H'], + line: '$H', comments: undefined, }, { - gcode: 'F1000', - cmds: undefined, + line: 'F1000', comments: undefined, }, { - gcode: '', - cmds: undefined, + line: '', comments: ['*** LAYER: 0 ***'], }, { - gcode: 'T5M6', - cmds: undefined, + line: 'T5M06', comments: undefined, }, { - gcode: 'S200', - cmds: undefined, + line: 'S200', comments: undefined, }, { - gcode: '', - cmds: undefined, + line: '', comments: ['* SHAPE Nr: 0 *'], }, { - gcode: 'G0X180.327Y137.08', - cmds: undefined, + line: 'G0X180.327Y137.080', comments: undefined, }, { - gcode: 'M3', - cmds: undefined, + line: 'M03', comments: undefined, }, ]; - parseString(sampleText, (err, results) => { - results = results.map(result => { - const gcode = result.words.map(word => { - return word.join(''); - }).join(''); - const cmds = result.cmds; - const comments = result.comments; - return { - gcode, - cmds, - comments, - }; - }); + parseString(inputText, { lineMode: 'compact' }, (err, results) => { + results = results.map(result => ({ + line: result.line, + comments: result.comments, + })); expect(results).toEqual(expectedResults); done(); }); diff --git a/src/index.js b/src/index.js index 39ad352..cb48bfd 100644 --- a/src/index.js +++ b/src/index.js @@ -1,10 +1,24 @@ -/* eslint no-bitwise: 0 */ -/* eslint no-continue: 0 */ import events from 'events'; import fs from 'fs'; import timers from 'timers'; import stream, { Transform } from 'stream'; +const LINE_MODES = [ + // Retains the line exactly as is, including comments and whitespace. + // This is the default when `lineMode` is not specified. + 'original', + + // Removes comments, trims leading and trailing whitespace (spaces and tabs), but keeps the inner whitespace between code elements. + 'stripped', + + // Removes both comments and all whitespace characters. + 'compact', +]; + +const DEFAULT_BATCH_SIZE = 1000; +const DEFAULT_FLATTEN = false; +const DEFAULT_LINE_MODE = LINE_MODES[0]; + const noop = () => {}; const streamify = (text) => { @@ -65,28 +79,59 @@ const parseLine = (() => { return cs; }; // http://linuxcnc.org/docs/html/gcode/overview.html#gcode:comments - // Comments can be embedded in a line using parentheses () or for the remainder of a lineusing a semi-colon. The semi-colon is not treated as the start of a comment when enclosed in parentheses. + // Comments can be embedded in a line using parentheses () or for the remainder of a lineusing a semi-colon. + // The semi-colon is not treated as the start of a comment when enclosed in parentheses. const stripComments = (() => { - // eslint-disable-next-line no-useless-escape - const re1 = new RegExp(/\(([^\)]*)\)/g); // Match anything inside parentheses - const re2 = new RegExp(/;(.*)$/g); // Match anything after a semi-colon to the end of the line + const _stripComments = (line) => { + let result = ''; + let currentComment = ''; + let comments = []; + let openParens = 0; + + // Detect semicolon comments before parentheses + for (let i = 0; i < line.length; i++) { + const char = line[i]; + + if (char === ';' && openParens === 0) { + // Start semicolon comment outside parentheses + comments.push(line.slice(i + 1).trim()); + openParens = 0; // Reset parentheses counter + break; // Stop further processing after a semicolon comment + } + + if (char === '(') { + // Start parentheses comment + if (openParens === 0) { + currentComment = ''; + } else if (openParens > 0) { + currentComment += char; + } + openParens = Math.min(openParens + 1, Number.MAX_SAFE_INTEGER); + } else if (char === ')') { + // End parentheses comment + openParens = Math.max(0, openParens - 1); + if (openParens === 0) { + comments.push(currentComment.trim()); + currentComment = ''; + } else if (openParens > 0) { + currentComment += char; + } + } else if (openParens > 0) { + // Inside parentheses comment + currentComment += char; + } else { + // Normal text outside comments + result += char; + } + } + + result = result.trim(); + return [result, comments]; + }; return (line) => { - const comments = []; - // Extract comments from parentheses - line = line.replace(re1, (match, p1) => { - const lineWithoutComments = p1.trim(); - comments.push(lineWithoutComments); // Add the match to comments - return ''; - }); - // Extract comments after a semi-colon - line = line.replace(re2, (match, p1) => { - const lineWithoutComments = p1.trim(); - comments.push(lineWithoutComments); // Add the match to comments - return ''; - }); - line = line.trim(); - return [line, comments]; + const [strippedLine, comments] = _stripComments(line); + return [strippedLine, comments]; }; })(); @@ -100,15 +145,10 @@ const parseLine = (() => { const re = /(%.*)|({.*)|((?:\$\$)|(?:\$[a-zA-Z0-9#]*))|([a-zA-Z][0-9\+\-\.]+)|(\*[0-9]+)/igm; return (line, options = {}) => { - options.flatten = !!options?.flatten; - - const validLineModes = [ - 'original', // Retains the line exactly as is, including comments and whitespace. (This is the default when `lineMode` is not specified.) - 'stripped', // Removes comments, trims leading and trailing whitespace (spaces and tabs), but keeps the inner whitespace between code elements. - 'compact', // Removes both comments and all whitespace characters. - ]; - if (!validLineModes.includes(options?.lineMode)) { - options.lineMode = validLineModes[0]; + const flatten = !!(options?.flatten ?? DEFAULT_FLATTEN); + let lineMode = options?.lineMode ?? DEFAULT_LINE_MODE; + if (!LINE_MODES.includes(options?.lineMode)) { + lineMode = DEFAULT_LINE_MODE; } const result = { @@ -122,9 +162,9 @@ const parseLine = (() => { const [strippedLine, comments] = stripComments(line); const compactLine = stripWhitespace(strippedLine); - if (options.lineMode === 'compact') { + if (lineMode === 'compact') { result.line = compactLine; - } else if (options.lineMode === 'stripped') { + } else if (lineMode === 'stripped') { result.line = strippedLine; } else { result.line = originalLine; @@ -180,7 +220,7 @@ const parseLine = (() => { value = argument; } - if (options.flatten) { + if (flatten) { result.words.push(letter + value); } else { result.words.push([letter, value]); @@ -245,10 +285,6 @@ const parseFile = (file, options, callback = noop) => { return parseStream(s, options, callback); }; -const parseFileSync = (file, options) => { - return parseStringSync(fs.readFileSync(file, 'utf8'), options); -}; - // @param {string} str The G-code text string // @param {options} options The options object // @param {function} callback The callback function @@ -261,7 +297,6 @@ const parseString = (str, options, callback = noop) => { }; const parseStringSync = (str, options) => { - const { flatten = false } = { ...options }; const results = []; const lines = str.split('\n'); @@ -270,15 +305,17 @@ const parseStringSync = (str, options) => { if (line.length === 0) { continue; } - const result = parseLine(line, { - flatten, - }); + const result = parseLine(line, options); results.push(result); } return results; }; +const parseFileSync = (file, options) => { + return parseStringSync(fs.readFileSync(file, 'utf8'), options); +}; + // @param {string} str The G-code text string // @param {options} options The options object class GCodeLineStream extends Transform { @@ -288,7 +325,9 @@ class GCodeLineStream extends Transform { }; options = { - batchSize: 1000, + batchSize: DEFAULT_BATCH_SIZE, + flatten: DEFAULT_FLATTEN, + lineMode: DEFAULT_LINE_MODE, }; lineBuffer = ''; @@ -298,6 +337,7 @@ class GCodeLineStream extends Transform { // @param {object} [options] The options object // @param {number} [options.batchSize] The batch size. // @param {boolean} [options.flatten] True to flatten the array, false otherwise. + // @param {number} [options.lineMode] The line mode. constructor(options = {}) { super({ objectMode: true }); @@ -349,9 +389,7 @@ class GCodeLineStream extends Transform { iterateArray(lines, { batchSize: this.options.batchSize }, (line) => { line = line.trim(); if (line.length > 0) { - const result = parseLine(line, { - flatten: this.options.flatten, - }); + const result = parseLine(line, this.options); this.push(result); } }, next); @@ -361,9 +399,7 @@ class GCodeLineStream extends Transform { if (this.lineBuffer) { const line = this.lineBuffer.trim(); if (line.length > 0) { - const result = parseLine(line, { - flatten: this.options.flatten, - }); + const result = parseLine(line, this.options); this.push(result); } @@ -377,10 +413,10 @@ class GCodeLineStream extends Transform { export { GCodeLineStream, - parseLine, - parseStream, parseFile, parseFileSync, + parseLine, + parseStream, parseString, parseStringSync };