diff --git a/Cargo.lock b/Cargo.lock index 7eaae3e383..c2dbf1a2cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,9 +61,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.43" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28ae2b3dec75a406790005a200b1bd89785afc02517a00ca99ecfe093ee9e6cf" +checksum = "61604a8f862e1d5c3229fdd78f8b02c68dcf73a4c4b05fd636d12240aaa242c1" [[package]] name = "arrayref" @@ -388,6 +388,7 @@ dependencies = [ "textwrap", "unicode-width", "vec_map", + "yaml-rust", ] [[package]] @@ -1797,6 +1798,9 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "serde", + "serde_json", + "thiserror", ] [[package]] @@ -3295,18 +3299,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.26" +version = "1.0.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93119e4feac1cbe6c798c34d3a53ea0026b0b1de6a120deef895137c0529bfe2" +checksum = "854babe52e4df1653706b98fcfc05843010039b406875930a70e4d9644e5c417" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.26" +version = "1.0.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "060d69a0afe7796bf42e9e2ff91f5ee691fb15c53d38b4b62a9a53eb23164745" +checksum = "aa32fd3f627f367fe16f893e2597ae3c05020f8bba2666a4e6ea73d377e5714b" dependencies = [ "proc-macro2", "quote", @@ -3853,6 +3857,12 @@ dependencies = [ "log", ] +[[package]] +name = "yaml-rust" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e66366e18dc58b46801afbf2ca7661a9f59cc8c5962c29892b6039b4f86fa992" + [[package]] name = "zeitstempel" version = "0.1.1" diff --git a/components/support/nimbus-fml/Cargo.toml b/components/support/nimbus-fml/Cargo.toml index ab4eaa9a98..3c648bd0c3 100644 --- a/components/support/nimbus-fml/Cargo.toml +++ b/components/support/nimbus-fml/Cargo.toml @@ -7,5 +7,8 @@ license = "MPL-2.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -clap = "2.33" -anyhow = "1" \ No newline at end of file +clap = {version = "2.33.0", features = ["yaml"]} +anyhow = "1.0.44" +serde_json = "1" +serde = { version = "1.0", features = ["derive"] } +thiserror = "1.0.29" diff --git a/components/support/nimbus-fml/fixtures/simple_homescreen.json b/components/support/nimbus-fml/fixtures/simple_homescreen.json new file mode 100644 index 0000000000..dbef796efc --- /dev/null +++ b/components/support/nimbus-fml/fixtures/simple_homescreen.json @@ -0,0 +1,50 @@ +{ + "enum_defs": [ + { + "name": "SectionId", + "doc": "The sections of the homescreen", + "variants": [ + { + "name": "top-sites", + "doc": "The original frecency sorted sites" + }, + { + "name": "jump-back-in", + "doc": "Jump back in section" + }, + { + "name": "recently-saved", + "doc": "Tabs that have been bookmarked recently" + } + ] + } + ], + "obj_defs": [], + "hints": {}, + "feature_defs": [ + { + "name": "homescreen", + "doc": "Represents the homescreen feature", + "props": [ + { + "name": "sections-enabled", + "doc": "A map of booleans", + "typ": { + "EnumMap": [ + { + "Enum": "SectionId" + }, + "String" + ] + }, + "default": { + "jump-back-in": false, + "recently-saved": false, + "top-sites": true + } + } + ], + "default": null + } + ] +} \ No newline at end of file diff --git a/components/support/nimbus-fml/fixtures/simple_nimbus_validation.json b/components/support/nimbus-fml/fixtures/simple_nimbus_validation.json new file mode 100644 index 0000000000..2caf20f559 --- /dev/null +++ b/components/support/nimbus-fml/fixtures/simple_nimbus_validation.json @@ -0,0 +1,32 @@ +{ + "enum_defs": [], + "obj_defs": [], + "hints": {}, + "feature_defs": [ + { + "name": "nimbus-validation", + "doc": "A simple validation feature", + "props": [ + { + "name": "enabled", + "doc": "An example boolean property", + "typ": "Boolean", + "default": true + }, + { + "name": "row-count", + "doc": "An example integer property", + "typ": "Boolean", + "default": 2 + }, + { + "name": "deeplink", + "doc": "An example string property", + "typ": "String", + "default": "deeplink://settings" + } + ], + "default": null + } + ] +} \ No newline at end of file diff --git a/components/support/nimbus-fml/src/cli.yaml b/components/support/nimbus-fml/src/cli.yaml new file mode 100644 index 0000000000..951737cea3 --- /dev/null +++ b/components/support/nimbus-fml/src/cli.yaml @@ -0,0 +1,37 @@ +name: nimbus-fml +version: "1.0" +author: nimbus-dev@mozilla.com +about: Tool for working with Nimbus Feature Manifests +args: + - config: + short: c + long: config + value_name: FILE + help: Sets a custom config file + takes_value: true + - verbose: + short: v + multiple: true + help: Sets the level of verbosity +subcommands: + - struct: + about: Generate the app code for configuring features + args: + - language: + short: l + long: language + value_name: LANGUAGE + possible_values: [ kotlin, swift, ir ] + - INPUT: + help: Sets the input file to use + required: true + index: 1 + - ir: + help: The input file is intermediate representation. Useful for debugging FML. + long: ir + - output: + help: The output file + short: o + long: output + value_name: FILE + required: true diff --git a/components/support/nimbus-fml/src/error.rs b/components/support/nimbus-fml/src/error.rs new file mode 100644 index 0000000000..85c18072f2 --- /dev/null +++ b/components/support/nimbus-fml/src/error.rs @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * */ + +//! Not complete yet +//! This is where the error definitions can go +//! TODO: Implement proper error handling, this would include defining the error enum, +//! impl std::error::Error using `thiserror` and ensuring all errors are handled appropriately +#[derive(Debug, thiserror::Error)] +pub enum FMLError { + #[error("IO error: {0}")] + IOError(#[from] std::io::Error), + #[error("JSON Error: {0}")] + JSONError(#[from] serde_json::Error), + #[error("Invalid path: {0}")] + InvalidPath(String), + #[error("Internal error: {0}")] + InternalError(&'static str), +} + +pub type Result = std::result::Result; diff --git a/components/support/nimbus-fml/src/fixtures.rs b/components/support/nimbus-fml/src/fixtures.rs new file mode 100644 index 0000000000..0e5a5fec85 --- /dev/null +++ b/components/support/nimbus-fml/src/fixtures.rs @@ -0,0 +1,5 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this +* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +pub(crate) mod intermediate_representation; diff --git a/components/support/nimbus-fml/src/fixtures/intermediate_representation.rs b/components/support/nimbus-fml/src/fixtures/intermediate_representation.rs new file mode 100644 index 0000000000..2031e8293c --- /dev/null +++ b/components/support/nimbus-fml/src/fixtures/intermediate_representation.rs @@ -0,0 +1,108 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this +* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use crate::intermediate_representation::{ + EnumDef, FeatureDef, FeatureManifest, PropDef, TypeRef, VariantDef, +}; +use serde_json::json; + +pub(crate) fn get_simple_nimbus_validation_feature() -> FeatureManifest { + FeatureManifest { + enum_defs: Default::default(), + obj_defs: Default::default(), + hints: Default::default(), + feature_defs: vec![FeatureDef::new( + "nimbus-validation", + "A simple validation feature", + vec![ + PropDef { + name: "enabled".into(), + doc: "An example boolean property".into(), + typ: TypeRef::Boolean, + default: json!(true), + }, + PropDef { + name: "row-count".into(), + doc: "An example integer property".into(), + typ: TypeRef::Boolean, + default: json!(2), + }, + PropDef { + name: "deeplink".into(), + doc: "An example string property".into(), + typ: TypeRef::String, + default: json!("deeplink://settings"), + }, + ], + None, + )], + } +} + +pub(crate) fn get_simple_homescreen_feature() -> FeatureManifest { + FeatureManifest { + enum_defs: vec![EnumDef { + name: "SectionId".into(), + doc: "The sections of the homescreen".into(), + variants: vec![ + VariantDef::new("top-sites", "The original frecency sorted sites"), + VariantDef::new("jump-back-in", "Jump back in section"), + VariantDef::new("recently-saved", "Tabs that have been bookmarked recently"), + ], + }], + obj_defs: Default::default(), + hints: Default::default(), + feature_defs: vec![FeatureDef::new( + "homescreen", + "Represents the homescreen feature", + vec![PropDef { + name: "sections-enabled".into(), + doc: "A map of booleans".into(), + typ: TypeRef::EnumMap( + Box::new(TypeRef::Enum("SectionId".into())), + Box::new(TypeRef::String), + ), + default: json!({ + "top-sites": true, + "jump-back-in": false, + "recently-saved": false, + }), + }], + None, + )], + } +} + +#[cfg(test)] +mod dump_to_file { + use std::path::PathBuf; + + use crate::error::Result; + + use super::*; + + fn write(fm: &FeatureManifest, nm: &str) -> Result<()> { + let root = std::env::var("CARGO_MANIFEST_DIR") + .expect("Missing $CARGO_MANIFEST_DIR, cannot write fixtures files"); + let fixtures_dir = "fixtures"; + let path: PathBuf = [&root, fixtures_dir, nm].iter().collect(); + + let contents = serde_json::to_string_pretty(fm)?; + + std::fs::write(path, contents)?; + + Ok(()) + } + + #[test] + fn write_to_fixtures_dir() -> Result<()> { + write(&get_simple_homescreen_feature(), "simple_homescreen.json")?; + write( + &get_simple_nimbus_validation_feature(), + "simple_nimbus_validation.json", + )?; + + Ok(()) + } +} diff --git a/components/support/nimbus-fml/src/intermediate_representation.rs b/components/support/nimbus-fml/src/intermediate_representation.rs new file mode 100644 index 0000000000..9bf3b31aa0 --- /dev/null +++ b/components/support/nimbus-fml/src/intermediate_representation.rs @@ -0,0 +1,148 @@ +use std::collections::HashMap; + +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this +* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// The `TypeRef` enum defines a reference to a type. +/// +/// Other types will be defined in terms of these enum values. +/// +/// They represent the types available via the current `Variables` API— +/// some primitives and structural types— and can be represented by +/// Kotlin, Swift and JSON Schema. +/// +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub enum TypeRef { + // Current primitives. + String, + Int, + Boolean, + + // Strings can be coerced into a few types. + // The types here will require the app's bundle or context to look up the final value. + // They will likely have + BundleText(StringId), + BundleImage(StringId), + + Enum(String), + // JSON objects can represent a data class. + Object(String), + + // JSON objects can also represent a `Map` or a `Map` with + // keys that can be derived from a string. + StringMap(Box), + // We can coerce the String keys into Enums, so this repesents that. + EnumMap(Box, Box), + + List(Box), + Option(Box), +} + +pub(crate) type StringId = String; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FeatureManifest { + pub enum_defs: Vec, + pub obj_defs: Vec, + // `hints` are useful for things that will be constructed from strings + // such as images and display text. + pub hints: HashMap, + pub feature_defs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FeatureDef { + name: String, + doc: String, + props: Vec, + default: Option, +} +impl FeatureDef { + #[allow(dead_code)] + pub fn new(name: &str, doc: &str, props: Vec, default: Option) -> Self { + Self { + name: name.into(), + doc: doc.into(), + props, + default, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct EnumDef { + pub name: String, + pub doc: String, + pub variants: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FromStringDef { + pub name: String, + pub doc: String, + pub variants: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct VariantDef { + name: String, + doc: String, +} +impl VariantDef { + #[allow(dead_code)] + pub fn new(name: &str, doc: &str) -> Self { + Self { + name: name.into(), + doc: doc.into(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ObjectDef { + name: String, + doc: String, + props: Vec, +} +impl ObjectDef { + #[allow(dead_code)] + pub fn new(name: &str, doc: &str, props: Vec) -> Self { + Self { + name: name.into(), + doc: doc.into(), + props, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PropDef { + pub name: String, + pub doc: String, + pub typ: TypeRef, + pub default: Literal, +} + +type Literal = Value; + +#[cfg(test)] +mod unit_tests { + use super::*; + use crate::error::Result; + use crate::fixtures::intermediate_representation; + + #[test] + fn can_ir_represent_smoke_test() -> Result<()> { + let m1 = intermediate_representation::get_simple_homescreen_feature(); + let string = serde_json::to_string(&m1)?; + let m2: FeatureManifest = serde_json::from_str(&string)?; + + assert_eq!(m1, m2); + + Ok(()) + } +} diff --git a/components/support/nimbus-fml/src/lib.rs b/components/support/nimbus-fml/src/lib.rs index e9d454721a..df15c62e2e 100644 --- a/components/support/nimbus-fml/src/lib.rs +++ b/components/support/nimbus-fml/src/lib.rs @@ -2,4 +2,10 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +pub mod error; +pub mod intermediate_representation; pub mod parser; + +#[cfg(test)] +#[allow(dead_code)] +pub mod fixtures; diff --git a/components/support/nimbus-fml/src/main.rs b/components/support/nimbus-fml/src/main.rs index c8162c162c..0d55c2d550 100644 --- a/components/support/nimbus-fml/src/main.rs +++ b/components/support/nimbus-fml/src/main.rs @@ -2,28 +2,54 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -use clap::{App, Arg}; +#[macro_use] +extern crate clap; +use clap::{App, ArgMatches}; +use nimbus_fml::error::{FMLError, Result}; +use nimbus_fml::intermediate_representation::FeatureManifest; use nimbus_fml::parser::Parser; -use std::fs::File; -fn main() -> anyhow::Result<()> { - let matches = App::new("Nimbus Feature Manifest") - .version("0.1.0") - .author("Nimbus SDK Engineering") - .about("A tool to generate code using an experiment feature manifest") - .arg( - Arg::with_name("manifest") - .short("m") - .long("manifest") - .value_name("FILE") - .help("Sets the manifest file to use") - .required(true) - .takes_value(true), - ) - .get_matches(); - let manifest_file_path = matches - .value_of("manifest") - .expect("Manifest path is required, but not found"); - let file = File::open(manifest_file_path)?; - let _parser = Parser::new(file); +use std::path::Path; +use std::{fs::File, path::PathBuf}; + +fn main() -> Result<()> { + let yaml = load_yaml!("cli.yaml"); + let matches = App::from_yaml(yaml).get_matches(); + let cwd = std::env::current_dir()?; + if let Some(cmd) = matches.subcommand_matches("struct") { + let manifest_file_path = file_path("INPUT", cmd, &cwd)?; + + let ir = if !cmd.is_present("ir") { + let file = File::open(manifest_file_path)?; + let _parser: Parser = Parser::new(file); + unimplemented!("No parser is available") + } else { + let string = slurp_file(&manifest_file_path)?; + serde_json::from_str::(&string)? + }; + + let output_path = file_path("output", cmd, &cwd)?; + match cmd.value_of("language") { + Some("ir") => { + let contents = serde_json::to_string_pretty(&ir)?; + std::fs::write(output_path, contents)?; + } + _ => unimplemented!("Language not implemented yet"), + }; + } Ok(()) } + +fn file_path(name: &str, args: &ArgMatches, cwd: &Path) -> Result { + let mut abs = cwd.to_path_buf(); + match args.value_of(name) { + Some(suffix) => { + abs.push(suffix); + Ok(abs) + } + _ => Err(FMLError::InvalidPath(name.into())), + } +} + +fn slurp_file(file_name: &Path) -> Result { + Ok(std::fs::read_to_string(file_name)?) +}