aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/shirabe-external-packages/src/seld/json_lint/json_parser.rs1031
-rw-r--r--crates/shirabe-external-packages/src/seld/json_lint/lexer.rs545
-rw-r--r--crates/shirabe-external-packages/src/seld/json_lint/mod.rs2
-rw-r--r--crates/shirabe-external-packages/src/symfony/console/helper/table.rs13
-rw-r--r--crates/shirabe-external-packages/src/symfony/process/executable_finder.rs111
-rw-r--r--crates/shirabe-external-packages/src/symfony/process/php_executable_finder.rs49
-rw-r--r--crates/shirabe-external-packages/src/symfony/string/byte_string.rs20
-rw-r--r--crates/shirabe-external-packages/src/symfony/string/code_point_string.rs161
-rw-r--r--crates/shirabe-external-packages/src/symfony/string/mod.rs11
-rw-r--r--crates/shirabe-external-packages/src/symfony/string/unicode_string.rs18
-rw-r--r--crates/shirabe-php-shim/src/datetime.rs21
-rw-r--r--crates/shirabe-php-shim/src/output.rs3
-rw-r--r--crates/shirabe-php-shim/src/stream.rs48
-rw-r--r--crates/shirabe-php-shim/src/zip.rs199
-rw-r--r--crates/shirabe-spdx-licenses/Cargo.toml2
-rw-r--r--crates/shirabe-spdx-licenses/src/spdx_licenses.rs150
-rw-r--r--crates/shirabe/tests/json/json_file_test.rs10
17 files changed, 2310 insertions, 84 deletions
diff --git a/crates/shirabe-external-packages/src/seld/json_lint/json_parser.rs b/crates/shirabe-external-packages/src/seld/json_lint/json_parser.rs
index 193a208..6607938 100644
--- a/crates/shirabe-external-packages/src/seld/json_lint/json_parser.rs
+++ b/crates/shirabe-external-packages/src/seld/json_lint/json_parser.rs
@@ -1,9 +1,71 @@
//! ref: composer/vendor/seld/jsonlint/src/Seld/JsonLint/JsonParser.php
+use std::collections::HashMap;
+
+use indexmap::IndexMap;
use shirabe_php_shim::PhpMixed;
+use super::DuplicateKeyException;
+use super::ParsingException;
+use super::lexer::{Lexer, YylLoc};
+
+/// Semantic value held on the parser value stack ($vstack). Most values are JSON values (PhpMixed),
+/// but object members are represented as a `[key, value]` pair while being reduced.
+#[derive(Debug, Clone)]
+enum SemValue {
+ Null,
+ Value(PhpMixed),
+ /// Raw token text from the lexer (yytext), before semantic actions transform it.
+ Text(String),
+ /// A `[key, value]` member pair (production 15).
+ Member(String, PhpMixed),
+}
+
+impl SemValue {
+ fn into_value(self) -> PhpMixed {
+ match self {
+ SemValue::Null => PhpMixed::Null,
+ SemValue::Value(v) => v,
+ SemValue::Text(s) => PhpMixed::String(s),
+ SemValue::Member(_, _) => PhpMixed::Null,
+ }
+ }
+}
+
+/// Result of `performAction`: the new semantic value plus an optional early-return (production 6).
+enum ActionResult {
+ /// `$$` was assigned but parsing continues (PHP returns `[token, new Undefined()]`).
+ Continue(SemValue),
+ /// Production 6 returned the accumulated value directly.
+ Return(PhpMixed),
+}
+
+#[derive(Debug)]
+pub struct JsonParser {
+ productions_: HashMap<i64, (i64, i64)>,
+ terminals_: HashMap<i64, &'static str>,
+ table: Vec<HashMap<i64, TableAction>>,
+ default_actions: HashMap<i64, (i64, i64)>,
+}
+
+/// Per-parse mutable state. PHP keeps `$flags`/`$stack`/`$vstack`/`$lstack` on the parser instance;
+/// holding them here keeps `parse()` `&self` so the parser can be shared/reused.
#[derive(Debug)]
-pub struct JsonParser;
+struct ParseState {
+ flags: u32,
+ stack: Vec<i64>,
+ vstack: Vec<SemValue>,
+ lstack: Vec<YylLoc>,
+}
+
+/// An entry in the parse table: either a shift/reduce action pair `[type, arg]` or a goto state.
+#[derive(Debug, Clone, Copy)]
+enum TableAction {
+ /// `array(type, arg)` — type 1 = shift, 2 = reduce, 3 = accept.
+ Action(i64, i64),
+ /// A bare integer = goto state (used for nonterminals).
+ Goto(i64),
+}
impl Default for JsonParser {
fn default() -> Self {
@@ -13,17 +75,972 @@ impl Default for JsonParser {
impl JsonParser {
pub const DETECT_KEY_CONFLICTS: u32 = 1;
+ pub const ALLOW_DUPLICATE_KEYS: u32 = 2;
+ pub const PARSE_TO_ASSOC: u32 = 4;
+ pub const ALLOW_COMMENTS: u32 = 8;
+ pub const ALLOW_DUPLICATE_KEYS_TO_ARRAY: u32 = 16;
pub fn new() -> Self {
- todo!()
+ let productions_: HashMap<i64, (i64, i64)> = [
+ (1, (3, 1)),
+ (2, (5, 1)),
+ (3, (7, 1)),
+ (4, (9, 1)),
+ (5, (9, 1)),
+ (6, (12, 2)),
+ (7, (13, 1)),
+ (8, (13, 1)),
+ (9, (13, 1)),
+ (10, (13, 1)),
+ (11, (13, 1)),
+ (12, (13, 1)),
+ (13, (15, 2)),
+ (14, (15, 3)),
+ (15, (20, 3)),
+ (16, (19, 1)),
+ (17, (19, 3)),
+ (18, (16, 2)),
+ (19, (16, 3)),
+ (20, (25, 1)),
+ (21, (25, 3)),
+ ]
+ .into_iter()
+ .collect();
+
+ let terminals_: HashMap<i64, &'static str> = [
+ (2, "error"),
+ (4, "STRING"),
+ (6, "NUMBER"),
+ (8, "NULL"),
+ (10, "TRUE"),
+ (11, "FALSE"),
+ (14, "EOF"),
+ (17, "{"),
+ (18, "}"),
+ (21, ":"),
+ (22, ","),
+ (23, "["),
+ (24, "]"),
+ ]
+ .into_iter()
+ .collect();
+
+ let table = build_table();
+
+ let default_actions: HashMap<i64, (i64, i64)> = [(16, (2, 6))].into_iter().collect();
+
+ Self {
+ productions_,
+ terminals_,
+ table,
+ default_actions,
+ }
+ }
+
+ /// ref: JsonParser::lint() — returns null on success, ParsingException on failure.
+ pub fn lint(&mut self, input: &str) -> Option<ParsingException> {
+ match self.parse(input, 0) {
+ Ok(_) => None,
+ Err(e) => match e.downcast::<ParsingException>() {
+ Ok(pe) => Some(pe),
+ // PHP only catches ParsingException; DuplicateKeyException (a subclass) is also
+ // caught, but lint() uses no flags so DETECT_KEY_CONFLICTS never triggers.
+ Err(other) => Some(ParsingException::new(other.to_string(), Default::default())),
+ },
+ }
+ }
+
+ pub fn parse(&self, input: &str, flags: u32) -> anyhow::Result<PhpMixed> {
+ if (flags & Self::ALLOW_DUPLICATE_KEYS_TO_ARRAY != 0)
+ && (flags & Self::ALLOW_DUPLICATE_KEYS != 0)
+ {
+ // PHP throws \InvalidArgumentException (uncaught fatal).
+ panic!(
+ "Only one of ALLOW_DUPLICATE_KEYS and ALLOW_DUPLICATE_KEYS_TO_ARRAY can be used, you passed in both."
+ );
+ }
+
+ self.fail_on_bom(input)?;
+
+ let mut state_ = ParseState {
+ flags,
+ stack: vec![0],
+ vstack: vec![SemValue::Null],
+ lstack: Vec::new(),
+ };
+
+ let mut yytext = String::new();
+ let mut yylineno: i64 = 0;
+ let mut yyleng: i64 = 0;
+ let mut recovering: i64 = 0;
+
+ let mut lexer = Lexer::new(flags);
+ lexer.set_input(input);
+
+ let mut yyloc = lexer.yylloc.clone();
+ state_.lstack.push(yyloc.clone());
+
+ let mut symbol: Option<i64> = None;
+ let mut pre_error_symbol: Option<i64> = None;
+ let mut err_str: Option<String> = None;
+
+ loop {
+ let mut state = state_.stack[state_.stack.len() - 1];
+
+ // use default actions if available
+ let mut action: Option<(i64, i64)> = if let Some(a) = self.default_actions.get(&state) {
+ Some(*a)
+ } else {
+ if symbol.is_none() {
+ symbol = Some(lexer.lex()?);
+ }
+ self.table[state as usize]
+ .get(&symbol.unwrap())
+ .and_then(|a| match *a {
+ TableAction::Action(t, arg) => Some((t, arg)),
+ TableAction::Goto(_) => None,
+ })
+ };
+
+ // handle parse error
+ if action.is_none() || action.unwrap().0 == 0 {
+ let sym = symbol.expect("symbol should be set");
+ if recovering == 0 {
+ // PHP iterates table entries in insertion order; mimic by sorting on symbol id.
+ let mut expected_pairs: Vec<(i64, String)> = self.table[state as usize]
+ .keys()
+ .filter_map(|p| {
+ self.terminals_
+ .get(p)
+ .filter(|_| *p > 2)
+ .map(|name| (*p, format!("'{name}'")))
+ })
+ .collect();
+ expected_pairs.sort_by_key(|(p, _)| *p);
+ let expected: Vec<String> =
+ expected_pairs.into_iter().map(|(_, s)| s).collect();
+
+ let mut message: Option<String> = None;
+ let match_first = lexer.match_.first().copied();
+ if expected.iter().any(|e| e == "'STRING'")
+ && matches!(match_first, Some(b'"') | Some(b'\''))
+ {
+ let mut msg = String::from("Invalid string");
+ if match_first == Some(b'\'') {
+ msg.push_str(
+ ", it appears you used single quotes instead of double quotes",
+ );
+ } else if let Some(found) =
+ detect_unescaped_backslash(&lexer.get_full_upcoming_input())
+ {
+ msg.push_str(", it appears you have an unescaped backslash at: ");
+ msg.push_str(&found);
+ } else if detect_unterminated_string(&lexer.get_full_upcoming_input()) {
+ msg.push_str(", it appears you forgot to terminate a string, or attempted to write a multiline string which is invalid");
+ }
+ message = Some(msg);
+ }
+
+ let mut errstr = format!("Parse error on line {}:\n", yylineno + 1);
+ errstr.push_str(&lexer.show_position());
+ errstr.push('\n');
+ if let Some(msg) = &message {
+ errstr.push_str(msg);
+ } else {
+ errstr.push_str(if expected.len() > 1 {
+ "Expected one of: "
+ } else {
+ "Expected: "
+ });
+ errstr.push_str(&expected.join(", "));
+ }
+
+ let past = lexer.get_past_input();
+ let trimmed = trim_bytes(&past);
+ if trimmed.last() == Some(&b',') {
+ errstr.push_str(" - It appears you have an extra trailing comma");
+ }
+
+ err_str = Some(errstr.clone());
+
+ let token = if let Some(name) = self.terminals_.get(&sym) {
+ super::parsing_exception::ParsingExceptionToken::Name(name.to_string())
+ } else {
+ super::parsing_exception::ParsingExceptionToken::Symbol(sym)
+ };
+ let details = super::parsing_exception::ParsingExceptionDetails {
+ text: Some(String::from_utf8_lossy(&lexer.match_).into_owned()),
+ token: Some(token),
+ line: Some(lexer.yylineno),
+ loc: Some(yyloc_to_loc(&yyloc)),
+ expected: Some(expected.clone()),
+ };
+ return Err(ParsingException::new(errstr, details).into());
+ }
+
+ // recovery path (recovering != 0). Not reachable for this grammar because the error
+ // is always reported (and thrown) above on the first failure; kept for fidelity.
+ if recovering == 3 {
+ if sym == Lexer::EOF {
+ return Err(ParsingException::new(
+ err_str.clone().unwrap_or_else(|| "Parsing halted.".into()),
+ Default::default(),
+ )
+ .into());
+ }
+ yyleng = lexer.yyleng;
+ yytext = String::from_utf8_lossy(&lexer.yytext).into_owned();
+ yylineno = lexer.yylineno;
+ yyloc = lexer.yylloc.clone();
+ symbol = Some(lexer.lex()?);
+ }
+
+ loop {
+ if self.table[state as usize].contains_key(&Lexer::T_ERROR) {
+ break;
+ }
+ if state == 0 {
+ return Err(ParsingException::new(
+ err_str.clone().unwrap_or_else(|| "Parsing halted.".into()),
+ Default::default(),
+ )
+ .into());
+ }
+ state_.pop_stack(1);
+ state = state_.stack[state_.stack.len() - 1];
+ }
+
+ pre_error_symbol = symbol;
+ symbol = Some(Lexer::T_ERROR);
+ state = state_.stack[state_.stack.len() - 1];
+ action = self.table[state as usize]
+ .get(&Lexer::T_ERROR)
+ .and_then(|a| match *a {
+ TableAction::Action(t, arg) => Some((t, arg)),
+ TableAction::Goto(_) => None,
+ });
+ if action.is_none() {
+ panic!("No table value found for {} => {}", state, Lexer::T_ERROR);
+ }
+ recovering = 3;
+ }
+
+ let action = action.unwrap();
+ match action.0 {
+ 1 => {
+ // shift
+ let sym = symbol.expect("symbol should be set");
+ state_.stack.push(sym);
+ state_.vstack.push(SemValue::Text(
+ String::from_utf8_lossy(&lexer.yytext).into_owned(),
+ ));
+ state_.lstack.push(lexer.yylloc.clone());
+ state_.stack.push(action.1);
+ symbol = None;
+ if pre_error_symbol.is_none() {
+ yyleng = lexer.yyleng;
+ yytext = String::from_utf8_lossy(&lexer.yytext).into_owned();
+ yylineno = lexer.yylineno;
+ yyloc = lexer.yylloc.clone();
+ if recovering > 0 {
+ recovering -= 1;
+ }
+ } else {
+ symbol = pre_error_symbol;
+ pre_error_symbol = None;
+ }
+ }
+ 2 => {
+ // reduce
+ let prod = self.productions_[&action.1];
+ let len = prod.1;
+
+ let position = YylLoc {
+ first_line: state_.lstack[state_.lstack.len() - (len.max(1) as usize)]
+ .first_line,
+ last_line: state_.lstack[state_.lstack.len() - 1].last_line,
+ first_column: state_.lstack[state_.lstack.len() - (len.max(1) as usize)]
+ .first_column,
+ last_column: state_.lstack[state_.lstack.len() - 1].last_column,
+ };
+
+ match state_.perform_action(&yytext, yyleng, yylineno, action.1)? {
+ ActionResult::Return(v) => return Ok(v),
+ ActionResult::Continue(new_token) => {
+ if len != 0 {
+ state_.pop_stack(len);
+ }
+ state_.stack.push(prod.0);
+ state_.vstack.push(new_token);
+ state_.lstack.push(position);
+ let goto_state = match self.table
+ [state_.stack[state_.stack.len() - 2] as usize]
+ .get(&state_.stack[state_.stack.len() - 1])
+ {
+ Some(TableAction::Goto(s)) => *s,
+ Some(TableAction::Action(_, arg)) => *arg,
+ None => panic!("No goto state"),
+ };
+ state_.stack.push(goto_state);
+ }
+ }
+ }
+ 3 => {
+ // accept
+ return Ok(PhpMixed::Bool(true));
+ }
+ _ => {}
+ }
+ }
+ }
+
+ fn fail_on_bom(&self, input: &str) -> Result<(), ParsingException> {
+ let bom = [0xEF, 0xBB, 0xBF];
+ if input.as_bytes().len() >= 3 && input.as_bytes()[0..3] == bom {
+ return Err(ParsingException::new(
+ "BOM detected, make sure your input does not include a Unicode Byte-Order-Mark"
+ .to_string(),
+ Default::default(),
+ ));
+ }
+ Ok(())
+ }
+}
+
+impl ParseState {
+ /// ref: JsonParser::performAction
+ fn perform_action(
+ &mut self,
+ _yytext: &str,
+ _yyleng: i64,
+ yylineno: i64,
+ yystate: i64,
+ ) -> anyhow::Result<ActionResult> {
+ // `$len = count($vstack) - 1` indexes the top semantic value.
+ let len = self.vstack.len() - 1;
+ // default: $$ = $1, the value at (count - production_len) ; PHP captures `$currentToken`
+ // separately, but for productions we rebuild explicitly below, defaulting to the top value.
+ let mut token: SemValue = self.vstack[len].clone();
+
+ match yystate {
+ 1 => {
+ // string interpolation of escape sequences
+ let raw = match &self.vstack[len] {
+ SemValue::Text(s) => s.clone(),
+ other => semvalue_string(other),
+ };
+ token = SemValue::Value(PhpMixed::String(string_interpolation_all(&raw)));
+ }
+ 2 => {
+ let raw = match &self.vstack[len] {
+ SemValue::Text(s) => s.clone(),
+ other => semvalue_string(other),
+ };
+ let v = if raw.contains('e') || raw.contains('E') {
+ PhpMixed::Float(php_floatval(&raw))
+ } else if !raw.contains('.') {
+ PhpMixed::Int(php_intval(&raw))
+ } else {
+ PhpMixed::Float(php_floatval(&raw))
+ };
+ token = SemValue::Value(v);
+ }
+ 3 => token = SemValue::Value(PhpMixed::Null),
+ 4 => token = SemValue::Value(PhpMixed::Bool(true)),
+ 5 => token = SemValue::Value(PhpMixed::Bool(false)),
+ 6 => {
+ let v = self.vstack[len - 1].clone().into_value();
+ return Ok(ActionResult::Return(v));
+ }
+ 13 => {
+ let v = if self.flags & JsonParser::PARSE_TO_ASSOC != 0 {
+ PhpMixed::Array(IndexMap::new())
+ } else {
+ PhpMixed::Object(IndexMap::new())
+ };
+ token = SemValue::Value(v);
+ }
+ 14 => {
+ token = SemValue::Value(self.vstack[len - 1].clone().into_value());
+ }
+ 15 => {
+ let key = match self.vstack[len - 2].clone() {
+ SemValue::Value(PhpMixed::String(s)) => s,
+ SemValue::Text(s) => s,
+ other => semvalue_string(&other),
+ };
+ let value = self.vstack[len].clone().into_value();
+ token = SemValue::Member(key, value);
+ }
+ 16 => {
+ let (property, value) = match self.vstack[len].clone() {
+ SemValue::Member(k, v) => (k, v),
+ _ => panic!("expected member pair"),
+ };
+ let mut map = IndexMap::new();
+ map.insert(property, value);
+ token = if self.flags & JsonParser::PARSE_TO_ASSOC != 0 {
+ SemValue::Value(PhpMixed::Array(map))
+ } else {
+ SemValue::Value(PhpMixed::Object(map))
+ };
+ }
+ 17 => {
+ let (key, value) = match self.vstack[len].clone() {
+ SemValue::Member(k, v) => (k, v),
+ _ => panic!("expected member pair"),
+ };
+ let mut container = self.vstack[len - 2].clone().into_value();
+ let map = match &mut container {
+ PhpMixed::Array(m) | PhpMixed::Object(m) => m,
+ _ => panic!("expected object/array container"),
+ };
+
+ if (self.flags & JsonParser::DETECT_KEY_CONFLICTS != 0) && map.contains_key(&key) {
+ // PHP inserts $this->lexer->showPosition() here; the lexer is owned by parse()
+ // and not reachable from this method, so the position line is omitted. Only the
+ // "Duplicate key" body is read by ConfigValidator (DETECT_KEY_CONFLICTS).
+ let mut errstr = format!("Parse error on line {}:\n", yylineno + 1);
+ errstr.push('\n');
+ errstr.push_str(&format!("Duplicate key: {key}"));
+ let mut details: IndexMap<String, PhpMixed> = IndexMap::new();
+ // PHP details: array('line' => $yylineno+1); the 'key' entry is read by
+ // ConfigValidator, so include it as well (PHP DuplicateKeyException stores the
+ // key separately, but Shirabe's details map carries both).
+ details.insert("key".to_string(), PhpMixed::String(key.clone()));
+ details.insert("line".to_string(), PhpMixed::Int(yylineno + 1));
+ return Err(DuplicateKeyException {
+ message: errstr,
+ code: 0,
+ details,
+ }
+ .into());
+ }
+
+ if (self.flags & JsonParser::ALLOW_DUPLICATE_KEYS != 0) && map.contains_key(&key) {
+ let mut duplicate_count = 1;
+ let mut duplicate_key;
+ loop {
+ duplicate_key = format!("{key}.{duplicate_count}");
+ duplicate_count += 1;
+ if !map.contains_key(&duplicate_key) {
+ break;
+ }
+ }
+ map.insert(duplicate_key, value);
+ } else {
+ // ALLOW_DUPLICATE_KEYS_TO_ARRAY path omitted: requires `__duplicates__` nesting;
+ // unused by Composer's lint/parse defaults.
+ map.insert(key, value);
+ }
+ token = SemValue::Value(container);
+ }
+ 18 => token = SemValue::Value(PhpMixed::List(Vec::new())),
+ 19 => {
+ token = SemValue::Value(self.vstack[len - 1].clone().into_value());
+ }
+ 20 => {
+ let v = self.vstack[len].clone().into_value();
+ token = SemValue::Value(PhpMixed::List(vec![v]));
+ }
+ 21 => {
+ let mut list = self.vstack[len - 2].clone().into_value();
+ let v = self.vstack[len].clone().into_value();
+ if let PhpMixed::List(items) = &mut list {
+ items.push(v);
+ } else {
+ panic!("expected list container");
+ }
+ token = SemValue::Value(list);
+ }
+ _ => {}
+ }
+
+ Ok(ActionResult::Continue(token))
+ }
+
+ fn pop_stack(&mut self, n: i64) {
+ let n = n as usize;
+ let new_stack_len = self.stack.len() - 2 * n;
+ self.stack.truncate(new_stack_len);
+ let new_v = self.vstack.len() - n;
+ self.vstack.truncate(new_v);
+ let new_l = self.lstack.len() - n;
+ self.lstack.truncate(new_l);
+ }
+}
+
+fn yyloc_to_loc(loc: &YylLoc) -> super::parsing_exception::ParsingExceptionLoc {
+ super::parsing_exception::ParsingExceptionLoc {
+ first_line: loc.first_line,
+ first_column: loc.first_column,
+ last_line: loc.last_line,
+ last_column: loc.last_column,
+ }
+}
+
+fn semvalue_string(v: &SemValue) -> String {
+ match v {
+ SemValue::Text(s) => s.clone(),
+ SemValue::Value(PhpMixed::String(s)) => s.clone(),
+ _ => String::new(),
}
+}
+
+/// ref: JsonParser::stringInterpolation, applied to every escape sequence in the string
+/// (PHP uses preg_replace_callback with '{(?:\\["bfnrt/\\]|\\u[a-fA-F0-9]{4})}').
+fn string_interpolation_all(input: &str) -> String {
+ let bytes = input.as_bytes();
+ let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
+ let mut i = 0;
+ while i < bytes.len() {
+ if bytes[i] == b'\\' && i + 1 < bytes.len() {
+ let n = bytes[i + 1];
+ match n {
+ b'\\' => {
+ out.push(b'\\');
+ i += 2;
+ continue;
+ }
+ b'"' => {
+ out.push(b'"');
+ i += 2;
+ continue;
+ }
+ b'b' => {
+ out.push(8);
+ i += 2;
+ continue;
+ }
+ b'f' => {
+ out.push(12);
+ i += 2;
+ continue;
+ }
+ b'n' => {
+ out.push(b'\n');
+ i += 2;
+ continue;
+ }
+ b'r' => {
+ out.push(b'\r');
+ i += 2;
+ continue;
+ }
+ b't' => {
+ out.push(b'\t');
+ i += 2;
+ continue;
+ }
+ b'/' => {
+ out.push(b'/');
+ i += 2;
+ continue;
+ }
+ b'u' if i + 5 < bytes.len()
+ && bytes[i + 2..i + 6].iter().all(|b| b.is_ascii_hexdigit()) =>
+ {
+ let hex = &input[i + 2..i + 6];
+ if let Ok(cp) = u32::from_str_radix(hex, 16) {
+ if let Some(c) = char::from_u32(cp) {
+ let mut buf = [0u8; 4];
+ out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
+ }
+ }
+ i += 6;
+ continue;
+ }
+ _ => {}
+ }
+ }
+ out.push(bytes[i]);
+ i += 1;
+ }
+ String::from_utf8_lossy(&out).into_owned()
+}
+
+/// PHP floatval for a JSON number literal.
+fn php_floatval(s: &str) -> f64 {
+ s.parse::<f64>().unwrap_or(0.0)
+}
+
+/// PHP intval for a JSON integer literal.
+fn php_intval(s: &str) -> i64 {
+ // PHP intval parses leading integer portion; JSON numbers here are already valid integers.
+ s.parse::<i64>().unwrap_or_else(|_| {
+ // overflow fallback: PHP would clamp/convert, but valid lexed ints rarely overflow i64.
+ if s.starts_with('-') {
+ i64::MIN
+ } else {
+ i64::MAX
+ }
+ })
+}
+
+/// ref: preg_match('{".+?(\\[^"bfnrt/\\u](...)?)}', $fullUpcomingInput) — returns the captured
+/// group 1 (the unescaped backslash plus up to 3 following chars) if found.
+fn detect_unescaped_backslash(input: &[u8]) -> Option<String> {
+ // Pattern: a `"`, then `.+?` (at least one char, non-greedy), then a `\` that is NOT followed by
+ // a valid escape char (one of "bfnrt/\\u), then optionally up to 3 more chars (the `(...)?`).
+ // `.` does not match newline. We scan for the first `"`, then for the first qualifying backslash
+ // after at least one character.
+ let valid_escape = |b: u8| {
+ matches!(
+ b,
+ b'"' | b'b' | b'f' | b'n' | b'r' | b't' | b'/' | b'\\' | b'u'
+ )
+ };
+ let n = input.len();
+ // find first `"`
+ let mut q = 0;
+ while q < n {
+ if input[q] == b'"' && input[q] != b'\n' {
+ break;
+ }
+ q += 1;
+ }
+ if q >= n || input[q] != b'"' {
+ return None;
+ }
+ // need at least one char (.+?) after the quote, then the backslash.
+ // The `.+?` must match at least one non-newline char before the backslash.
+ let mut i = q + 1;
+ // ensure at least one char consumed by .+? : the backslash cannot be the char immediately
+ // following the quote? `.+?` is greedy-minimal but must consume >=1, and `.` excludes newline.
+ let mut consumed_one = false;
+ while i < n {
+ let c = input[i];
+ if c == b'\n' {
+ // `.` cannot cross a newline; PCRE `.+?` would stop — no match across lines here.
+ return None;
+ }
+ if c == b'\\' {
+ if consumed_one {
+ let next = input.get(i + 1).copied();
+ let bad = match next {
+ None => true, // `[^...]` requires a char; if none, no match
+ Some(b) => !valid_escape(b) && b != b'\n',
+ };
+ if next.is_some() && bad {
+ // capture group 1: the `\`, the disallowed char, then up to 3 more (...)?
+ let start = i;
+ let mut end = i + 2; // backslash + the [^...] char
+ // (...)? = exactly 3 chars if present
+ let mut extra = 0;
+ let mut k = end;
+ while extra < 3 && k < n && input[k] != b'\n' {
+ k += 1;
+ extra += 1;
+ }
+ if extra == 3 {
+ end = k;
+ }
+ return Some(String::from_utf8_lossy(&input[start..end]).into_owned());
+ }
+ }
+ }
+ consumed_one = true;
+ i += 1;
+ }
+ None
+}
- pub fn parse(&self, _json: &str, _flags: u32) -> anyhow::Result<PhpMixed> {
- todo!()
+/// ref: preg_match('{"(?:[^"]+|\\")*$}m', $fullUpcomingInput) — a `"` followed by string body that
+/// reaches a line end without a closing quote (multiline, with `m` so `$` is end-of-line).
+fn detect_unterminated_string(input: &[u8]) -> bool {
+ // Find a `"`; from there consume `[^"]+ | \"` repeatedly; succeed if we reach end-of-line/string
+ // without hitting an unescaped closing `"`.
+ let n = input.len();
+ let mut start = 0;
+ while start < n {
+ if input[start] == b'"' {
+ // try matching from this quote
+ let mut i = start + 1;
+ loop {
+ if i >= n {
+ return true; // reached end of string ($ at end)
+ }
+ let c = input[i];
+ if c == b'\n' {
+ return true; // $ matches before newline in multiline mode
+ }
+ if c == b'\\' && input.get(i + 1) == Some(&b'"') {
+ i += 2; // \" alternative
+ continue;
+ }
+ if c == b'"' {
+ break; // closing quote: this start fails, try next `"`
+ }
+ i += 1; // [^"]+
+ }
+ }
+ start += 1;
}
+ false
+}
- /// PHP: JsonParser::lint() — returns null on success, ParsingException on failure.
- pub fn lint(&mut self, _json: &str) -> Option<crate::seld::json_lint::ParsingException> {
- todo!()
+fn trim_bytes(input: &[u8]) -> Vec<u8> {
+ // PHP trim() default strips " \t\n\r\0\x0B"
+ let is_trim = |b: u8| matches!(b, b' ' | b'\t' | b'\n' | b'\r' | 0 | 0x0b);
+ let mut start = 0;
+ let mut end = input.len();
+ while start < end && is_trim(input[start]) {
+ start += 1;
+ }
+ while end > start && is_trim(input[end - 1]) {
+ end -= 1;
}
+ input[start..end].to_vec()
+}
+
+/// Builds the LALR parse `table`. Indexed by state id (0..=31).
+fn build_table() -> Vec<HashMap<i64, TableAction>> {
+ use TableAction::{Action, Goto};
+ let mut t: Vec<HashMap<i64, TableAction>> = (0..32).map(|_| HashMap::new()).collect();
+
+ let mut set = |state: usize, entries: &[(i64, TableAction)]| {
+ for (sym, act) in entries {
+ t[state].insert(*sym, *act);
+ }
+ };
+
+ set(
+ 0,
+ &[
+ (3, Goto(5)),
+ (4, Action(1, 12)),
+ (5, Goto(6)),
+ (6, Action(1, 13)),
+ (7, Goto(3)),
+ (8, Action(1, 9)),
+ (9, Goto(4)),
+ (10, Action(1, 10)),
+ (11, Action(1, 11)),
+ (12, Goto(1)),
+ (13, Goto(2)),
+ (15, Goto(7)),
+ (16, Goto(8)),
+ (17, Action(1, 14)),
+ (23, Action(1, 15)),
+ ],
+ );
+ set(1, &[(1, Action(3, 0))]);
+ set(2, &[(14, Action(1, 16))]);
+ set(
+ 3,
+ &[
+ (14, Action(2, 7)),
+ (18, Action(2, 7)),
+ (22, Action(2, 7)),
+ (24, Action(2, 7)),
+ ],
+ );
+ set(
+ 4,
+ &[
+ (14, Action(2, 8)),
+ (18, Action(2, 8)),
+ (22, Action(2, 8)),
+ (24, Action(2, 8)),
+ ],
+ );
+ set(
+ 5,
+ &[
+ (14, Action(2, 9)),
+ (18, Action(2, 9)),
+ (22, Action(2, 9)),
+ (24, Action(2, 9)),
+ ],
+ );
+ set(
+ 6,
+ &[
+ (14, Action(2, 10)),
+ (18, Action(2, 10)),
+ (22, Action(2, 10)),
+ (24, Action(2, 10)),
+ ],
+ );
+ set(
+ 7,
+ &[
+ (14, Action(2, 11)),
+ (18, Action(2, 11)),
+ (22, Action(2, 11)),
+ (24, Action(2, 11)),
+ ],
+ );
+ set(
+ 8,
+ &[
+ (14, Action(2, 12)),
+ (18, Action(2, 12)),
+ (22, Action(2, 12)),
+ (24, Action(2, 12)),
+ ],
+ );
+ set(
+ 9,
+ &[
+ (14, Action(2, 3)),
+ (18, Action(2, 3)),
+ (22, Action(2, 3)),
+ (24, Action(2, 3)),
+ ],
+ );
+ set(
+ 10,
+ &[
+ (14, Action(2, 4)),
+ (18, Action(2, 4)),
+ (22, Action(2, 4)),
+ (24, Action(2, 4)),
+ ],
+ );
+ set(
+ 11,
+ &[
+ (14, Action(2, 5)),
+ (18, Action(2, 5)),
+ (22, Action(2, 5)),
+ (24, Action(2, 5)),
+ ],
+ );
+ set(
+ 12,
+ &[
+ (14, Action(2, 1)),
+ (18, Action(2, 1)),
+ (21, Action(2, 1)),
+ (22, Action(2, 1)),
+ (24, Action(2, 1)),
+ ],
+ );
+ set(
+ 13,
+ &[
+ (14, Action(2, 2)),
+ (18, Action(2, 2)),
+ (22, Action(2, 2)),
+ (24, Action(2, 2)),
+ ],
+ );
+ set(
+ 14,
+ &[
+ (3, Goto(20)),
+ (4, Action(1, 12)),
+ (18, Action(1, 17)),
+ (19, Goto(18)),
+ (20, Goto(19)),
+ ],
+ );
+ set(
+ 15,
+ &[
+ (3, Goto(5)),
+ (4, Action(1, 12)),
+ (5, Goto(6)),
+ (6, Action(1, 13)),
+ (7, Goto(3)),
+ (8, Action(1, 9)),
+ (9, Goto(4)),
+ (10, Action(1, 10)),
+ (11, Action(1, 11)),
+ (13, Goto(23)),
+ (15, Goto(7)),
+ (16, Goto(8)),
+ (17, Action(1, 14)),
+ (23, Action(1, 15)),
+ (24, Action(1, 21)),
+ (25, Goto(22)),
+ ],
+ );
+ set(16, &[(1, Action(2, 6))]);
+ set(
+ 17,
+ &[
+ (14, Action(2, 13)),
+ (18, Action(2, 13)),
+ (22, Action(2, 13)),
+ (24, Action(2, 13)),
+ ],
+ );
+ set(18, &[(18, Action(1, 24)), (22, Action(1, 25))]);
+ set(19, &[(18, Action(2, 16)), (22, Action(2, 16))]);
+ set(20, &[(21, Action(1, 26))]);
+ set(
+ 21,
+ &[
+ (14, Action(2, 18)),
+ (18, Action(2, 18)),
+ (22, Action(2, 18)),
+ (24, Action(2, 18)),
+ ],
+ );
+ set(22, &[(22, Action(1, 28)), (24, Action(1, 27))]);
+ set(23, &[(22, Action(2, 20)), (24, Action(2, 20))]);
+ set(
+ 24,
+ &[
+ (14, Action(2, 14)),
+ (18, Action(2, 14)),
+ (22, Action(2, 14)),
+ (24, Action(2, 14)),
+ ],
+ );
+ set(25, &[(3, Goto(20)), (4, Action(1, 12)), (20, Goto(29))]);
+ set(
+ 26,
+ &[
+ (3, Goto(5)),
+ (4, Action(1, 12)),
+ (5, Goto(6)),
+ (6, Action(1, 13)),
+ (7, Goto(3)),
+ (8, Action(1, 9)),
+ (9, Goto(4)),
+ (10, Action(1, 10)),
+ (11, Action(1, 11)),
+ (13, Goto(30)),
+ (15, Goto(7)),
+ (16, Goto(8)),
+ (17, Action(1, 14)),
+ (23, Action(1, 15)),
+ ],
+ );
+ set(
+ 27,
+ &[
+ (14, Action(2, 19)),
+ (18, Action(2, 19)),
+ (22, Action(2, 19)),
+ (24, Action(2, 19)),
+ ],
+ );
+ set(
+ 28,
+ &[
+ (3, Goto(5)),
+ (4, Action(1, 12)),
+ (5, Goto(6)),
+ (6, Action(1, 13)),
+ (7, Goto(3)),
+ (8, Action(1, 9)),
+ (9, Goto(4)),
+ (10, Action(1, 10)),
+ (11, Action(1, 11)),
+ (13, Goto(31)),
+ (15, Goto(7)),
+ (16, Goto(8)),
+ (17, Action(1, 14)),
+ (23, Action(1, 15)),
+ ],
+ );
+ set(29, &[(18, Action(2, 17)), (22, Action(2, 17))]);
+ set(30, &[(18, Action(2, 15)), (22, Action(2, 15))]);
+ set(31, &[(22, Action(2, 21)), (24, Action(2, 21))]);
+
+ t
}
diff --git a/crates/shirabe-external-packages/src/seld/json_lint/lexer.rs b/crates/shirabe-external-packages/src/seld/json_lint/lexer.rs
new file mode 100644
index 0000000..96263ef
--- /dev/null
+++ b/crates/shirabe-external-packages/src/seld/json_lint/lexer.rs
@@ -0,0 +1,545 @@
+//! ref: composer/vendor/seld/jsonlint/src/Seld/JsonLint/Lexer.php
+
+use super::JsonParser;
+use super::ParsingException;
+
+/// ref: array{first_line, first_column, last_line, last_column} ($yylloc)
+#[derive(Debug, Clone, Default)]
+pub struct YylLoc {
+ pub first_line: i64,
+ pub first_column: i64,
+ pub last_line: i64,
+ pub last_column: i64,
+}
+
+#[derive(Debug)]
+pub struct Lexer {
+ rules: Vec<Rule>,
+ input: Vec<u8>,
+ more: bool,
+ done: bool,
+ offset: usize,
+ flags: u32,
+
+ pub match_: Vec<u8>,
+ pub yylineno: i64,
+ pub yyleng: i64,
+ pub yytext: Vec<u8>,
+ pub yylloc: YylLoc,
+}
+
+/// Each variant replicates the corresponding `\G`-anchored PHP rule. The PCRE rules are not portable
+/// to the `regex` crate (possessive quantifiers, `\G`, `\b`), so they are matched by hand.
+#[derive(Debug, Clone, Copy)]
+enum Rule {
+ BreakLine, // 0: /\G\s*\n\r?/
+ Whitespace, // 1: /\G\s+/
+ Number, // 2
+ Str, // 3
+ BraceOpen, // 4: {
+ BraceClose, // 5: }
+ BracketOpen, // 6: [
+ BracketClose, // 7: ]
+ Comma, // 8: ,
+ Colon, // 9: :
+ True, // 10
+ False, // 11
+ Null, // 12
+ End, // 13: /\G$/
+ LineComment, // 14: //
+ OpenComment, // 15: /*
+ CloseComment, // 16: */
+ AnyChar, // 17: /\G./
+}
+
+impl Lexer {
+ pub const EOF: i64 = 1;
+ pub const T_INVALID: i64 = -1;
+ pub const T_SKIP_WHITESPACE: i64 = 0;
+ pub const T_ERROR: i64 = 2;
+ pub const T_BREAK_LINE: i64 = 3;
+ pub const T_COMMENT: i64 = 30;
+ pub const T_OPEN_COMMENT: i64 = 31;
+ pub const T_CLOSE_COMMENT: i64 = 32;
+
+ pub fn new(flags: u32) -> Self {
+ let rules = vec![
+ Rule::BreakLine,
+ Rule::Whitespace,
+ Rule::Number,
+ Rule::Str,
+ Rule::BraceOpen,
+ Rule::BraceClose,
+ Rule::BracketOpen,
+ Rule::BracketClose,
+ Rule::Comma,
+ Rule::Colon,
+ Rule::True,
+ Rule::False,
+ Rule::Null,
+ Rule::End,
+ Rule::LineComment,
+ Rule::OpenComment,
+ Rule::CloseComment,
+ Rule::AnyChar,
+ ];
+ Self {
+ rules,
+ input: Vec::new(),
+ more: false,
+ done: false,
+ offset: 0,
+ flags,
+ match_: Vec::new(),
+ yylineno: 0,
+ yyleng: 0,
+ yytext: Vec::new(),
+ yylloc: YylLoc::default(),
+ }
+ }
+
+ pub fn lex(&mut self) -> Result<i64, ParsingException> {
+ loop {
+ let symbol = self.next()?;
+ match symbol {
+ Self::T_SKIP_WHITESPACE | Self::T_BREAK_LINE => {}
+ Self::T_COMMENT | Self::T_OPEN_COMMENT => {
+ if self.flags & JsonParser::ALLOW_COMMENTS == 0 {
+ return Err(self.parse_error(format!(
+ "Lexical error on line {}. Comments are not allowed.\n{}",
+ self.yylineno + 1,
+ self.show_position()
+ )));
+ }
+ let until = if symbol == Self::T_COMMENT {
+ Self::T_BREAK_LINE
+ } else {
+ Self::T_CLOSE_COMMENT
+ };
+ self.skip_until(until)?;
+ if self.done {
+ // last symbol '/\G$/' before EOF
+ return Ok(14);
+ }
+ }
+ Self::T_CLOSE_COMMENT => {
+ return Err(self.parse_error(format!(
+ "Lexical error on line {}. Unexpected token.\n{}",
+ self.yylineno + 1,
+ self.show_position()
+ )));
+ }
+ _ => return Ok(symbol),
+ }
+ }
+ }
+
+ pub fn set_input(&mut self, input: &str) {
+ self.input = input.as_bytes().to_vec();
+ self.more = false;
+ self.done = false;
+ self.offset = 0;
+ self.yylineno = 0;
+ self.yyleng = 0;
+ self.yytext = Vec::new();
+ self.match_ = Vec::new();
+ self.yylloc = YylLoc {
+ first_line: 1,
+ first_column: 0,
+ last_line: 1,
+ last_column: 0,
+ };
+ }
+
+ pub fn show_position(&self) -> String {
+ if self.yylineno == 0 && self.offset == 1 && self.match_ != b"{" {
+ let mut s = String::from_utf8_lossy(&self.match_).into_owned();
+ s.push_str("...\n^");
+ return s;
+ }
+
+ let pre = str_replace_nl(&self.get_past_input());
+ let dash_count = (pre.len() as i64 - 1).max(0) as usize;
+ let c = "-".repeat(dash_count);
+
+ let upcoming = str_replace_nl(&self.get_upcoming_input());
+ format!("{pre}{upcoming}\n{c}^")
+ }
+
+ pub fn get_past_input(&self) -> Vec<u8> {
+ let past_length = self.offset as i64 - self.match_.len() as i64;
+ let prefix = if past_length > 20 { "..." } else { "" };
+ let start = (past_length - 20).max(0) as usize;
+ let len = past_length.min(20).max(0) as usize;
+ let slice = substr_bytes(&self.input, start, len);
+ let mut out = prefix.as_bytes().to_vec();
+ out.extend_from_slice(&slice);
+ out
+ }
+
+ pub fn get_upcoming_input(&self) -> Vec<u8> {
+ let mut next = self.match_.clone();
+ if next.len() < 20 {
+ let want = 20 - next.len();
+ next.extend_from_slice(&substr_bytes(&self.input, self.offset, want));
+ }
+ let too_long = next.len() > 20;
+ let mut out = substr_bytes(&next, 0, 20);
+ if too_long {
+ out.extend_from_slice(b"...");
+ }
+ out
+ }
+
+ pub fn get_full_upcoming_input(&self) -> Vec<u8> {
+ let mut next = self.match_.clone();
+ if next.first() == Some(&b'"') && byte_count(&next, b'"') == 1 {
+ let len = self.input.len();
+ let str_end = if len == self.offset {
+ len
+ } else {
+ let q = find_byte_from(&self.input, b'"', self.offset + 1).unwrap_or(len);
+ let q = if q == 0 { len } else { q };
+ let n = find_byte_from(&self.input, b'\n', self.offset + 1).unwrap_or(len);
+ let n = if n == 0 { len } else { n };
+ q.min(n)
+ };
+ next.extend_from_slice(&self.input[self.offset..str_end]);
+ } else if next.len() < 20 {
+ let want = 20 - next.len();
+ next.extend_from_slice(&substr_bytes(&self.input, self.offset, want));
+ }
+ next
+ }
+
+ fn parse_error(&self, str: String) -> ParsingException {
+ ParsingException::new(str, Default::default())
+ }
+
+ fn skip_until(&mut self, token: i64) -> Result<(), ParsingException> {
+ let mut symbol = self.next()?;
+ while symbol != token && !self.done {
+ symbol = self.next()?;
+ }
+ Ok(())
+ }
+
+ fn next(&mut self) -> Result<i64, ParsingException> {
+ if self.done {
+ return Ok(Self::EOF);
+ }
+ if self.offset == self.input.len() {
+ self.done = true;
+ }
+
+ if !self.more {
+ self.yytext = Vec::new();
+ self.match_ = Vec::new();
+ }
+
+ for i in 0..self.rules.len() {
+ if let Some(matched) = self.match_rule(self.rules[i]) {
+ let lines: Vec<&[u8]> = split_bytes(&matched, b'\n');
+ // array_shift: drop first element, count remaining
+ let line_count = lines.len().saturating_sub(1);
+ self.yylineno += line_count as i64;
+ self.yylloc = YylLoc {
+ first_line: self.yylloc.last_line,
+ last_line: self.yylineno + 1,
+ first_column: self.yylloc.last_column,
+ last_column: if line_count > 0 {
+ lines[lines.len() - 1].len() as i64
+ } else {
+ self.yylloc.last_column + matched.len() as i64
+ },
+ };
+ self.yytext.extend_from_slice(&matched);
+ self.match_.extend_from_slice(&matched);
+ self.yyleng = self.yytext.len() as i64;
+ self.more = false;
+ self.offset += matched.len();
+ return Ok(self.perform_action(i, &matched));
+ }
+ }
+
+ if self.offset == self.input.len() {
+ return Ok(Self::EOF);
+ }
+
+ Err(self.parse_error(format!(
+ "Lexical error on line {}. Unrecognized text.\n{}",
+ self.yylineno + 1,
+ self.show_position()
+ )))
+ }
+
+ /// Returns the matched bytes (anchored at `self.offset`) if the rule applies, mirroring
+ /// `preg_match($rule, $input, $m, 0, $offset)` for the corresponding `\G`-anchored PHP pattern.
+ fn match_rule(&self, rule: Rule) -> Option<Vec<u8>> {
+ let input = &self.input;
+ let off = self.offset;
+ match rule {
+ Rule::BreakLine => match_break_line(input, off),
+ Rule::Whitespace => match_whitespace(input, off),
+ Rule::Number => match_number(input, off),
+ Rule::Str => match_string(input, off),
+ Rule::BraceOpen => match_char(input, off, b'{'),
+ Rule::BraceClose => match_char(input, off, b'}'),
+ Rule::BracketOpen => match_char(input, off, b'['),
+ Rule::BracketClose => match_char(input, off, b']'),
+ Rule::Comma => match_char(input, off, b','),
+ Rule::Colon => match_char(input, off, b':'),
+ Rule::True => match_keyword(input, off, b"true"),
+ Rule::False => match_keyword(input, off, b"false"),
+ Rule::Null => match_keyword(input, off, b"null"),
+ Rule::End => match_end(input, off),
+ Rule::LineComment => match_literal(input, off, b"//"),
+ Rule::OpenComment => match_literal(input, off, b"/*"),
+ Rule::CloseComment => match_literal(input, off, b"*/"),
+ Rule::AnyChar => match_any_char(input, off),
+ }
+ }
+
+ fn perform_action(&mut self, rule: usize, _matched: &[u8]) -> i64 {
+ match rule {
+ 0 => Self::T_BREAK_LINE,
+ 1 => Self::T_SKIP_WHITESPACE,
+ 2 => 6,
+ 3 => {
+ // strip surrounding quotes: substr($yytext, 1, $yyleng-2)
+ let len = self.yyleng;
+ self.yytext = substr_bytes(&self.yytext, 1, (len - 2).max(0) as usize);
+ 4
+ }
+ 4 => 17,
+ 5 => 18,
+ 6 => 23,
+ 7 => 24,
+ 8 => 22,
+ 9 => 21,
+ 10 => 10,
+ 11 => 11,
+ 12 => 8,
+ 13 => 14,
+ 14 => Self::T_COMMENT,
+ 15 => Self::T_OPEN_COMMENT,
+ 16 => Self::T_CLOSE_COMMENT,
+ 17 => Self::T_INVALID,
+ _ => panic!("Unsupported rule {rule}"),
+ }
+ }
+}
+
+/// PHP `\s` (matches space, \t, \n, \r, \f, \v).
+fn is_php_space(b: u8) -> bool {
+ matches!(b, b' ' | b'\t' | b'\n' | b'\r' | 0x0c | 0x0b)
+}
+
+/// Rule 0: /\G\s*\n\r?/ — greedy whitespace ending at a newline, optionally followed by `\r`.
+fn match_break_line(input: &[u8], off: usize) -> Option<Vec<u8>> {
+ // Find the run of whitespace that ends with `\n` (followed by optional `\r`). PCRE backtracks
+ // so the `\s*` consumes up to and including the last reachable `\n` within the leading run.
+ let mut i = off;
+ let mut last_nl: Option<usize> = None;
+ while i < input.len() && is_php_space(input[i]) {
+ if input[i] == b'\n' {
+ last_nl = Some(i);
+ }
+ i += 1;
+ }
+ let nl = last_nl?;
+ let mut end = nl + 1;
+ if end < input.len() && input[end] == b'\r' {
+ end += 1;
+ }
+ Some(input[off..end].to_vec())
+}
+
+/// Rule 1: /\G\s+/
+fn match_whitespace(input: &[u8], off: usize) -> Option<Vec<u8>> {
+ let mut i = off;
+ while i < input.len() && is_php_space(input[i]) {
+ i += 1;
+ }
+ if i == off {
+ None
+ } else {
+ Some(input[off..i].to_vec())
+ }
+}
+
+/// Rule 2: /\G-?([0-9]|[1-9][0-9]+)(\.[0-9]+)?([eE][+-]?[0-9]+)?\b/
+fn match_number(input: &[u8], off: usize) -> Option<Vec<u8>> {
+ let mut i = off;
+ if i < input.len() && input[i] == b'-' {
+ i += 1;
+ }
+ // integer part: a single digit OR [1-9][0-9]+
+ let int_start = i;
+ if i >= input.len() || !input[i].is_ascii_digit() {
+ return None;
+ }
+ if input[i] == b'0' {
+ // single 0 only (the [1-9][0-9]+ alternative cannot start with 0)
+ i += 1;
+ } else {
+ // [1-9] then [0-9]* ; but grammar is `[0-9]` (single) | `[1-9][0-9]+` (2+ digits).
+ // Both collapse to: one or more digits starting with 1-9.
+ i += 1;
+ while i < input.len() && input[i].is_ascii_digit() {
+ i += 1;
+ }
+ }
+ let _ = int_start;
+ // fraction
+ if i < input.len() && input[i] == b'.' {
+ let mut j = i + 1;
+ let frac_start = j;
+ while j < input.len() && input[j].is_ascii_digit() {
+ j += 1;
+ }
+ if j > frac_start {
+ i = j;
+ }
+ }
+ // exponent
+ if i < input.len() && (input[i] == b'e' || input[i] == b'E') {
+ let mut j = i + 1;
+ if j < input.len() && (input[j] == b'+' || input[j] == b'-') {
+ j += 1;
+ }
+ let exp_start = j;
+ while j < input.len() && input[j].is_ascii_digit() {
+ j += 1;
+ }
+ if j > exp_start {
+ i = j;
+ }
+ }
+ // \b word boundary: previous char (input[i-1]) is a word char (digit), so the next char must be
+ // a non-word char (or end).
+ if i < input.len() && is_word_byte(input[i]) {
+ return None;
+ }
+ Some(input[off..i].to_vec())
+}
+
+fn is_word_byte(b: u8) -> bool {
+ b.is_ascii_alphanumeric() || b == b'_'
+}
+
+/// Rule 3: {\G"(?>\\["bfnrt/\\]|\\u[a-fA-F0-9]{4}|[^\0-\x1f\\"]++)*+"}
+fn match_string(input: &[u8], off: usize) -> Option<Vec<u8>> {
+ if input.get(off) != Some(&b'"') {
+ return None;
+ }
+ let mut i = off + 1;
+ loop {
+ let c = *input.get(i)?;
+ if c == b'"' {
+ return Some(input[off..=i].to_vec());
+ }
+ if c == b'\\' {
+ let n = *input.get(i + 1)?;
+ match n {
+ b'"' | b'b' | b'f' | b'n' | b'r' | b't' | b'/' | b'\\' => {
+ i += 2;
+ }
+ b'u' => {
+ // require 4 hex digits
+ for k in 0..4 {
+ let h = *input.get(i + 2 + k)?;
+ if !h.is_ascii_hexdigit() {
+ return None;
+ }
+ }
+ i += 6;
+ }
+ _ => return None,
+ }
+ } else if c <= 0x1f {
+ // [^\0-\x1f\\"] excludes control chars
+ return None;
+ } else {
+ i += 1;
+ }
+ }
+}
+
+/// Rules 4-9: single literal char.
+fn match_char(input: &[u8], off: usize, ch: u8) -> Option<Vec<u8>> {
+ if input.get(off) == Some(&ch) {
+ Some(vec![ch])
+ } else {
+ None
+ }
+}
+
+/// Rules 10-12: keyword followed by a `\b` boundary.
+fn match_keyword(input: &[u8], off: usize, kw: &[u8]) -> Option<Vec<u8>> {
+ if input.len() < off + kw.len() || &input[off..off + kw.len()] != kw {
+ return None;
+ }
+ let after = off + kw.len();
+ if after < input.len() && is_word_byte(input[after]) {
+ return None;
+ }
+ Some(kw.to_vec())
+}
+
+/// Rule 13: /\G$/ — matches the empty string at end of input (or before a trailing final newline,
+/// per PCRE `$` without the `m` modifier).
+fn match_end(input: &[u8], off: usize) -> Option<Vec<u8>> {
+ if off == input.len() {
+ return Some(Vec::new());
+ }
+ if off == input.len() - 1 && input[off] == b'\n' {
+ return Some(Vec::new());
+ }
+ None
+}
+
+/// Rules 14-16: multi-char literal.
+fn match_literal(input: &[u8], off: usize, lit: &[u8]) -> Option<Vec<u8>> {
+ if input.len() >= off + lit.len() && &input[off..off + lit.len()] == lit {
+ Some(lit.to_vec())
+ } else {
+ None
+ }
+}
+
+/// Rule 17: /\G./ — any single character except newline.
+fn match_any_char(input: &[u8], off: usize) -> Option<Vec<u8>> {
+ match input.get(off) {
+ Some(&b'\n') | None => None,
+ Some(&c) => Some(vec![c]),
+ }
+}
+
+fn substr_bytes(input: &[u8], start: usize, len: usize) -> Vec<u8> {
+ if start >= input.len() {
+ return Vec::new();
+ }
+ let end = (start + len).min(input.len());
+ input[start..end].to_vec()
+}
+
+fn str_replace_nl(input: &[u8]) -> String {
+ let filtered: Vec<u8> = input.iter().copied().filter(|&b| b != b'\n').collect();
+ String::from_utf8_lossy(&filtered).into_owned()
+}
+
+fn split_bytes(input: &[u8], sep: u8) -> Vec<&[u8]> {
+ input.split(|&b| b == sep).collect()
+}
+
+fn byte_count(input: &[u8], b: u8) -> usize {
+ input.iter().filter(|&&x| x == b).count()
+}
+
+fn find_byte_from(input: &[u8], b: u8, from: usize) -> Option<usize> {
+ if from > input.len() {
+ return None;
+ }
+ input[from..].iter().position(|&x| x == b).map(|p| p + from)
+}
diff --git a/crates/shirabe-external-packages/src/seld/json_lint/mod.rs b/crates/shirabe-external-packages/src/seld/json_lint/mod.rs
index 1058268..8027cb2 100644
--- a/crates/shirabe-external-packages/src/seld/json_lint/mod.rs
+++ b/crates/shirabe-external-packages/src/seld/json_lint/mod.rs
@@ -1,7 +1,9 @@
pub mod duplicate_key_exception;
pub mod json_parser;
+pub mod lexer;
pub mod parsing_exception;
pub use duplicate_key_exception::*;
pub use json_parser::*;
+pub use lexer::*;
pub use parsing_exception::*;
diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/table.rs b/crates/shirabe-external-packages/src/symfony/console/helper/table.rs
index 1167d63..bc426f6 100644
--- a/crates/shirabe-external-packages/src/symfony/console/helper/table.rs
+++ b/crates/shirabe-external-packages/src/symfony/console/helper/table.rs
@@ -14,6 +14,7 @@ use crate::symfony::console::helper::table_style::TableStyle;
use crate::symfony::console::output::console_section_output::ConsoleSectionOutput;
use crate::symfony::console::output::output_interface::OutputInterface;
use indexmap::IndexMap;
+use shirabe_php_shim::AsAny;
use shirabe_php_shim::PhpMixed;
use std::cell::RefCell;
use std::rc::Rc;
@@ -1248,7 +1249,9 @@ impl Table {
fn formatter_is_wrappable(_output: &Rc<RefCell<dyn OutputInterface>>) -> bool {
// PHP: $this->output->getFormatter() instanceof WrappableOutputFormatterInterface
- // TODO(phase-b): trait-to-trait instanceof check requires concrete formatter knowledge.
+ // TODO(phase-c/d): instanceof on `dyn OutputFormatterInterface` needs an AsAny supertrait
+ // on OutputFormatterInterface to downcast to the concrete wrappable formatter; adding it
+ // would touch output_formatter_interface.rs, which is out of scope for this file.
let _ = std::any::type_name::<dyn WrappableOutputFormatterInterface>();
todo!()
}
@@ -1260,9 +1263,13 @@ impl Table {
Helper::remove_decoration(&mut *formatter, string)
}
- fn output_is_console_section(_output: &Rc<RefCell<dyn OutputInterface>>) -> bool {
+ fn output_is_console_section(output: &Rc<RefCell<dyn OutputInterface>>) -> bool {
// PHP: $this->output instanceof ConsoleSectionOutput
- todo!()
+ let borrowed = output.borrow();
+ (*borrowed)
+ .as_any()
+ .downcast_ref::<ConsoleSectionOutput>()
+ .is_some()
}
fn is_divider(_row: &PhpMixed, _divider: &TableSeparator) -> bool {
diff --git a/crates/shirabe-external-packages/src/symfony/process/executable_finder.rs b/crates/shirabe-external-packages/src/symfony/process/executable_finder.rs
index cfd1bef..a151283 100644
--- a/crates/shirabe-external-packages/src/symfony/process/executable_finder.rs
+++ b/crates/shirabe-external-packages/src/symfony/process/executable_finder.rs
@@ -1,7 +1,18 @@
//! ref: composer/vendor/symfony/process/ExecutableFinder.php
+use shirabe_php_shim::{self as php, PhpMixed};
+
+const CMD_BUILTINS: &[&str] = &[
+ "assoc", "break", "call", "cd", "chdir", "cls", "color", "copy", "date", "del", "dir", "echo",
+ "endlocal", "erase", "exit", "for", "ftype", "goto", "help", "if", "label", "md", "mkdir",
+ "mklink", "move", "path", "pause", "popd", "prompt", "pushd", "rd", "rem", "ren", "rename",
+ "rmdir", "set", "setlocal", "shift", "start", "time", "title", "type", "ver", "vol",
+];
+
#[derive(Debug)]
-pub struct ExecutableFinder;
+pub struct ExecutableFinder {
+ suffixes: Vec<String>,
+}
impl Default for ExecutableFinder {
fn default() -> Self {
@@ -11,14 +22,102 @@ impl Default for ExecutableFinder {
impl ExecutableFinder {
pub fn new() -> Self {
- todo!()
+ Self { suffixes: vec![] }
}
- pub fn add_suffix(&mut self, _suffix: &str) {
- todo!()
+ /// Replaces default suffixes of executable.
+ pub fn set_suffixes(&mut self, suffixes: Vec<String>) {
+ self.suffixes = suffixes;
}
- pub fn find(&self, _name: &str, _default: Option<&str>, _dirs: &[String]) -> Option<String> {
- todo!()
+ /// Adds new possible suffix to check for executable.
+ pub fn add_suffix(&mut self, suffix: &str) {
+ self.suffixes.push(suffix.to_string());
+ }
+
+ pub fn find(&self, name: &str, default: Option<&str>, extra_dirs: &[String]) -> Option<String> {
+ // windows built-in commands that are present in cmd.exe should not be resolved using PATH as they do not exist as exes
+ if php::DIRECTORY_SEPARATOR == "\\"
+ && CMD_BUILTINS.contains(&php::strtolower(name).as_str())
+ {
+ return Some(name.to_string());
+ }
+
+ let path = php::getenv("PATH")
+ .or_else(|| php::getenv("Path"))
+ .map(|v| v.to_string_lossy().into_owned())
+ .unwrap_or_default();
+ let mut dirs = php::explode(php::PATH_SEPARATOR, &path);
+ dirs.extend_from_slice(extra_dirs);
+
+ let mut suffixes: Vec<String> = vec![];
+ if php::DIRECTORY_SEPARATOR == "\\" {
+ let path_ext = php::getenv("PATHEXT").map(|v| v.to_string_lossy().into_owned());
+ suffixes = self.suffixes.clone();
+ let exts = match path_ext {
+ Some(ref ext) if !ext.is_empty() => php::explode(php::PATH_SEPARATOR, ext),
+ _ => vec![
+ ".exe".to_string(),
+ ".bat".to_string(),
+ ".cmd".to_string(),
+ ".com".to_string(),
+ ],
+ };
+ suffixes.extend(exts);
+ }
+ suffixes = if !php::pathinfo(PhpMixed::String(name.to_string()), php::PATHINFO_EXTENSION)
+ .as_string()
+ .unwrap_or("")
+ .is_empty()
+ {
+ let mut s = vec![String::new()];
+ s.extend(suffixes);
+ s
+ } else {
+ suffixes.push(String::new());
+ suffixes
+ };
+ for suffix in &suffixes {
+ for dir in &dirs {
+ let dir = if dir.is_empty() { "." } else { dir.as_str() };
+ let file = format!("{dir}{}{name}{suffix}", php::DIRECTORY_SEPARATOR);
+ if php::is_file(&file)
+ && (php::DIRECTORY_SEPARATOR == "\\" || php::is_executable(&file))
+ {
+ return Some(file);
+ }
+
+ if !php::is_dir(dir)
+ && php::basename(dir) == format!("{name}{suffix}")
+ && php::is_executable(dir)
+ {
+ return Some(dir.to_string());
+ }
+ }
+ }
+
+ if php::DIRECTORY_SEPARATOR == "\\"
+ || name.len() != php::strcspn(name, &format!("/{}", php::DIRECTORY_SEPARATOR))
+ {
+ return default.map(ToString::to_string);
+ }
+
+ let exec_result = php::exec(
+ &format!("command -v -- {}", php::escapeshellarg(name)),
+ None,
+ None,
+ )
+ .unwrap_or_default();
+
+ let executable_path = php::substr(
+ &exec_result,
+ 0,
+ php::strpos(&exec_result, php::PHP_EOL).map(|i| i as i64),
+ );
+ if !executable_path.is_empty() && php::is_executable(&executable_path) {
+ return Some(executable_path);
+ }
+
+ default.map(ToString::to_string)
}
}
diff --git a/crates/shirabe-external-packages/src/symfony/process/php_executable_finder.rs b/crates/shirabe-external-packages/src/symfony/process/php_executable_finder.rs
index 65eae30..9d04c5e 100644
--- a/crates/shirabe-external-packages/src/symfony/process/php_executable_finder.rs
+++ b/crates/shirabe-external-packages/src/symfony/process/php_executable_finder.rs
@@ -1,7 +1,12 @@
//! ref: composer/vendor/symfony/process/PhpExecutableFinder.php
+use super::executable_finder::ExecutableFinder;
+use shirabe_php_shim::{self as php};
+
#[derive(Debug)]
-pub struct PhpExecutableFinder;
+pub struct PhpExecutableFinder {
+ executable_finder: ExecutableFinder,
+}
impl Default for PhpExecutableFinder {
fn default() -> Self {
@@ -11,14 +16,52 @@ impl Default for PhpExecutableFinder {
impl PhpExecutableFinder {
pub fn new() -> Self {
- todo!()
+ Self {
+ executable_finder: ExecutableFinder::new(),
+ }
}
- pub fn find(&self, _include_args: bool) -> Option<String> {
+ /// Finds The PHP executable.
+ pub fn find(&self, include_args: bool) -> Option<String> {
+ if let Some(php) = php::getenv("PHP_BINARY").filter(|v| !v.is_empty()) {
+ let mut php = php.to_string_lossy().into_owned();
+ if !php::is_executable(&php) {
+ match self.executable_finder.find(&php, None, &[]) {
+ Some(found) => php = found,
+ None => return None,
+ }
+ }
+
+ if php::is_dir(&php) {
+ return None;
+ }
+
+ return Some(php);
+ }
+
+ let args = self.find_arguments();
+ let _args = if include_args && !args.is_empty() {
+ format!(" {}", args.join(" "))
+ } else {
+ String::new()
+ };
+
+ // PHP_BINARY return the current sapi executable
+ //
+ // Everything from here on depends on runtime constants describing the *running* PHP
+ // interpreter (\PHP_BINARY truthiness, \PHP_SAPI, \PHP_BINDIR). The shim does not model a
+ // current PHP runtime, so the remaining fallbacks (the \PHP_SAPI sapi check, \PHP_PATH,
+ // \PHP_PEAR_PHP_BIN, \PHP_BINDIR probing and the final php lookup seeded with \PHP_BINDIR)
+ // cannot be ported faithfully here.
+ // TODO(php-runtime): port once the shim exposes \PHP_SAPI and \PHP_BINDIR.
todo!()
}
+ /// Finds the PHP executable arguments.
pub fn find_arguments(&self) -> Vec<String> {
+ let _arguments: Vec<String> = vec![];
+ // TODO(php-runtime): \PHP_SAPI is the SAPI name of the running PHP interpreter; the shim
+ // does not model a current PHP runtime, so the 'phpdbg' check cannot be ported faithfully.
todo!()
}
}
diff --git a/crates/shirabe-external-packages/src/symfony/string/byte_string.rs b/crates/shirabe-external-packages/src/symfony/string/byte_string.rs
index ab17c1e..cd8eae8 100644
--- a/crates/shirabe-external-packages/src/symfony/string/byte_string.rs
+++ b/crates/shirabe-external-packages/src/symfony/string/byte_string.rs
@@ -8,11 +8,25 @@ pub struct ByteString {
}
impl ByteString {
- pub fn new(_string: &str) -> Self {
- todo!()
+ pub fn new(string: &str) -> Self {
+ Self {
+ string: string.to_string(),
+ }
}
- pub fn to_code_point_string(&self, _encoding: &str) -> CodePointString {
+ pub fn to_code_point_string(&self, from_encoding: &str) -> CodePointString {
+ // The source `string` is always valid UTF-8 (Rust `String`/`&str` guarantee). PHP takes the
+ // early-return branch whenever `preg_match('//u', ...)` holds for a UTF-8/null encoding, so
+ // the result mirrors the input bytes verbatim. The `mb_detect_encoding`/`iconv` conversion
+ // path only applies to genuinely non-UTF-8 byte strings, which cannot occur here.
+ if matches!(from_encoding, "" | "utf8" | "utf-8" | "UTF8" | "UTF-8") {
+ return CodePointString {
+ string: self.string.clone(),
+ };
+ }
+
+ // TODO(phase-d): non-UTF-8 source encodings would require mb_convert_encoding/iconv-style
+ // decoding, which is unreachable for the UTF-8-only inputs Shirabe currently produces.
todo!()
}
}
diff --git a/crates/shirabe-external-packages/src/symfony/string/code_point_string.rs b/crates/shirabe-external-packages/src/symfony/string/code_point_string.rs
index b76f157..ce67428 100644
--- a/crates/shirabe-external-packages/src/symfony/string/code_point_string.rs
+++ b/crates/shirabe-external-packages/src/symfony/string/code_point_string.rs
@@ -6,11 +6,166 @@ pub struct CodePointString {
}
impl CodePointString {
- pub fn wordwrap(&self, _width: i64, _break: &str, _cut: bool) -> Self {
- todo!()
+ /// Port of `AbstractString::wordwrap()`, specialised to the non-`ignoreCase` code-point case.
+ pub fn wordwrap(&self, width: i64, r#break: &str, cut: bool) -> Self {
+ // `split($break)` with no flags reduces to `explode($break, $string)` here, then `chunk()`
+ // yields one entry per code point. `ignoreCase` is always false for freshly built instances.
+ let lines: Vec<&str> = if !r#break.is_empty() {
+ self.string.split(r#break).collect()
+ } else {
+ vec![&self.string]
+ };
+
+ let mut chars: Vec<String> = Vec::new();
+ let mut mask = String::new();
+
+ if lines.len() == 1 && lines[0].is_empty() {
+ return Self {
+ string: String::new(),
+ };
+ }
+
+ for (i, line) in lines.iter().enumerate() {
+ if i != 0 {
+ chars.push(r#break.to_string());
+ mask.push('#');
+ }
+
+ for ch in line.chars() {
+ let s = ch.to_string();
+ mask.push(if s == " " { ' ' } else { '?' });
+ chars.push(s);
+ }
+ }
+
+ let mut string = String::new();
+ let mut j: usize = 0;
+ // PHP seeds both `$b` and `$i` at -1; mirror with signed indices.
+ let mut i: i64 = -1;
+ let mask = php_wordwrap(&mask, width, "#", cut);
+ let mask_bytes = mask.as_bytes();
+
+ let mut b: i64 = -1;
+ loop {
+ // strpos($mask, '#', $b + 1)
+ let from = (b + 1) as usize;
+ let Some(rel) = mask_bytes[from..].iter().position(|&c| c == b'#') else {
+ break;
+ };
+ b = (from + rel) as i64;
+
+ i += 1;
+ while i < b {
+ string.push_str(&chars[j]);
+ j += 1;
+ i += 1;
+ }
+
+ if chars[j] == r#break || chars[j] == " " {
+ j += 1;
+ }
+
+ string.push_str(r#break);
+ }
+
+ for c in &chars[j..] {
+ string.push_str(c);
+ }
+
+ Self { string }
}
- pub fn to_byte_string(&self, _encoding: &str) -> String {
+ pub fn to_byte_string(&self, to_encoding: &str) -> String {
+ // The source is always valid UTF-8, so PHP's `toByteString` returns the string verbatim
+ // whenever the target is null/UTF-8 (the only encodings reached here). The
+ // mb_convert_encoding/iconv path applies only to non-UTF-8 targets, which do not occur.
+ if matches!(to_encoding, "" | "utf8" | "utf-8" | "UTF8" | "UTF-8") {
+ return self.string.clone();
+ }
+
+ // TODO(phase-d): converting to a non-UTF-8 target encoding needs mb_convert_encoding/iconv,
+ // unreachable for Shirabe's UTF-8-only output.
todo!()
}
}
+
+/// Port of PHP's built-in `wordwrap()` (`PHP_FUNCTION(wordwrap)` in ext/standard/string.c).
+/// Byte-based, matching PHP's single-byte/multi-byte break and cut handling.
+fn php_wordwrap(text: &str, linelength: i64, breakchar: &str, docut: bool) -> String {
+ let text = text.as_bytes();
+ let breakchar = breakchar.as_bytes();
+ let textlen = text.len() as i64;
+ let breaklen = breakchar.len() as i64;
+
+ if textlen == 0 {
+ return String::new();
+ }
+
+ let mut laststart: i64 = 0;
+ let mut lastspace: i64 = 0;
+
+ // Special case for a single-character break that needs no extra storage.
+ if breaklen == 1 && !docut {
+ let mut out = text.to_vec();
+ let mut current = 0i64;
+ while current < textlen {
+ let c = out[current as usize];
+ if c == breakchar[0] {
+ laststart = current + 1;
+ lastspace = current + 1;
+ } else if c == b' ' {
+ if current - laststart >= linelength {
+ out[current as usize] = breakchar[0];
+ laststart = current + 1;
+ }
+ lastspace = current;
+ } else if current - laststart >= linelength && laststart != lastspace {
+ out[lastspace as usize] = breakchar[0];
+ laststart = lastspace + 1;
+ }
+ current += 1;
+ }
+ return String::from_utf8_lossy(&out).into_owned();
+ }
+
+ // Multiple character line break or forced cut.
+ let mut out: Vec<u8> = Vec::new();
+ let mut current = 0i64;
+ while current < textlen {
+ // When we hit an existing break, copy to the new buffer and fix up laststart/lastspace.
+ if text[current as usize] == breakchar[0]
+ && current + breaklen < textlen
+ && &text[current as usize..(current + breaklen) as usize] == breakchar
+ {
+ out.extend_from_slice(&text[laststart as usize..(current + breaklen) as usize]);
+ current += breaklen - 1;
+ laststart = current + 1;
+ lastspace = current + 1;
+ } else if text[current as usize] == b' ' {
+ if current - laststart >= linelength {
+ out.extend_from_slice(&text[laststart as usize..current as usize]);
+ out.extend_from_slice(breakchar);
+ laststart = current + 1;
+ }
+ lastspace = current;
+ } else if current - laststart >= linelength && docut && laststart >= lastspace {
+ out.extend_from_slice(&text[laststart as usize..current as usize]);
+ out.extend_from_slice(breakchar);
+ laststart = current;
+ lastspace = current;
+ } else if current - laststart >= linelength && laststart < lastspace {
+ out.extend_from_slice(&text[laststart as usize..lastspace as usize]);
+ out.extend_from_slice(breakchar);
+ laststart = lastspace + 1;
+ lastspace = lastspace + 1;
+ }
+ current += 1;
+ }
+
+ // Copy over any stragglers.
+ if laststart != current {
+ out.extend_from_slice(&text[laststart as usize..current as usize]);
+ }
+
+ String::from_utf8_lossy(&out).into_owned()
+}
diff --git a/crates/shirabe-external-packages/src/symfony/string/mod.rs b/crates/shirabe-external-packages/src/symfony/string/mod.rs
index 411a3f3..139d726 100644
--- a/crates/shirabe-external-packages/src/symfony/string/mod.rs
+++ b/crates/shirabe-external-packages/src/symfony/string/mod.rs
@@ -6,11 +6,12 @@ pub use byte_string::*;
pub use code_point_string::*;
pub use unicode_string::*;
-/// Mirror of Symfony's `u()` / `b()` helper functions.
-pub fn b(_string: &str) -> ByteString {
- todo!()
+/// Mirror of Symfony's `b()` helper function.
+pub fn b(string: &str) -> ByteString {
+ ByteString::new(string)
}
-pub fn s(_string: &str) -> UnicodeString {
- todo!()
+/// Mirror of Symfony's `u()` helper function.
+pub fn s(string: &str) -> UnicodeString {
+ UnicodeString::new(string)
}
diff --git a/crates/shirabe-external-packages/src/symfony/string/unicode_string.rs b/crates/shirabe-external-packages/src/symfony/string/unicode_string.rs
index 8917f20..e415846 100644
--- a/crates/shirabe-external-packages/src/symfony/string/unicode_string.rs
+++ b/crates/shirabe-external-packages/src/symfony/string/unicode_string.rs
@@ -34,11 +34,23 @@ impl UnicodeString {
width
}
+ // TODO(phase-d): the faithful `length()` uses `grapheme_strlen` (extended grapheme clusters),
+ // which needs Unicode segmentation tables with no Rust std equivalent and no permitted crate.
+ // Approximated with the code-point count, exact only when no combining/multi-code-point
+ // clusters are present (e.g. ASCII).
pub fn length(&self) -> i64 {
- todo!()
+ shirabe_php_shim::mb_strlen(&self.string, "UTF-8")
}
- pub fn slice(&self, _start: i64, _length: Option<i64>) -> Self {
- todo!()
+ // TODO(phase-d): the faithful `slice()` uses `grapheme_substr` (grapheme-cluster offsets), which
+ // needs Unicode segmentation tables with no std equivalent and no permitted crate. Approximated
+ // with code-point offsets via `mb_substr`, exact only without combining/multi-code-point clusters.
+ pub fn slice(&self, start: i64, length: Option<i64>) -> Self {
+ Self::new(&shirabe_php_shim::mb_substr(
+ &self.string,
+ start,
+ length,
+ Some("UTF-8"),
+ ))
}
}
diff --git a/crates/shirabe-php-shim/src/datetime.rs b/crates/shirabe-php-shim/src/datetime.rs
index 9810966..bddf5da 100644
--- a/crates/shirabe-php-shim/src/datetime.rs
+++ b/crates/shirabe-php-shim/src/datetime.rs
@@ -1,6 +1,10 @@
static DEFAULT_TIMEZONE: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
pub fn date_create<Tz: chrono::TimeZone>(s: &str) -> chrono::ParseResult<chrono::DateTime<Tz>> {
+ // TODO(phase-d): PHP `date_create` accepts the full strtotime() grammar (RFC2822, ISO8601,
+ // VCS-specific and relative formats), which requires a dedicated date parser not available
+ // here. The generic `Tz` also has no constructor from a parsed `FixedOffset`/`Utc` value.
+ let _ = s;
todo!()
}
@@ -22,6 +26,8 @@ pub fn date_format_to_strftime(format: &str) -> &'static str {
}
pub fn strtotime(_time: &str) -> Option<i64> {
+ // TODO(phase-d): requires the full strtotime() grammar (absolute, relative and compound
+ // expressions); a partial parser would silently mis-handle unsupported inputs.
todo!()
}
@@ -53,6 +59,17 @@ pub fn date_default_timezone_set(tz: &str) -> bool {
true
}
-pub fn date(_format: &str, _timestamp: Option<i64>) -> String {
- todo!()
+pub fn date(format: &str, timestamp: Option<i64>) -> String {
+ let timestamp = timestamp.unwrap_or_else(time);
+ // PHP `date()` renders in the default timezone. Without a timezone database only "UTC" can be
+ // resolved; any named zone is rejected loudly rather than silently rendered in the wrong zone.
+ let tz = date_default_timezone_get();
+ if tz != "UTC" {
+ panic!(
+ "date() with non-UTC default timezone {tz:?} is not supported (no timezone database)"
+ );
+ }
+ let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(timestamp, 0)
+ .expect("date() timestamp out of range");
+ dt.format(date_format_to_strftime(format)).to_string()
}
diff --git a/crates/shirabe-php-shim/src/output.rs b/crates/shirabe-php-shim/src/output.rs
index 17f7ab9..698f3bf 100644
--- a/crates/shirabe-php-shim/src/output.rs
+++ b/crates/shirabe-php-shim/src/output.rs
@@ -1,3 +1,6 @@
+// PHP output buffering captures everything the interpreter would echo to stdout. The shim has no
+// general echo-to-buffer routing, and its only producer in Composer (`phpinfo`) depends on PHP
+// runtime configuration that is itself unmodeled, so a buffer here would silently capture nothing.
pub fn ob_start() -> bool {
todo!()
}
diff --git a/crates/shirabe-php-shim/src/stream.rs b/crates/shirabe-php-shim/src/stream.rs
index e5e5200..f5582a7 100644
--- a/crates/shirabe-php-shim/src/stream.rs
+++ b/crates/shirabe-php-shim/src/stream.rs
@@ -13,6 +13,8 @@ pub fn stream_get_contents(stream: &PhpResource) -> Option<String> {
}
pub fn stream_resolve_include_path(filename: &str) -> Option<String> {
+ // TODO(phase-d): resolution searches the `include_path` ini setting, which the shim does not
+ // model; checking only the current directory would silently miss configured include paths.
let _ = filename;
todo!()
}
@@ -60,19 +62,49 @@ fn stream_read_remaining(stream: &PhpResource, max_length: Option<i64>) -> Optio
}
}
+// A stream context is modeled as an object holding the two arrays PHP keeps for it: the per-wrapper
+// `options` and the `params`. This is a self-contained value, so it round-trips through the
+// accessors below without any registry.
pub fn stream_context_create(
- _options: &IndexMap<String, PhpMixed>,
- _params: Option<&IndexMap<String, PhpMixed>>,
+ options: &IndexMap<String, PhpMixed>,
+ params: Option<&IndexMap<String, PhpMixed>>,
) -> PhpMixed {
- todo!()
+ let mut context = IndexMap::new();
+ context.insert("options".to_string(), PhpMixed::Array(options.clone()));
+ context.insert(
+ "params".to_string(),
+ PhpMixed::Array(params.cloned().unwrap_or_default()),
+ );
+ PhpMixed::Object(context)
}
-pub fn stream_context_get_options(_stream_or_context: &PhpMixed) -> IndexMap<String, PhpMixed> {
- todo!()
+pub fn stream_context_get_options(stream_or_context: &PhpMixed) -> IndexMap<String, PhpMixed> {
+ match stream_or_context {
+ PhpMixed::Object(context) | PhpMixed::Array(context) => context
+ .get("options")
+ .and_then(PhpMixed::as_array)
+ .cloned()
+ .unwrap_or_default(),
+ _ => IndexMap::new(),
+ }
}
-pub fn stream_context_get_params(_stream_or_context: &PhpMixed) -> IndexMap<String, PhpMixed> {
- todo!()
+pub fn stream_context_get_params(stream_or_context: &PhpMixed) -> IndexMap<String, PhpMixed> {
+ let (options, params) = match stream_or_context {
+ PhpMixed::Object(context) | PhpMixed::Array(context) => (
+ context.get("options").cloned().unwrap_or_default(),
+ context
+ .get("params")
+ .and_then(PhpMixed::as_array)
+ .cloned()
+ .unwrap_or_default(),
+ ),
+ _ => (PhpMixed::default(), IndexMap::new()),
+ };
+ // PHP exposes the wrapper options under an "options" key alongside the params.
+ let mut result = params;
+ result.insert("options".to_string(), options);
+ result
}
pub fn stream_isatty(stream: PhpResource) -> bool {
@@ -80,6 +112,8 @@ pub fn stream_isatty(stream: PhpResource) -> bool {
}
pub fn stream_get_wrappers() -> Vec<String> {
+ // TODO(phase-d): the registered wrapper set depends on compiled-in extensions and runtime
+ // `stream_wrapper_register` calls; neither is modeled, so a fixed list would be inaccurate.
todo!()
}
diff --git a/crates/shirabe-php-shim/src/zip.rs b/crates/shirabe-php-shim/src/zip.rs
index 08662e6..6b4d87b 100644
--- a/crates/shirabe-php-shim/src/zip.rs
+++ b/crates/shirabe-php-shim/src/zip.rs
@@ -1,9 +1,30 @@
use crate::PhpMixed;
+use crate::{StreamBacking, StreamState};
use indexmap::IndexMap;
+use std::cell::RefCell;
+use zip::write::SimpleFileOptions;
+
+/// Internal backing of an opened `ZipArchive`. PHP's `ZipArchive` multiplexes
+/// reading an existing archive and building a new one through the same handle;
+/// the `zip` crate splits these into `ZipArchive` (read) and `ZipWriter` (write),
+/// so the open mode determines which variant is live.
+#[derive(Debug, Default)]
+enum ZipState {
+ #[default]
+ Closed,
+ Reader(zip::ZipArchive<std::fs::File>),
+ Writer {
+ writer: zip::ZipWriter<std::fs::File>,
+ /// The destination path, retained so `close` can confirm the file exists.
+ path: String,
+ status: String,
+ },
+}
#[derive(Debug)]
pub struct ZipArchive {
pub num_files: i64,
+ state: RefCell<ZipState>,
}
impl Default for ZipArchive {
@@ -14,63 +35,193 @@ impl Default for ZipArchive {
impl ZipArchive {
pub fn new() -> Self {
- todo!()
+ Self {
+ num_files: 0,
+ state: RefCell::new(ZipState::Closed),
+ }
}
- pub fn open(&mut self, _filename: &str, _flags: i64) -> Result<(), i64> {
- todo!()
+ pub fn open(&mut self, filename: &str, flags: i64) -> Result<(), i64> {
+ if flags & Self::CREATE != 0 {
+ let file = match std::fs::File::create(filename) {
+ Ok(f) => f,
+ Err(_) => return Err(Self::ER_OPEN),
+ };
+ *self.state.borrow_mut() = ZipState::Writer {
+ writer: zip::ZipWriter::new(file),
+ path: filename.to_string(),
+ status: String::new(),
+ };
+ self.num_files = 0;
+ Ok(())
+ } else {
+ let file = match std::fs::File::open(filename) {
+ Ok(f) => f,
+ Err(_) => return Err(Self::ER_NOENT),
+ };
+ let archive = match zip::ZipArchive::new(file) {
+ Ok(a) => a,
+ Err(_) => return Err(Self::ER_NOZIP),
+ };
+ self.num_files = archive.len() as i64;
+ *self.state.borrow_mut() = ZipState::Reader(archive);
+ Ok(())
+ }
}
pub fn close(&self) -> bool {
- todo!()
+ let state = std::mem::take(&mut *self.state.borrow_mut());
+ match state {
+ ZipState::Closed => false,
+ ZipState::Reader(_) => true,
+ ZipState::Writer { writer, .. } => writer.finish().is_ok(),
+ }
}
pub fn count(&self) -> i64 {
- todo!()
+ self.num_files
}
- pub fn stat_index(&self, _index: i64) -> Option<IndexMap<String, PhpMixed>> {
- todo!()
+ pub fn stat_index(&self, index: i64) -> Option<IndexMap<String, PhpMixed>> {
+ let mut state = self.state.borrow_mut();
+ let ZipState::Reader(archive) = &mut *state else {
+ return None;
+ };
+ let file = archive.by_index(index as usize).ok()?;
+ let mut stat = IndexMap::new();
+ stat.insert(
+ "name".to_string(),
+ PhpMixed::String(file.name().to_string()),
+ );
+ stat.insert("index".to_string(), PhpMixed::Int(index));
+ stat.insert("crc".to_string(), PhpMixed::Int(file.crc32() as i64));
+ stat.insert("size".to_string(), PhpMixed::Int(file.size() as i64));
+ // PHP exposes the last-modified time as a Unix timestamp. The `zip` crate
+ // only surfaces a 2-second-precision MS-DOS datetime; no consumer reads
+ // this field, so it is reported as 0 rather than reconstructing it.
+ stat.insert("mtime".to_string(), PhpMixed::Int(0));
+ stat.insert(
+ "comp_size".to_string(),
+ PhpMixed::Int(file.compressed_size() as i64),
+ );
+ let comp_method = match file.compression() {
+ zip::CompressionMethod::Stored => 0,
+ zip::CompressionMethod::Deflated => 8,
+ _ => -1,
+ };
+ stat.insert("comp_method".to_string(), PhpMixed::Int(comp_method));
+ Some(stat)
}
- pub fn extract_to(&self, _path: &str) -> bool {
- todo!()
+ pub fn extract_to(&self, path: &str) -> bool {
+ let mut state = self.state.borrow_mut();
+ let ZipState::Reader(archive) = &mut *state else {
+ return false;
+ };
+ archive.extract(path).is_ok()
}
- pub fn locate_name(&self, _name: &str) -> Option<i64> {
- todo!()
+ pub fn locate_name(&self, name: &str) -> Option<i64> {
+ let state = self.state.borrow();
+ let ZipState::Reader(archive) = &*state else {
+ return None;
+ };
+ archive.index_for_name(name).map(|i| i as i64)
}
- pub fn get_from_index(&self, _index: i64) -> Option<String> {
- todo!()
+ pub fn get_from_index(&self, index: i64) -> Option<String> {
+ let mut state = self.state.borrow_mut();
+ let ZipState::Reader(archive) = &mut *state else {
+ return None;
+ };
+ let mut file = archive.by_index(index as usize).ok()?;
+ let mut buf = Vec::new();
+ std::io::Read::read_to_end(&mut file, &mut buf).ok()?;
+ Some(String::from_utf8_lossy(&buf).into_owned())
}
- pub fn get_name_index(&self, _index: i64) -> String {
- todo!()
+ pub fn get_name_index(&self, index: i64) -> String {
+ let mut state = self.state.borrow_mut();
+ let ZipState::Reader(archive) = &mut *state else {
+ return String::new();
+ };
+ match archive.by_index(index as usize) {
+ Ok(file) => file.name().to_string(),
+ Err(_) => String::new(),
+ }
}
- pub fn get_from_name(&self, _name: &str) -> Option<String> {
- todo!()
+ pub fn get_from_name(&self, name: &str) -> Option<String> {
+ let mut state = self.state.borrow_mut();
+ let ZipState::Reader(archive) = &mut *state else {
+ return None;
+ };
+ let mut file = archive.by_name(name).ok()?;
+ let mut buf = Vec::new();
+ std::io::Read::read_to_end(&mut file, &mut buf).ok()?;
+ Some(String::from_utf8_lossy(&buf).into_owned())
}
- pub fn get_stream(&self, _name: &str) -> Option<crate::PhpResource> {
- todo!()
+ pub fn get_stream(&self, name: &str) -> Option<crate::PhpResource> {
+ let mut state = self.state.borrow_mut();
+ let ZipState::Reader(archive) = &mut *state else {
+ return None;
+ };
+ let mut file = archive.by_name(name).ok()?;
+ let mut buf = Vec::new();
+ std::io::Read::read_to_end(&mut file, &mut buf).ok()?;
+ Some(StreamState::new(
+ StreamBacking::Memory(std::io::Cursor::new(buf)),
+ true,
+ false,
+ "r".to_string(),
+ format!("zip://{}", name),
+ ))
}
- pub fn add_empty_dir(&self, _local_name: &str) -> bool {
- todo!()
+ pub fn add_empty_dir(&self, local_name: &str) -> bool {
+ let mut state = self.state.borrow_mut();
+ let ZipState::Writer { writer, .. } = &mut *state else {
+ return false;
+ };
+ writer
+ .add_directory(local_name, SimpleFileOptions::default())
+ .is_ok()
}
- pub fn add_file(&self, _filepath: &str, _local_name: &str) -> bool {
- todo!()
+ pub fn add_file(&self, filepath: &str, local_name: &str) -> bool {
+ let contents = match std::fs::read(filepath) {
+ Ok(c) => c,
+ Err(_) => return false,
+ };
+ let mut state = self.state.borrow_mut();
+ let ZipState::Writer { writer, .. } = &mut *state else {
+ return false;
+ };
+ let options =
+ SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
+ if writer.start_file(local_name, options).is_err() {
+ return false;
+ }
+ std::io::Write::write_all(writer, &contents).is_ok()
}
pub fn set_external_attributes_name(&self, _name: &str, _opsys: i64, _attr: i64) -> bool {
+ // TODO(phase-d): PHP's setExternalAttributesName mutates an already-added
+ // entry's external attributes (e.g. Unix permissions) after addFile. The
+ // `zip` crate fixes external attributes at start_file time via FileOptions
+ // and exposes no API to amend a written entry, so this cannot be faithfully
+ // reproduced without re-architecting add_file. Left unimplemented rather
+ // than silently dropping the permission bits.
todo!()
}
pub fn get_status_string(&self) -> String {
- todo!()
+ let state = self.state.borrow();
+ match &*state {
+ ZipState::Writer { status, .. } => status.clone(),
+ _ => String::new(),
+ }
}
}
diff --git a/crates/shirabe-spdx-licenses/Cargo.toml b/crates/shirabe-spdx-licenses/Cargo.toml
index a1bd9bb..014673e 100644
--- a/crates/shirabe-spdx-licenses/Cargo.toml
+++ b/crates/shirabe-spdx-licenses/Cargo.toml
@@ -5,6 +5,8 @@ edition.workspace = true
[dependencies]
shirabe-php-shim.workspace = true
+indexmap.workspace = true
+serde_json.workspace = true
[lints]
workspace = true
diff --git a/crates/shirabe-spdx-licenses/src/spdx_licenses.rs b/crates/shirabe-spdx-licenses/src/spdx_licenses.rs
index b955ccd..d47b727 100644
--- a/crates/shirabe-spdx-licenses/src/spdx_licenses.rs
+++ b/crates/shirabe-spdx-licenses/src/spdx_licenses.rs
@@ -1,9 +1,26 @@
//! ref: composer/vendor/composer/spdx-licenses/src/SpdxLicenses.php
+use indexmap::IndexMap;
use shirabe_php_shim::PhpMixed;
+pub const LICENSES_FILE: &str = "spdx-licenses.json";
+pub const EXCEPTIONS_FILE: &str = "spdx-exceptions.json";
+
+// PHP reads the resource files from `dirname(__DIR__) . '/res'` at runtime via
+// file_get_contents. Composer's res directory ships with the vendored package; embed it at compile
+// time so the data is available without filesystem access.
+const LICENSES_JSON: &str =
+ include_str!("../../../composer/vendor/composer/spdx-licenses/res/spdx-licenses.json");
+const EXCEPTIONS_JSON: &str =
+ include_str!("../../../composer/vendor/composer/spdx-licenses/res/spdx-exceptions.json");
+
#[derive(Debug)]
-pub struct SpdxLicenses;
+pub struct SpdxLicenses {
+ // [ lowercased license identifier => (identifier, full name, osi certified, deprecated) ]
+ licenses: IndexMap<String, (String, String, bool, bool)>,
+ // [ lowercased exception identifier => (exception identifier, full name) ]
+ exceptions: IndexMap<String, (String, String)>,
+}
impl Default for SpdxLicenses {
fn default() -> Self {
@@ -13,18 +30,135 @@ impl Default for SpdxLicenses {
impl SpdxLicenses {
pub fn new() -> Self {
- todo!()
+ let mut this = SpdxLicenses {
+ licenses: IndexMap::new(),
+ exceptions: IndexMap::new(),
+ };
+ this.load_licenses();
+ this.load_exceptions();
+ this
}
- pub fn validate(&self, _license: &str) -> bool {
- todo!()
- }
+ /// Returns license metadata by license identifier.
+ ///
+ /// The returned list is in the form of:
+ /// [ 0 => full name, 1 => osi certified, 2 => link to license text, 3 => deprecation status ]
+ pub fn get_license_by_identifier(&self, identifier: &str) -> Option<PhpMixed> {
+ let key = identifier.to_lowercase();
- pub fn get_license_by_identifier(&self, _identifier: &str) -> Option<PhpMixed> {
- todo!()
+ let (identifier, name, is_osi_approved, is_deprecated_license_id) =
+ self.licenses.get(&key)?;
+
+ Some(PhpMixed::List(vec![
+ PhpMixed::String(name.clone()),
+ PhpMixed::Bool(*is_osi_approved),
+ PhpMixed::String(format!(
+ "https://spdx.org/licenses/{}.html#licenseText",
+ identifier
+ )),
+ PhpMixed::Bool(*is_deprecated_license_id),
+ ]))
}
+ /// Returns all licenses information, keyed by the lowercased license identifier.
+ ///
+ /// Each item is [ 0 => identifier, 1 => full name, 2 => osi certified, 3 => deprecated ].
pub fn get_licenses(&self) -> PhpMixed {
- todo!()
+ let mut out: IndexMap<String, PhpMixed> = IndexMap::new();
+ for (key, (identifier, name, is_osi_approved, is_deprecated_license_id)) in &self.licenses {
+ out.insert(
+ key.clone(),
+ PhpMixed::List(vec![
+ PhpMixed::String(identifier.clone()),
+ PhpMixed::String(name.clone()),
+ PhpMixed::Bool(*is_osi_approved),
+ PhpMixed::Bool(*is_deprecated_license_id),
+ ]),
+ );
+ }
+ PhpMixed::Array(out)
+ }
+
+ /// Returns license exception metadata by license exception identifier.
+ ///
+ /// The returned list is in the form of:
+ /// [ 0 => full name, 1 => link to license text ]
+ pub fn get_exception_by_identifier(&self, identifier: &str) -> Option<PhpMixed> {
+ let key = identifier.to_lowercase();
+
+ let (identifier, name) = self.exceptions.get(&key)?;
+
+ Some(PhpMixed::List(vec![
+ PhpMixed::String(name.clone()),
+ PhpMixed::String(format!(
+ "https://spdx.org/licenses/{}.html#licenseExceptionText",
+ identifier
+ )),
+ ]))
+ }
+
+ /// Returns the short identifier of a license (or license exception) by full name.
+ pub fn get_identifier_by_name(&self, name: &str) -> Option<String> {
+ for (identifier, full_name, _, _) in self.licenses.values() {
+ if full_name == name {
+ return Some(identifier.clone());
+ }
+ }
+
+ for (identifier, full_name) in self.exceptions.values() {
+ if full_name == name {
+ return Some(identifier.clone());
+ }
+ }
+
+ None
+ }
+
+ /// Returns the OSI Approved status for a license by identifier.
+ pub fn is_osi_approved_by_identifier(&self, identifier: &str) -> bool {
+ self.licenses[&identifier.to_lowercase()].2
+ }
+
+ /// Returns the deprecation status for a license by identifier.
+ pub fn is_deprecated_by_identifier(&self, identifier: &str) -> bool {
+ self.licenses[&identifier.to_lowercase()].3
+ }
+
+ pub fn validate(&self, license: &str) -> bool {
+ // PHP also accepts string[] and joins it with ' OR '. The Rust signature here takes a single
+ // &str, matching the string-input path of the PHP code; the array path lives in callers.
+ self.is_valid_license_string(license)
+ }
+
+ fn load_licenses(&mut self) {
+ let parsed: IndexMap<String, (String, bool, bool)> =
+ serde_json::from_str(LICENSES_JSON).expect("Missing or invalid license file");
+ for (identifier, license) in parsed {
+ self.licenses.insert(
+ identifier.to_lowercase(),
+ (identifier, license.0, license.1, license.2),
+ );
+ }
+ }
+
+ fn load_exceptions(&mut self) {
+ let parsed: IndexMap<String, (String,)> =
+ serde_json::from_str(EXCEPTIONS_JSON).expect("Missing or invalid exceptions file");
+ for (identifier, exception) in parsed {
+ self.exceptions
+ .insert(identifier.to_lowercase(), (identifier, exception.0));
+ }
+ }
+
+ fn is_valid_license_string(&self, license: &str) -> bool {
+ if self.licenses.contains_key(&license.to_lowercase()) {
+ return true;
+ }
+
+ // The remaining validation matches a license expression against a recursive PCRE pattern
+ // (DEFINE subpatterns plus `(?&name)` recursion for compound AND/OR/WITH expressions). The
+ // `regex` crate cannot express recursive subpatterns, so this branch needs a hand-written
+ // SPDX expression parser. Left unported pending that work.
+ todo!("port SPDX license-expression grammar; recursive PCRE not expressible in regex crate")
}
}
diff --git a/crates/shirabe/tests/json/json_file_test.rs b/crates/shirabe/tests/json/json_file_test.rs
index d3e1048..aea34ba 100644
--- a/crates/shirabe/tests/json/json_file_test.rs
+++ b/crates/shirabe/tests/json/json_file_test.rs
@@ -43,70 +43,60 @@ fn create_temp_file() -> tempfile::NamedTempFile {
}
#[test]
-#[ignore]
fn test_parse_error_detect_extra_comma() {
let json = "{\n \"foo\": \"bar\",\n}";
expect_parse_exception("Parse error on line 2", json);
}
#[test]
-#[ignore]
fn test_parse_error_detect_extra_comma_in_array() {
let json = "{\n \"foo\": [\n \"bar\",\n ]\n}";
expect_parse_exception("Parse error on line 3", json);
}
#[test]
-#[ignore]
fn test_parse_error_detect_unescaped_backslash() {
let json = "{\n \"fo\\o\": \"bar\"\n}";
expect_parse_exception("Parse error on line 1", json);
}
#[test]
-#[ignore]
fn test_parse_error_skips_escaped_backslash() {
let json = "{\n \"fo\\\\o\": \"bar\"\n \"a\": \"b\"\n}";
expect_parse_exception("Parse error on line 2", json);
}
#[test]
-#[ignore]
fn test_parse_error_detect_single_quotes() {
let json = "{\n 'foo': \"bar\"\n}";
expect_parse_exception("Parse error on line 1", json);
}
#[test]
-#[ignore]
fn test_parse_error_detect_missing_quotes() {
let json = "{\n foo: \"bar\"\n}";
expect_parse_exception("Parse error on line 1", json);
}
#[test]
-#[ignore]
fn test_parse_error_detect_array_as_hash() {
let json = "{\n \"foo\": [\"bar\": \"baz\"]\n}";
expect_parse_exception("Parse error on line 2", json);
}
#[test]
-#[ignore]
fn test_parse_error_detect_missing_comma() {
let json = "{\n \"foo\": \"bar\"\n \"bar\": \"foo\"\n}";
expect_parse_exception("Parse error on line 2", json);
}
#[test]
-#[ignore]
fn test_parse_error_detect_missing_comma_multiline() {
let json = "{\n \"foo\": \"barbar\"\n\n \"bar\": \"foo\"\n}";
expect_parse_exception("Parse error on line 2", json);
}
#[test]
-#[ignore]
fn test_parse_error_detect_missing_colon() {
let json = "{\n \"foo\": \"bar\",\n \"bar\" \"foo\"\n}";
expect_parse_exception("Parse error on line 3", json);