Skip to content

Commit

Permalink
feat(parse): use AST for parsing declarations, removing usage of regex
Browse files Browse the repository at this point in the history
extracted expression strings now includes specified flags such as '!global'
  • Loading branch information
jgranstrom committed Sep 5, 2017
1 parent bbf6694 commit dfc9796
Show file tree
Hide file tree
Showing 11 changed files with 112 additions and 80 deletions.
12 changes: 7 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
{
"name": "sass-extract",
"version": "0.5.3",
"description": "",
"description": "Extract structured variables from sass files. Fast and accurate.",
"main": "lib/index.js",
"scripts": {
"compile": "babel -d lib/ src/",
"prepublish": "npm run compile",
"test": "npm run compile && mocha --compilers js:babel-core/register",
"changelog": "conventional-changelog -i CHANGELOG.md -s -r 0"
"test": "mocha --compilers js:babel-core/register",
"changelog": "conventional-changelog -i CHANGELOG.md -s -r 0",
"watch": "mocha --watch --reporter min --compilers js:babel-core/register"
},
"repository": "jgranstrom/sass-extract",
"author": "John Granström <[email protected]>",
"description": "Extract structured variables from sass files. Fast and accurate.",
"keywords": [
"sass",
"scss",
Expand All @@ -29,7 +29,9 @@
"node-sass": "^3.8.0 || 4"
},
"dependencies": {
"bluebird": "^3.4.7"
"bluebird": "^3.4.7",
"query-ast": "^1.0.1",
"scss-parser": "^1.0.0"
},
"devDependencies": {
"babel-cli": "^6.22.2",
Expand Down
85 changes: 56 additions & 29 deletions src/parse.js
Original file line number Diff line number Diff line change
@@ -1,51 +1,78 @@
import sass from 'node-sass';
import { parse, stringify } from 'scss-parser';
import createQueryWrapper from 'query-ast';

const REGEX_VARIABLE_GLOBAL_IMPLICIT = /(\$[\w-_]+)\s*:\s*((.*?[\r\n]?)+?);/g;
const REGEX_VARIABLE_GLOBAL_EXPLICIT = /(\$[\w-_]+)\s*:\s*(.*?)\s+!global\s*;/g;
const REGEX_DEEP_CONTEXT = /({[^{}]*})/g;
const REGEX_COMMENTS = /(?:(?:\/\*[\w\W]*\*\/)|(?:\/\/[^\r\n]*[\r\n]?))/g;
/**
* Check if a declaration node has a '!' operator followed by a '<flag>' identifier in its value
*/
function declarationHasFlag($ast, node, flag) {
return $ast(node)
.children('value').children('operator')
.filter(operator => operator.node.value === '!')
.nextAll('identifier') // nextAll to account for potential space tokens following operator
.filter(identifier => identifier.node.value === flag)
.length() > 0
}

/**
* Strip a string for all occurences that matches provided regex
* Check if a declaration node has a '!' operator followed by the 'default' identifier in its value
*/
function stripByRegex(data, regex) {
let strippedData = data;
function isExplicitGlobalDeclaration($ast, node) {
return declarationHasFlag($ast, node, 'global');
}

let match;
while(match = strippedData.match(regex)) {
strippedData = strippedData.replace(match[0], '');
}
/**
* Check if a declaration node has a '!' operator followed by the 'global' identifier in its value
*/
function isDefaultDeclaration($ast, node) {
return declarationHasFlag($ast, node, 'default');
}

return strippedData;
/**
* Parse the raw expression of a variable declaration excluding flags
*/
function parseExpression($ast, declaration) {
let flagsReached = false;

return stringify($ast(declaration)
.children('value')
.get(0))
.trim();
}

/**
* Extract variable declaration and expression from a chunk of sass source using provided regex
* Parse declaration node into declaration object
*/
function extractVariables(data, regex) {
const variables = [];
function parseDeclaration($ast, declaration) {
const variable = {};

variable.declarationClean = $ast(declaration)
.children('property')
.value();

let matches;
while(matches = regex.exec(data)) {
const declaration = matches[1];
const expression = matches[2];
const declarationClean = declaration.replace('$', '');
variable.declaration = `$${variable.declarationClean}`;

variables.push({ declaration, expression, declarationClean });
}
variable.expression = parseExpression($ast, declaration);
variable.flags = {
default: isDefaultDeclaration($ast, declaration),
global: isExplicitGlobalDeclaration($ast, declaration),
};

return variables;
return variable;
}

/**
* Parse variables declarations from a chunk of sass source
* Parse variable declarations from a chunk of sass source
*/
export function parseDeclarations(data) {
const decommentedData = stripByRegex(data, REGEX_COMMENTS);
const decontextifiedData = stripByRegex(decommentedData, REGEX_DEEP_CONTEXT);
const ast = parse(data);
const $ast = createQueryWrapper(ast);

const implicitGlobalDeclarations = $ast('declaration').hasParent('stylesheet');
const explicitGlobalDeclarations = $ast('declaration').hasParent('block')
.filter(node => isExplicitGlobalDeclaration($ast, node));

const explicitGlobals = extractVariables(decommentedData, REGEX_VARIABLE_GLOBAL_EXPLICIT);
const implicitGlobals = extractVariables(decontextifiedData, REGEX_VARIABLE_GLOBAL_IMPLICIT);
let implicitGlobals = implicitGlobalDeclarations.map(declaration => parseDeclaration($ast, declaration));
let explicitGlobals = explicitGlobalDeclarations.map(declaration => parseDeclaration($ast, declaration));

return { explicitGlobals, implicitGlobals };
}
63 changes: 31 additions & 32 deletions test/basic.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
const { expect } = require('chai');
const path = require('path');
const { render, renderSync } = require('../lib');
const { normalizePath } = require('../lib/util');
const { render, renderSync } = require('../src');
const { normalizePath } = require('../src/util');
const { EOL } = require('os');

const basicImplicitFile = path.join(__dirname, 'sass', 'basic-implicit.scss');
const basicExplicitFile = path.join(__dirname, 'sass', 'basic-explicit.scss');
const basicMixedFile = path.join(__dirname, 'sass', 'basic-mixed.scss');

function verifyBasic(rendered, sourceFile, mapIncluded) {
function verifyBasic(rendered, sourceFile, explicit, mixed) {
expect(rendered.vars).to.exist;
expect(rendered.vars).to.have.property('global');
expect(rendered.vars.global).to.have.property('$number1');
Expand All @@ -18,22 +18,23 @@ function verifyBasic(rendered, sourceFile, mapIncluded) {
expect(rendered.vars.global).to.have.property('$string');
expect(rendered.vars.global).to.have.property('$boolean');
expect(rendered.vars.global).to.have.property('$null');
expect(rendered.vars.global).to.have.property('$map');

expect(rendered.vars.global.$number1.value).to.equal(100);
expect(rendered.vars.global.$number1.unit).to.equal('px');
expect(rendered.vars.global.$number1.type).to.equal('SassNumber');
expect(rendered.vars.global.$number1.sources).to.have.length(1);
expect(rendered.vars.global.$number1.sources[0]).to.equal(normalizePath(sourceFile));
expect(rendered.vars.global.$number1.expressions).to.have.length(1);
expect(rendered.vars.global.$number1.expressions[0]).to.equal('100px');
expect(rendered.vars.global.$number1.expressions[0]).to.equal(`100px${ explicit || mixed ? ' !global' : ''}`);

expect(rendered.vars.global.$number2.value).to.equal(200);
expect(rendered.vars.global.$number2.unit).to.equal('px');
expect(rendered.vars.global.$number2.type).to.equal('SassNumber');
expect(rendered.vars.global.$number2.sources).to.have.length(1);
expect(rendered.vars.global.$number2.sources[0]).to.equal(normalizePath(sourceFile));
expect(rendered.vars.global.$number2.expressions).to.have.length(1);
expect(rendered.vars.global.$number2.expressions[0]).to.equal('$number1 * 2');
expect(rendered.vars.global.$number2.expressions[0]).to.equal(`$number1 * 2${ explicit || mixed ? ' !global' : ''}`);

expect(rendered.vars.global.$color.value.r).to.equal(255);
expect(rendered.vars.global.$color.value.g).to.equal(0);
Expand All @@ -44,14 +45,14 @@ function verifyBasic(rendered, sourceFile, mapIncluded) {
expect(rendered.vars.global.$color.sources).to.have.length(1);
expect(rendered.vars.global.$color.sources[0]).to.equal(normalizePath(sourceFile));
expect(rendered.vars.global.$color.expressions).to.have.length(1);
expect(rendered.vars.global.$color.expressions[0]).to.equal('get-color()');
expect(rendered.vars.global.$color.expressions[0]).to.equal(`get-color()${ explicit || mixed ? ' !global' : ''}`);

expect(rendered.vars.global.$list.value).to.have.length(3);
expect(rendered.vars.global.$list.type).to.equal('SassList');
expect(rendered.vars.global.$list.sources).to.have.length(1);
expect(rendered.vars.global.$list.sources[0]).to.equal(normalizePath(sourceFile));
expect(rendered.vars.global.$list.expressions).to.have.length(1);
expect(rendered.vars.global.$list.expressions[0]).to.equal('1px solid black');
expect(rendered.vars.global.$list.expressions[0]).to.equal(`1px solid black${ explicit ? ' !global' : ''}`);
expect(rendered.vars.global.$list.value[0].value).to.equal(1);
expect(rendered.vars.global.$list.value[0].unit).to.equal('px');
expect(rendered.vars.global.$list.value[0].type).to.equal('SassNumber');
Expand All @@ -69,52 +70,50 @@ function verifyBasic(rendered, sourceFile, mapIncluded) {
expect(rendered.vars.global.$string.sources).to.have.length(1);
expect(rendered.vars.global.$string.sources[0]).to.equal(normalizePath(sourceFile));
expect(rendered.vars.global.$string.expressions).to.have.length(1);
expect(rendered.vars.global.$string.expressions[0]).to.equal('\'string\'');
expect(rendered.vars.global.$string.expressions[0]).to.equal(`\'string\'${ explicit ? ' !global' : ''}`);

expect(rendered.vars.global.$boolean.value).to.equal(true);
expect(rendered.vars.global.$boolean.type).to.equal('SassBoolean');
expect(rendered.vars.global.$boolean.sources).to.have.length(1);
expect(rendered.vars.global.$boolean.sources[0]).to.equal(normalizePath(sourceFile));
expect(rendered.vars.global.$boolean.expressions).to.have.length(1);
expect(rendered.vars.global.$boolean.expressions[0]).to.equal('true');
expect(rendered.vars.global.$boolean.expressions[0]).to.equal(`true${ explicit ? ' !global' : ''}`);

expect(rendered.vars.global.$null.value).to.equal(null);
expect(rendered.vars.global.$null.type).to.equal('SassNull');
expect(rendered.vars.global.$null.sources).to.have.length(1);
expect(rendered.vars.global.$null.sources[0]).to.equal(normalizePath(sourceFile));
expect(rendered.vars.global.$null.expressions).to.have.length(1);
expect(rendered.vars.global.$null.expressions[0]).to.equal('null');

if(mapIncluded) {
expect(rendered.vars.global.$map.type).to.equal('SassMap');
expect(rendered.vars.global.$map.value.number.value).to.equal(2);
expect(rendered.vars.global.$map.value.number.unit).to.equal('em');
expect(rendered.vars.global.$map.value.number.type).to.equal('SassNumber');
expect(rendered.vars.global.$map.value.string.value).to.equal('mapstring');
expect(rendered.vars.global.$map.value.string.type).to.equal('SassString');
expect(rendered.vars.global.$map.sources).to.have.length(1);
expect(rendered.vars.global.$map.sources[0]).to.equal(normalizePath(sourceFile));
expect(rendered.vars.global.$map.expressions).to.have.length(1);
expect(rendered.vars.global.$map.expressions[0]).to.be.oneOf([
`(${EOL} number: 2em,${EOL} string: 'mapstring'${EOL})`,
`(\n number: 2em,\n string: 'mapstring'\n)`,
]);
}
expect(rendered.vars.global.$null.expressions[0]).to.equal(`null${ explicit ? ' !global' : ''}`);

expect(rendered.vars.global.$map.type).to.equal('SassMap');
expect(rendered.vars.global.$map.value.number.value).to.equal(2);
expect(rendered.vars.global.$map.value.number.unit).to.equal('em');
expect(rendered.vars.global.$map.value.number.type).to.equal('SassNumber');
expect(rendered.vars.global.$map.value.string.value).to.equal('mapstring');
expect(rendered.vars.global.$map.value.string.type).to.equal('SassString');
expect(rendered.vars.global.$map.sources).to.have.length(1);
expect(rendered.vars.global.$map.sources[0]).to.equal(normalizePath(sourceFile));
expect(rendered.vars.global.$map.expressions).to.have.length(1);
expect(rendered.vars.global.$map.expressions[0]).to.be.oneOf([
`(${EOL} number: 2em,${EOL} string: 'mapstring'${EOL})${ explicit ? ' !global' : ''}`,
`(\n number: 2em,\n string: 'mapstring'\n)${ explicit ? ' !global' : ''}`,
]);
}

describe('basic-implicit', () => {
describe('sync', () => {
it('should extract all variables', () => {
const rendered = renderSync({ file: basicImplicitFile })
verifyBasic(rendered, basicImplicitFile, true);
verifyBasic(rendered, basicImplicitFile, false, false);
});
});

describe('async', () => {
it('should extract all variables', () => {
return render({ file: basicImplicitFile })
.then(rendered => {
verifyBasic(rendered, basicImplicitFile, true);
verifyBasic(rendered, basicImplicitFile, false, false);
});
});
});
Expand All @@ -124,15 +123,15 @@ describe('basic-explicit', () => {
describe('sync', () => {
it('should extract all variables', () => {
const rendered = renderSync({ file: basicExplicitFile })
verifyBasic(rendered, basicExplicitFile, false);
verifyBasic(rendered, basicExplicitFile, true, false);
});
});

describe('async', () => {
it('should extract all variables', () => {
return render({ file: basicExplicitFile })
.then(rendered => {
verifyBasic(rendered, basicExplicitFile, false);
verifyBasic(rendered, basicExplicitFile, true, false);
});
});
});
Expand All @@ -142,15 +141,15 @@ describe('basic-mixed', () => {
describe('sync', () => {
it('should extract all variables', () => {
const rendered = renderSync({ file: basicMixedFile })
verifyBasic(rendered, basicMixedFile, true);
verifyBasic(rendered, basicMixedFile, false, true);
});
});

describe('async', () => {
it('should extract all variables', () => {
return render({ file: basicMixedFile })
.then(rendered => {
verifyBasic(rendered, basicMixedFile, true);
verifyBasic(rendered, basicMixedFile, false, true);
});
});
});
Expand Down
4 changes: 2 additions & 2 deletions test/comments.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const { expect } = require('chai');
const path = require('path');
const { render, renderSync } = require('../lib');
const { normalizePath } = require('../lib/util');
const { render, renderSync } = require('../src');
const { normalizePath } = require('../src/util');

const commentFile = path.join(__dirname, 'sass', 'comments.scss');

Expand Down
4 changes: 2 additions & 2 deletions test/functions.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const { expect } = require('chai');
const path = require('path');
const { render, renderSync } = require('../lib');
const { normalizePath } = require('../lib/util');
const { render, renderSync } = require('../src');
const { normalizePath } = require('../src/util');
const { types } = require('node-sass');

const functionsFile = path.join(__dirname, 'sass', 'functions.scss');
Expand Down
4 changes: 2 additions & 2 deletions test/include.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const { expect } = require('chai');
const path = require('path');
const { render, renderSync } = require('../lib');
const { normalizePath } = require('../lib/util');
const { render, renderSync } = require('../src');
const { normalizePath } = require('../src/util');

const includeRootFile = path.join(__dirname, 'sass', 'include', 'root.scss');
const includeRoot2File = path.join(__dirname, 'sass', 'include', 'root2.scss');
Expand Down
4 changes: 2 additions & 2 deletions test/inline.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const { expect } = require('chai');
const path = require('path');
const { render, renderSync } = require('../lib');
const { normalizePath } = require('../lib/util');
const { render, renderSync } = require('../src');
const { normalizePath } = require('../src/util');

const inlineData = `
$number1: 123px;
Expand Down
4 changes: 2 additions & 2 deletions test/nested.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const { expect } = require('chai');
const path = require('path');
const { render, renderSync } = require('../lib');
const { normalizePath } = require('../lib/util');
const { render, renderSync } = require('../src');
const { normalizePath } = require('../src/util');

const nestedBasicFile = path.join(__dirname, 'sass', 'nested', 'nested-basic.scss');
const nestedSubFile = path.join(__dirname, 'sass', 'nested', 'sub', 'sub.scss');
Expand Down
4 changes: 2 additions & 2 deletions test/order.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const { expect } = require('chai');
const path = require('path');
const { render, renderSync } = require('../lib');
const { normalizePath } = require('../lib/util');
const { render, renderSync } = require('../src');
const { normalizePath } = require('../src/util');

const orderFile = path.join(__dirname, 'sass', 'order.scss');
const order1File = path.join(__dirname, 'sass', 'order', '1.scss');
Expand Down
4 changes: 2 additions & 2 deletions test/partial.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const { expect } = require('chai');
const path = require('path');
const { render, renderSync } = require('../lib');
const { normalizePath } = require('../lib/util');
const { render, renderSync } = require('../src');
const { normalizePath } = require('../src/util');

const partialFile = path.join(__dirname, 'sass', 'partial.scss');
const somePartialFile = path.join(__dirname, 'sass', '_somepartial.scss');
Expand Down
4 changes: 4 additions & 0 deletions test/sass/basic-explicit.scss
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,8 @@ div {
$string: 'string' !global;
$boolean: true !global;
$null: null !global;
$map: (
number: 2em,
string: 'mapstring'
) !global;
}

0 comments on commit dfc9796

Please sign in to comment.